From 03b1ef8f75f7a4f3066145f7a980b911db6d1e3c Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 05:39:28 +0200 Subject: [PATCH 01/26] feat(invocations): discover node modules recursively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The node loader globbed `*.py` in `invokeai/app/invocations/` and put the stems in `__all__`, which `services/shared/graph.py` then triggers with `import *`. That only ever sees the top directory, so nodes in a subpackage would not be registered — and the failure would not surface at boot but later, as an "unknown node type" when a user opens a workflow that uses one. Walk the whole package tree instead, and import each module eagerly rather than leaving it to `import *`. `__all__` keeps only the top component of each path, because a dotted name cannot be bound by `import *`; the registration this module exists for has already happened by then. `pkgutil.walk_packages` swallows import errors raised while descending into a subpackage, which would turn "this package's `__init__.py` is broken" into "these nodes quietly do not exist" — the exact failure mode being removed here. Pass an `onerror` that re-raises. The walk is parameterized on root and prefix so it can be exercised against a synthetic tree. A walker with a bug here finds nothing and stays green forever, so its test must not depend on the layout of the package it normally walks. No behaviour change on the current flat layout: the generated `openapi.json` is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/invocations/__init__.py | 57 ++++++++++++++++++- tests/app/invocations/test_node_discovery.py | 60 ++++++++++++++++++++ 2 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 tests/app/invocations/test_node_discovery.py diff --git a/invokeai/app/invocations/__init__.py b/invokeai/app/invocations/__init__.py index c8d64437524..86d9e7960a3 100644 --- a/invokeai/app/invocations/__init__.py +++ b/invokeai/app/invocations/__init__.py @@ -1,5 +1,56 @@ +"""Core invocation modules, imported for their side effects. + +Every module below registers its `@invocation`-decorated classes with `InvocationRegistry` as it is +imported, so the app is only correct once *all* of them have been imported. That import is triggered +by `from invokeai.app.invocations import *` in `invokeai.app.services.shared.graph`. + +Discovery walks the whole package tree rather than globbing `*.py` in this directory. Node modules +are grouped into per-architecture subpackages (`flux/`, `wan/`, ...) and cross-cutting ones (`vae/`, +`text_encoder/`, `pid/`), and a flat glob would skip every one of them silently — the failure would +not surface at boot but later, as an "unknown node type" when a user opens a workflow that uses one. +""" + +import pkgutil +from importlib import import_module from pathlib import Path +from types import ModuleType + +_PACKAGE_ROOT = Path(__file__).parent + + +def _on_discovery_error(name: str) -> None: + """Re-raise whatever broke while walking the tree. + + `pkgutil.walk_packages` swallows errors raised while importing a *subpackage* by default, which + would turn "this architecture's `__init__.py` is broken" into "these nodes quietly do not exist". + That is the exact failure mode this module exists to prevent, so refuse to continue. + """ + raise ImportError(f"Failed to walk invocation package {name!r} while discovering nodes.") + + +def discover_node_modules(root: Path = _PACKAGE_ROOT, prefix: str = f"{__name__}.") -> list[str]: + """Fully-qualified names of every non-private module in the package tree rooted at `root`. + + A path component starting with `_` excludes the module: that covers `__pycache__` and marks a + module as internal. Subpackages themselves are skipped — importing them is a side effect of the + walk, and their `__init__.py` files hold no nodes. + + Parameterized on `root`/`prefix` so the walk can be exercised against a synthetic tree. A walker + with a bug here finds nothing and stays green forever, so it needs a test that does not depend + on the layout of this package. + """ + names: list[str] = [] + for info in pkgutil.walk_packages([str(root)], prefix=prefix, onerror=_on_discovery_error): + relative = info.name.removeprefix(prefix) + if info.ispkg or any(part.startswith("_") for part in relative.split(".")): + continue + names.append(info.name) + return names + + +_MODULES: dict[str, ModuleType] = {name: import_module(name) for name in discover_node_modules()} -# add core nodes to __all__ -python_files = filter(lambda f: not f.name.startswith("_"), Path(__file__).parent.glob("*.py")) -__all__ = [f.stem for f in python_files] # type: ignore +# `import *` binds names, and a dotted name is not one. Only the top component of each module path +# is an attribute of this package, so a node in a subpackage contributes that subpackage's name. +# Binding is incidental here anyway — the registration this module exists for already happened above. +__all__ = sorted({name.removeprefix(f"{__name__}.").split(".", 1)[0] for name in _MODULES}) diff --git a/tests/app/invocations/test_node_discovery.py b/tests/app/invocations/test_node_discovery.py new file mode 100644 index 00000000000..1c0e1189bc2 --- /dev/null +++ b/tests/app/invocations/test_node_discovery.py @@ -0,0 +1,60 @@ +"""The invocation walker must find nodes in subpackages, not just in the top directory. + +A walker that silently finds nothing is green forever, so the self-tests below run it against a +synthetic tree whose expected result is written out by hand — independent of how +`invokeai/app/invocations/` happens to be laid out today. +""" + +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from invokeai.app.invocations import _MODULES, discover_node_modules + +PACKAGE = "invokeai.app.invocations" + + +@pytest.fixture +def synthetic_tree(tmp_path: Path) -> Iterator[Path]: + """A miniature invocations package: one flat module, one in a subpackage, plus things to skip.""" + root = tmp_path / "synthetic_nodes" + (root / "arch").mkdir(parents=True) + (root / "__pycache__").mkdir() + (root / "__init__.py").write_text("", encoding="utf-8") + (root / "flat_node.py").write_text("", encoding="utf-8") + (root / "_private.py").write_text("", encoding="utf-8") + (root / "arch" / "__init__.py").write_text("", encoding="utf-8") + (root / "arch" / "nested_node.py").write_text("", encoding="utf-8") + (root / "__pycache__" / "stale.py").write_text("", encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + try: + yield root + finally: + sys.path.remove(str(tmp_path)) + for name in [m for m in sys.modules if m.startswith("synthetic_nodes")]: + del sys.modules[name] + + +def test_walker_descends_into_subpackages(synthetic_tree: Path) -> None: + found = discover_node_modules(synthetic_tree, prefix="synthetic_nodes.") + assert sorted(found) == ["synthetic_nodes.arch.nested_node", "synthetic_nodes.flat_node"] + + +def test_walker_reports_a_broken_subpackage(synthetic_tree: Path) -> None: + (synthetic_tree / "arch" / "__init__.py").write_text("raise RuntimeError('boom')", encoding="utf-8") + with pytest.raises(ImportError, match="synthetic_nodes.arch"): + discover_node_modules(synthetic_tree, prefix="synthetic_nodes.") + + +def test_every_node_module_on_disk_was_imported() -> None: + """Filesystem and registry agree. Catches a subpackage that never got an `__init__.py`.""" + root = Path(__file__).parents[3] / "invokeai" / "app" / "invocations" + on_disk = { + f"{PACKAGE}." + p.relative_to(root).with_suffix("").as_posix().replace("/", ".") + for p in root.rglob("*.py") + if not any(part.startswith("_") for part in p.relative_to(root).parts) + } + assert on_disk == set(_MODULES), "walker and filesystem disagree" From a74566bfa95692fb36a1f34e1cfa79bc69ab9557 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 05:40:33 +0200 Subject: [PATCH 02/26] refactor(invocations): group node modules by architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `invokeai/app/invocations/` held 164 modules in one flat directory, so the files belonging to one architecture were only findable by their name prefix. Move 106 of them into 16 packages; 58 stay flat. Thirteen packages are per-architecture (`flux/`, `wan/`, `minimax_h3/`, ...). Three group by role instead, because those nodes are shared rather than owned: vae/ a VAE follows the VAE, not the architecture — one latent space serves several text_encoder/ encoders are mixed and shared across architectures pid/ PiD decodes and upscales *on top of* a base architecture Filenames are unchanged, so `flux/flux_denoise.py` keeps its prefix. Inside a per-architecture package that is redundant, but the cross-cutting packages mix architectures by design and the prefix is what keeps a file findable by name. The split was derived, not typed: a file moves only if *every* one of its `@invocation` type strings names the same architecture. "Any hit wins" would have put `image.py` under `flux/` on the strength of one `flux_kontext_image_prep` among 36 nodes. Five files cannot be derived and are listed as named overrides — `ideogram4_caption.py` is a string builder rather than an encoder, `pidi.py` is the PiDiNet edge detector and has nothing to do with PiD decoders, `image_to_latents.py` and `latents_to_image.py` are the unprefixed SD VAE nodes, and `wan_latents_to_video.py` is a VAE node whose type string (`wan_l2v`) says otherwise. No `@invocation` type string changes, so persisted workflows keep resolving and the generated `openapi.json` is byte-identical. `test_pid_memory_optimization_wiring.py` globbed the flat directory and asserted the result was non-empty with "has the invocations directory moved?". It had. Recurse. `test_encoder_offload.py` looped over `invocations.__all__` importing each entry, to be sure every node was registered before enumerating the registry. Importing the package now does that; the loop would only have imported the sixteen packages by name. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/invocations/anima/__init__.py | 1 + .../invocations/{ => anima}/anima_denoise.py | 2 +- .../invocations/{ => anima}/anima_lllite.py | 0 .../{ => anima}/anima_lora_loader.py | 0 .../{ => anima}/anima_model_loader.py | 0 invokeai/app/invocations/cogview4/__init__.py | 1 + .../{ => cogview4}/cogview4_denoise.py | 0 .../{ => cogview4}/cogview4_model_loader.py | 0 .../app/invocations/ernie_image/__init__.py | 1 + .../{ => ernie_image}/ernie_image_denoise.py | 0 .../ernie_image_model_loader.py | 0 .../ernie_image_prompt_enhancer.py | 0 invokeai/app/invocations/flux/__init__.py | 1 + .../{ => flux}/flux_control_lora_loader.py | 0 .../invocations/{ => flux}/flux_controlnet.py | 0 .../invocations/{ => flux}/flux_denoise.py | 6 +- .../app/invocations/{ => flux}/flux_fill.py | 0 .../invocations/{ => flux}/flux_ip_adapter.py | 6 +- .../invocations/{ => flux}/flux_kontext.py | 0 .../{ => flux}/flux_lora_loader.py | 0 .../{ => flux}/flux_model_loader.py | 0 .../app/invocations/{ => flux}/flux_redux.py | 0 invokeai/app/invocations/flux2/__init__.py | 1 + .../invocations/{ => flux2}/flux2_denoise.py | 0 .../{ => flux2}/flux2_dev_lora_loader.py | 0 .../{ => flux2}/flux2_dev_model_loader.py | 0 .../{ => flux2}/flux2_klein_lora_loader.py | 0 .../{ => flux2}/flux2_klein_model_loader.py | 0 .../app/invocations/ideogram4/__init__.py | 1 + .../{ => ideogram4}/ideogram4_caption.py | 0 .../{ => ideogram4}/ideogram4_denoise.py | 0 .../{ => ideogram4}/ideogram4_model_loader.py | 0 invokeai/app/invocations/krea2/__init__.py | 1 + .../krea2_conditioning_rebalance.py | 0 .../invocations/{ => krea2}/krea2_denoise.py | 0 .../{ => krea2}/krea2_lora_loader.py | 0 .../{ => krea2}/krea2_model_loader.py | 0 .../{ => krea2}/krea2_seed_variance.py | 0 invokeai/app/invocations/metadata_linked.py | 12 ++-- .../app/invocations/minimax_h3/__init__.py | 1 + .../{ => minimax_h3}/minimax_h3_denoise.py | 0 .../minimax_h3_frame_conditioning.py | 0 .../minimax_h3_ideal_dimensions.py | 0 .../minimax_h3_lora_loader.py | 0 .../minimax_h3_model_loader.py | 0 invokeai/app/invocations/pid/__init__.py | 2 + .../invocations/{ => pid}/flux2_pid_decode.py | 0 .../invocations/{ => pid}/flux_pid_decode.py | 0 .../{ => pid}/pid_decoder_loader.py | 0 .../app/invocations/{ => pid}/pid_upscale.py | 2 +- .../{ => pid}/qwen_image_pid_decode.py | 0 .../invocations/{ => pid}/sd3_pid_decode.py | 0 .../invocations/{ => pid}/sdxl_pid_decode.py | 0 .../{ => pid}/z_image_pid_decode.py | 0 .../app/invocations/qwen_image/__init__.py | 1 + .../{ => qwen_image}/qwen_image_denoise.py | 0 .../qwen_image_lora_loader.py | 0 .../qwen_image_model_loader.py | 0 invokeai/app/invocations/sd/__init__.py | 2 + .../app/invocations/{ => sd}/controlnet.py | 0 .../{ => sd}/create_denoise_mask.py | 2 +- .../{ => sd}/create_gradient_mask.py | 2 +- .../invocations/{ => sd}/denoise_latents.py | 6 +- .../app/invocations/{ => sd}/ip_adapter.py | 0 invokeai/app/invocations/{ => sd}/sdxl.py | 0 .../app/invocations/{ => sd}/t2i_adapter.py | 0 .../tiled_multi_diffusion_denoise_latents.py | 4 +- invokeai/app/invocations/sd3/__init__.py | 1 + .../app/invocations/{ => sd3}/sd3_denoise.py | 2 +- .../invocations/{ => sd3}/sd3_model_loader.py | 0 .../app/invocations/text_encoder/__init__.py | 2 + .../{ => text_encoder}/anima_text_encoder.py | 0 .../cogview4_text_encoder.py | 0 .../invocations/{ => text_encoder}/compel.py | 0 .../ernie_image_text_encoder.py | 0 .../flux2_dev_text_encoder.py | 0 .../flux2_klein_text_encoder.py | 0 .../{ => text_encoder}/flux_text_encoder.py | 0 .../gemma2_encoder_loader.py | 0 .../ideogram4_text_encoder.py | 0 .../{ => text_encoder}/krea2_text_encoder.py | 0 .../minimax_h3_text_encoder.py | 0 .../qwen_image_text_encoder.py | 0 .../{ => text_encoder}/sd3_text_encoder.py | 0 .../{ => text_encoder}/wan_text_encoder.py | 0 .../z_image_text_encoder.py | 0 invokeai/app/invocations/vae/__init__.py | 2 + .../{ => vae}/anima_image_to_latents.py | 0 .../{ => vae}/anima_latents_to_image.py | 0 .../{ => vae}/cogview4_image_to_latents.py | 0 .../{ => vae}/cogview4_latents_to_image.py | 0 .../{ => vae}/ernie_image_vae_decode.py | 0 .../invocations/{ => vae}/flux2_vae_decode.py | 0 .../invocations/{ => vae}/flux2_vae_encode.py | 0 .../invocations/{ => vae}/flux_vae_decode.py | 0 .../invocations/{ => vae}/flux_vae_encode.py | 0 .../{ => vae}/ideogram4_latents_to_image.py | 0 .../invocations/{ => vae}/image_to_latents.py | 0 .../invocations/{ => vae}/latents_to_image.py | 0 .../{ => vae}/minimax_h3_latents_to_image.py | 2 +- .../{ => vae}/minimax_h3_latents_to_video.py | 2 +- .../{ => vae}/qwen_image_image_to_latents.py | 0 .../{ => vae}/qwen_image_latents_to_image.py | 0 .../{ => vae}/sd3_image_to_latents.py | 0 .../{ => vae}/sd3_latents_to_image.py | 0 .../{ => vae}/wan_image_to_latents.py | 0 .../{ => vae}/wan_latents_to_image.py | 0 .../{ => vae}/wan_latents_to_video.py | 0 .../{ => vae}/z_image_image_to_latents.py | 0 .../{ => vae}/z_image_latents_to_image.py | 0 invokeai/app/invocations/wan/__init__.py | 1 + .../app/invocations/{ => wan}/wan_denoise.py | 0 .../{ => wan}/wan_ideal_dimensions.py | 0 .../invocations/{ => wan}/wan_lora_loader.py | 0 .../invocations/{ => wan}/wan_model_loader.py | 0 .../{ => wan}/wan_ref_image_encoder.py | 0 .../{ => wan}/wan_video_denoise.py | 2 +- invokeai/app/invocations/z_image/__init__.py | 1 + .../{ => z_image}/z_image_control.py | 0 .../{ => z_image}/z_image_denoise.py | 4 +- .../{ => z_image}/z_image_lora_loader.py | 0 .../{ => z_image}/z_image_model_loader.py | 0 .../z_image_seed_variance_enhancer.py | 0 .../instantx_controlnet_extension.py | 2 +- .../util/graph/generation/addFLUXRedux.ts | 2 +- pyproject.toml | 4 +- tests/app/invocations/test_anima_denoise.py | 2 +- .../invocations/test_anima_text_encoder.py | 4 +- tests/app/invocations/test_anima_vae.py | 10 ++-- .../invocations/test_cogview4_text_encoder.py | 4 +- tests/app/invocations/test_compel.py | 6 +- .../invocations/test_denoise_noise_inputs.py | 60 +++++++++++-------- .../invocations/test_ernie_image_denoise.py | 2 +- .../test_ernie_image_model_loader.py | 2 +- .../test_ernie_image_prompt_enhancer.py | 14 ++--- .../test_flux2_dev_output_device.py | 2 +- .../test_flux2_klein_model_loader.py | 2 +- .../test_flux2_klein_output_device.py | 2 +- .../test_flux2_model_loader_source_guards.py | 4 +- tests/app/invocations/test_flux_denoise.py | 2 +- .../test_flux_model_loader_self_contained.py | 2 +- .../test_flux_redux_output_device.py | 2 +- tests/app/invocations/test_flux_vae_decode.py | 2 +- ...est_idle_offload_encoder_output_devices.py | 14 ++--- tests/app/invocations/test_krea2_denoise.py | 10 ++-- tests/app/invocations/test_krea2_enhancers.py | 4 +- .../app/invocations/test_krea2_lora_loader.py | 2 +- .../invocations/test_krea2_model_loader.py | 2 +- .../invocations/test_krea2_text_encoder.py | 20 +++---- .../test_minimax_h3_denoise_num_frames.py | 2 +- .../test_minimax_h3_ideal_dimensions.py | 2 +- .../test_minimax_h3_model_loader.py | 2 +- .../test_pid_memory_optimization_wiring.py | 4 +- .../invocations/test_qwen_image_denoise.py | 2 +- .../test_qwen_image_model_loader.py | 2 +- .../test_qwen_image_text_encoder.py | 2 +- .../test_qwen_image_working_memory.py | 22 ++++--- .../app/invocations/test_sd3_text_encoder.py | 6 +- tests/app/invocations/test_wan_denoise.py | 18 +++--- .../invocations/test_wan_expert_swapper.py | 28 ++++----- .../invocations/test_wan_ideal_dimensions.py | 2 +- .../invocations/test_wan_latents_to_image.py | 2 +- .../test_wan_latents_to_video_encoding.py | 2 +- tests/app/invocations/test_wan_lora_loader.py | 2 +- .../app/invocations/test_wan_model_loader.py | 2 +- .../test_wan_ti2v_ideal_dimensions.py | 2 +- .../invocations/test_wan_working_memory.py | 42 ++++++++----- .../invocations/test_z_image_model_loader.py | 2 +- .../test_z_image_working_memory.py | 8 +-- .../session_processor/test_encoder_offload.py | 12 ++-- .../backend/anima/test_control_net_lllite.py | 4 +- tests/backend/anima/test_scheduler_driver.py | 2 +- tests/backend/flux/test_anima_schedulers.py | 8 +-- .../ideogram4/test_caption_builder_node.py | 2 +- .../ideogram4/test_guidance_schedule.py | 2 +- tests/backend/minimax_h3/test_denoise.py | 2 +- 176 files changed, 242 insertions(+), 200 deletions(-) create mode 100644 invokeai/app/invocations/anima/__init__.py rename invokeai/app/invocations/{ => anima}/anima_denoise.py (99%) rename invokeai/app/invocations/{ => anima}/anima_lllite.py (100%) rename invokeai/app/invocations/{ => anima}/anima_lora_loader.py (100%) rename invokeai/app/invocations/{ => anima}/anima_model_loader.py (100%) create mode 100644 invokeai/app/invocations/cogview4/__init__.py rename invokeai/app/invocations/{ => cogview4}/cogview4_denoise.py (100%) rename invokeai/app/invocations/{ => cogview4}/cogview4_model_loader.py (100%) create mode 100644 invokeai/app/invocations/ernie_image/__init__.py rename invokeai/app/invocations/{ => ernie_image}/ernie_image_denoise.py (100%) rename invokeai/app/invocations/{ => ernie_image}/ernie_image_model_loader.py (100%) rename invokeai/app/invocations/{ => ernie_image}/ernie_image_prompt_enhancer.py (100%) create mode 100644 invokeai/app/invocations/flux/__init__.py rename invokeai/app/invocations/{ => flux}/flux_control_lora_loader.py (100%) rename invokeai/app/invocations/{ => flux}/flux_controlnet.py (100%) rename invokeai/app/invocations/{ => flux}/flux_denoise.py (99%) rename invokeai/app/invocations/{ => flux}/flux_fill.py (100%) rename invokeai/app/invocations/{ => flux}/flux_ip_adapter.py (98%) rename invokeai/app/invocations/{ => flux}/flux_kontext.py (100%) rename invokeai/app/invocations/{ => flux}/flux_lora_loader.py (100%) rename invokeai/app/invocations/{ => flux}/flux_model_loader.py (100%) rename invokeai/app/invocations/{ => flux}/flux_redux.py (100%) create mode 100644 invokeai/app/invocations/flux2/__init__.py rename invokeai/app/invocations/{ => flux2}/flux2_denoise.py (100%) rename invokeai/app/invocations/{ => flux2}/flux2_dev_lora_loader.py (100%) rename invokeai/app/invocations/{ => flux2}/flux2_dev_model_loader.py (100%) rename invokeai/app/invocations/{ => flux2}/flux2_klein_lora_loader.py (100%) rename invokeai/app/invocations/{ => flux2}/flux2_klein_model_loader.py (100%) create mode 100644 invokeai/app/invocations/ideogram4/__init__.py rename invokeai/app/invocations/{ => ideogram4}/ideogram4_caption.py (100%) rename invokeai/app/invocations/{ => ideogram4}/ideogram4_denoise.py (100%) rename invokeai/app/invocations/{ => ideogram4}/ideogram4_model_loader.py (100%) create mode 100644 invokeai/app/invocations/krea2/__init__.py rename invokeai/app/invocations/{ => krea2}/krea2_conditioning_rebalance.py (100%) rename invokeai/app/invocations/{ => krea2}/krea2_denoise.py (100%) rename invokeai/app/invocations/{ => krea2}/krea2_lora_loader.py (100%) rename invokeai/app/invocations/{ => krea2}/krea2_model_loader.py (100%) rename invokeai/app/invocations/{ => krea2}/krea2_seed_variance.py (100%) create mode 100644 invokeai/app/invocations/minimax_h3/__init__.py rename invokeai/app/invocations/{ => minimax_h3}/minimax_h3_denoise.py (100%) rename invokeai/app/invocations/{ => minimax_h3}/minimax_h3_frame_conditioning.py (100%) rename invokeai/app/invocations/{ => minimax_h3}/minimax_h3_ideal_dimensions.py (100%) rename invokeai/app/invocations/{ => minimax_h3}/minimax_h3_lora_loader.py (100%) rename invokeai/app/invocations/{ => minimax_h3}/minimax_h3_model_loader.py (100%) create mode 100644 invokeai/app/invocations/pid/__init__.py rename invokeai/app/invocations/{ => pid}/flux2_pid_decode.py (100%) rename invokeai/app/invocations/{ => pid}/flux_pid_decode.py (100%) rename invokeai/app/invocations/{ => pid}/pid_decoder_loader.py (100%) rename invokeai/app/invocations/{ => pid}/pid_upscale.py (99%) rename invokeai/app/invocations/{ => pid}/qwen_image_pid_decode.py (100%) rename invokeai/app/invocations/{ => pid}/sd3_pid_decode.py (100%) rename invokeai/app/invocations/{ => pid}/sdxl_pid_decode.py (100%) rename invokeai/app/invocations/{ => pid}/z_image_pid_decode.py (100%) create mode 100644 invokeai/app/invocations/qwen_image/__init__.py rename invokeai/app/invocations/{ => qwen_image}/qwen_image_denoise.py (100%) rename invokeai/app/invocations/{ => qwen_image}/qwen_image_lora_loader.py (100%) rename invokeai/app/invocations/{ => qwen_image}/qwen_image_model_loader.py (100%) create mode 100644 invokeai/app/invocations/sd/__init__.py rename invokeai/app/invocations/{ => sd}/controlnet.py (100%) rename invokeai/app/invocations/{ => sd}/create_denoise_mask.py (97%) rename invokeai/app/invocations/{ => sd}/create_gradient_mask.py (99%) rename invokeai/app/invocations/{ => sd}/denoise_latents.py (99%) rename invokeai/app/invocations/{ => sd}/ip_adapter.py (100%) rename invokeai/app/invocations/{ => sd}/sdxl.py (100%) rename invokeai/app/invocations/{ => sd}/t2i_adapter.py (100%) rename invokeai/app/invocations/{ => sd}/tiled_multi_diffusion_denoise_latents.py (98%) create mode 100644 invokeai/app/invocations/sd3/__init__.py rename invokeai/app/invocations/{ => sd3}/sd3_denoise.py (99%) rename invokeai/app/invocations/{ => sd3}/sd3_model_loader.py (100%) create mode 100644 invokeai/app/invocations/text_encoder/__init__.py rename invokeai/app/invocations/{ => text_encoder}/anima_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/cogview4_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/compel.py (100%) rename invokeai/app/invocations/{ => text_encoder}/ernie_image_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/flux2_dev_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/flux2_klein_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/flux_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/gemma2_encoder_loader.py (100%) rename invokeai/app/invocations/{ => text_encoder}/ideogram4_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/krea2_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/minimax_h3_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/qwen_image_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/sd3_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/wan_text_encoder.py (100%) rename invokeai/app/invocations/{ => text_encoder}/z_image_text_encoder.py (100%) create mode 100644 invokeai/app/invocations/vae/__init__.py rename invokeai/app/invocations/{ => vae}/anima_image_to_latents.py (100%) rename invokeai/app/invocations/{ => vae}/anima_latents_to_image.py (100%) rename invokeai/app/invocations/{ => vae}/cogview4_image_to_latents.py (100%) rename invokeai/app/invocations/{ => vae}/cogview4_latents_to_image.py (100%) rename invokeai/app/invocations/{ => vae}/ernie_image_vae_decode.py (100%) rename invokeai/app/invocations/{ => vae}/flux2_vae_decode.py (100%) rename invokeai/app/invocations/{ => vae}/flux2_vae_encode.py (100%) rename invokeai/app/invocations/{ => vae}/flux_vae_decode.py (100%) rename invokeai/app/invocations/{ => vae}/flux_vae_encode.py (100%) rename invokeai/app/invocations/{ => vae}/ideogram4_latents_to_image.py (100%) rename invokeai/app/invocations/{ => vae}/image_to_latents.py (100%) rename invokeai/app/invocations/{ => vae}/latents_to_image.py (100%) rename invokeai/app/invocations/{ => vae}/minimax_h3_latents_to_image.py (96%) rename invokeai/app/invocations/{ => vae}/minimax_h3_latents_to_video.py (99%) rename invokeai/app/invocations/{ => vae}/qwen_image_image_to_latents.py (100%) rename invokeai/app/invocations/{ => vae}/qwen_image_latents_to_image.py (100%) rename invokeai/app/invocations/{ => vae}/sd3_image_to_latents.py (100%) rename invokeai/app/invocations/{ => vae}/sd3_latents_to_image.py (100%) rename invokeai/app/invocations/{ => vae}/wan_image_to_latents.py (100%) rename invokeai/app/invocations/{ => vae}/wan_latents_to_image.py (100%) rename invokeai/app/invocations/{ => vae}/wan_latents_to_video.py (100%) rename invokeai/app/invocations/{ => vae}/z_image_image_to_latents.py (100%) rename invokeai/app/invocations/{ => vae}/z_image_latents_to_image.py (100%) create mode 100644 invokeai/app/invocations/wan/__init__.py rename invokeai/app/invocations/{ => wan}/wan_denoise.py (100%) rename invokeai/app/invocations/{ => wan}/wan_ideal_dimensions.py (100%) rename invokeai/app/invocations/{ => wan}/wan_lora_loader.py (100%) rename invokeai/app/invocations/{ => wan}/wan_model_loader.py (100%) rename invokeai/app/invocations/{ => wan}/wan_ref_image_encoder.py (100%) rename invokeai/app/invocations/{ => wan}/wan_video_denoise.py (99%) create mode 100644 invokeai/app/invocations/z_image/__init__.py rename invokeai/app/invocations/{ => z_image}/z_image_control.py (100%) rename invokeai/app/invocations/{ => z_image}/z_image_denoise.py (99%) rename invokeai/app/invocations/{ => z_image}/z_image_lora_loader.py (100%) rename invokeai/app/invocations/{ => z_image}/z_image_model_loader.py (100%) rename invokeai/app/invocations/{ => z_image}/z_image_seed_variance_enhancer.py (100%) diff --git a/invokeai/app/invocations/anima/__init__.py b/invokeai/app/invocations/anima/__init__.py new file mode 100644 index 00000000000..8e32db3e4f5 --- /dev/null +++ b/invokeai/app/invocations/anima/__init__.py @@ -0,0 +1 @@ +"""Anima nodes (Cosmos Predict2 DiT + LLM adapter).""" diff --git a/invokeai/app/invocations/anima_denoise.py b/invokeai/app/invocations/anima/anima_denoise.py similarity index 99% rename from invokeai/app/invocations/anima_denoise.py rename to invokeai/app/invocations/anima/anima_denoise.py index 159be883113..b392fc8bb68 100644 --- a/invokeai/app/invocations/anima_denoise.py +++ b/invokeai/app/invocations/anima/anima_denoise.py @@ -28,7 +28,7 @@ from torchvision.transforms.functional import to_tensor from tqdm import tqdm -from invokeai.app.invocations.anima_lllite import AnimaLLLiteField +from invokeai.app.invocations.anima.anima_lllite import AnimaLLLiteField from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation from invokeai.app.invocations.fields import ( AnimaConditioningField, diff --git a/invokeai/app/invocations/anima_lllite.py b/invokeai/app/invocations/anima/anima_lllite.py similarity index 100% rename from invokeai/app/invocations/anima_lllite.py rename to invokeai/app/invocations/anima/anima_lllite.py diff --git a/invokeai/app/invocations/anima_lora_loader.py b/invokeai/app/invocations/anima/anima_lora_loader.py similarity index 100% rename from invokeai/app/invocations/anima_lora_loader.py rename to invokeai/app/invocations/anima/anima_lora_loader.py diff --git a/invokeai/app/invocations/anima_model_loader.py b/invokeai/app/invocations/anima/anima_model_loader.py similarity index 100% rename from invokeai/app/invocations/anima_model_loader.py rename to invokeai/app/invocations/anima/anima_model_loader.py diff --git a/invokeai/app/invocations/cogview4/__init__.py b/invokeai/app/invocations/cogview4/__init__.py new file mode 100644 index 00000000000..d3a0a9f2760 --- /dev/null +++ b/invokeai/app/invocations/cogview4/__init__.py @@ -0,0 +1 @@ +"""CogView 4 nodes.""" diff --git a/invokeai/app/invocations/cogview4_denoise.py b/invokeai/app/invocations/cogview4/cogview4_denoise.py similarity index 100% rename from invokeai/app/invocations/cogview4_denoise.py rename to invokeai/app/invocations/cogview4/cogview4_denoise.py diff --git a/invokeai/app/invocations/cogview4_model_loader.py b/invokeai/app/invocations/cogview4/cogview4_model_loader.py similarity index 100% rename from invokeai/app/invocations/cogview4_model_loader.py rename to invokeai/app/invocations/cogview4/cogview4_model_loader.py diff --git a/invokeai/app/invocations/ernie_image/__init__.py b/invokeai/app/invocations/ernie_image/__init__.py new file mode 100644 index 00000000000..931107f648e --- /dev/null +++ b/invokeai/app/invocations/ernie_image/__init__.py @@ -0,0 +1 @@ +"""Baidu ERNIE-Image nodes.""" diff --git a/invokeai/app/invocations/ernie_image_denoise.py b/invokeai/app/invocations/ernie_image/ernie_image_denoise.py similarity index 100% rename from invokeai/app/invocations/ernie_image_denoise.py rename to invokeai/app/invocations/ernie_image/ernie_image_denoise.py diff --git a/invokeai/app/invocations/ernie_image_model_loader.py b/invokeai/app/invocations/ernie_image/ernie_image_model_loader.py similarity index 100% rename from invokeai/app/invocations/ernie_image_model_loader.py rename to invokeai/app/invocations/ernie_image/ernie_image_model_loader.py diff --git a/invokeai/app/invocations/ernie_image_prompt_enhancer.py b/invokeai/app/invocations/ernie_image/ernie_image_prompt_enhancer.py similarity index 100% rename from invokeai/app/invocations/ernie_image_prompt_enhancer.py rename to invokeai/app/invocations/ernie_image/ernie_image_prompt_enhancer.py diff --git a/invokeai/app/invocations/flux/__init__.py b/invokeai/app/invocations/flux/__init__.py new file mode 100644 index 00000000000..51c38bb6bf1 --- /dev/null +++ b/invokeai/app/invocations/flux/__init__.py @@ -0,0 +1 @@ +"""FLUX.1 nodes (Dev, Schnell, Fill, Kontext).""" diff --git a/invokeai/app/invocations/flux_control_lora_loader.py b/invokeai/app/invocations/flux/flux_control_lora_loader.py similarity index 100% rename from invokeai/app/invocations/flux_control_lora_loader.py rename to invokeai/app/invocations/flux/flux_control_lora_loader.py diff --git a/invokeai/app/invocations/flux_controlnet.py b/invokeai/app/invocations/flux/flux_controlnet.py similarity index 100% rename from invokeai/app/invocations/flux_controlnet.py rename to invokeai/app/invocations/flux/flux_controlnet.py diff --git a/invokeai/app/invocations/flux_denoise.py b/invokeai/app/invocations/flux/flux_denoise.py similarity index 99% rename from invokeai/app/invocations/flux_denoise.py rename to invokeai/app/invocations/flux/flux_denoise.py index 3f1c0682ea6..c26bf271e67 100644 --- a/invokeai/app/invocations/flux_denoise.py +++ b/invokeai/app/invocations/flux/flux_denoise.py @@ -23,12 +23,12 @@ InputField, LatentsField, ) -from invokeai.app.invocations.flux_controlnet import FluxControlNetField -from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation -from invokeai.app.invocations.ip_adapter import IPAdapterField +from invokeai.app.invocations.flux.flux_controlnet import FluxControlNetField from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape from invokeai.app.invocations.model import ControlLoRAField, LoRAField, TransformerField, VAEField from invokeai.app.invocations.primitives import LatentsOutput +from invokeai.app.invocations.sd.ip_adapter import IPAdapterField +from invokeai.app.invocations.vae.flux_vae_encode import FluxVaeEncodeInvocation from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.flux.controlnet.instantx_controlnet_flux import InstantXControlNetFlux from invokeai.backend.flux.controlnet.xlabs_controlnet_flux import XLabsControlNetFlux diff --git a/invokeai/app/invocations/flux_fill.py b/invokeai/app/invocations/flux/flux_fill.py similarity index 100% rename from invokeai/app/invocations/flux_fill.py rename to invokeai/app/invocations/flux/flux_fill.py diff --git a/invokeai/app/invocations/flux_ip_adapter.py b/invokeai/app/invocations/flux/flux_ip_adapter.py similarity index 98% rename from invokeai/app/invocations/flux_ip_adapter.py rename to invokeai/app/invocations/flux/flux_ip_adapter.py index c0d797d0bdd..6290bf81ca0 100644 --- a/invokeai/app/invocations/flux_ip_adapter.py +++ b/invokeai/app/invocations/flux/flux_ip_adapter.py @@ -6,14 +6,14 @@ from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation from invokeai.app.invocations.fields import InputField -from invokeai.app.invocations.ip_adapter import ( +from invokeai.app.invocations.model import ModelIdentifierField +from invokeai.app.invocations.primitives import ImageField +from invokeai.app.invocations.sd.ip_adapter import ( CLIP_VISION_MODEL_MAP, IPAdapterField, IPAdapterInvocation, IPAdapterOutput, ) -from invokeai.app.invocations.model import ModelIdentifierField -from invokeai.app.invocations.primitives import ImageField from invokeai.app.invocations.util import validate_begin_end_step, validate_weights from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.model_manager.configs.ip_adapter import IPAdapter_Checkpoint_FLUX_Config diff --git a/invokeai/app/invocations/flux_kontext.py b/invokeai/app/invocations/flux/flux_kontext.py similarity index 100% rename from invokeai/app/invocations/flux_kontext.py rename to invokeai/app/invocations/flux/flux_kontext.py diff --git a/invokeai/app/invocations/flux_lora_loader.py b/invokeai/app/invocations/flux/flux_lora_loader.py similarity index 100% rename from invokeai/app/invocations/flux_lora_loader.py rename to invokeai/app/invocations/flux/flux_lora_loader.py diff --git a/invokeai/app/invocations/flux_model_loader.py b/invokeai/app/invocations/flux/flux_model_loader.py similarity index 100% rename from invokeai/app/invocations/flux_model_loader.py rename to invokeai/app/invocations/flux/flux_model_loader.py diff --git a/invokeai/app/invocations/flux_redux.py b/invokeai/app/invocations/flux/flux_redux.py similarity index 100% rename from invokeai/app/invocations/flux_redux.py rename to invokeai/app/invocations/flux/flux_redux.py diff --git a/invokeai/app/invocations/flux2/__init__.py b/invokeai/app/invocations/flux2/__init__.py new file mode 100644 index 00000000000..62b8f208461 --- /dev/null +++ b/invokeai/app/invocations/flux2/__init__.py @@ -0,0 +1 @@ +"""FLUX.2 nodes ([dev] and Klein).""" diff --git a/invokeai/app/invocations/flux2_denoise.py b/invokeai/app/invocations/flux2/flux2_denoise.py similarity index 100% rename from invokeai/app/invocations/flux2_denoise.py rename to invokeai/app/invocations/flux2/flux2_denoise.py diff --git a/invokeai/app/invocations/flux2_dev_lora_loader.py b/invokeai/app/invocations/flux2/flux2_dev_lora_loader.py similarity index 100% rename from invokeai/app/invocations/flux2_dev_lora_loader.py rename to invokeai/app/invocations/flux2/flux2_dev_lora_loader.py diff --git a/invokeai/app/invocations/flux2_dev_model_loader.py b/invokeai/app/invocations/flux2/flux2_dev_model_loader.py similarity index 100% rename from invokeai/app/invocations/flux2_dev_model_loader.py rename to invokeai/app/invocations/flux2/flux2_dev_model_loader.py diff --git a/invokeai/app/invocations/flux2_klein_lora_loader.py b/invokeai/app/invocations/flux2/flux2_klein_lora_loader.py similarity index 100% rename from invokeai/app/invocations/flux2_klein_lora_loader.py rename to invokeai/app/invocations/flux2/flux2_klein_lora_loader.py diff --git a/invokeai/app/invocations/flux2_klein_model_loader.py b/invokeai/app/invocations/flux2/flux2_klein_model_loader.py similarity index 100% rename from invokeai/app/invocations/flux2_klein_model_loader.py rename to invokeai/app/invocations/flux2/flux2_klein_model_loader.py diff --git a/invokeai/app/invocations/ideogram4/__init__.py b/invokeai/app/invocations/ideogram4/__init__.py new file mode 100644 index 00000000000..921003c61fa --- /dev/null +++ b/invokeai/app/invocations/ideogram4/__init__.py @@ -0,0 +1 @@ +"""Ideogram 4 nodes.""" diff --git a/invokeai/app/invocations/ideogram4_caption.py b/invokeai/app/invocations/ideogram4/ideogram4_caption.py similarity index 100% rename from invokeai/app/invocations/ideogram4_caption.py rename to invokeai/app/invocations/ideogram4/ideogram4_caption.py diff --git a/invokeai/app/invocations/ideogram4_denoise.py b/invokeai/app/invocations/ideogram4/ideogram4_denoise.py similarity index 100% rename from invokeai/app/invocations/ideogram4_denoise.py rename to invokeai/app/invocations/ideogram4/ideogram4_denoise.py diff --git a/invokeai/app/invocations/ideogram4_model_loader.py b/invokeai/app/invocations/ideogram4/ideogram4_model_loader.py similarity index 100% rename from invokeai/app/invocations/ideogram4_model_loader.py rename to invokeai/app/invocations/ideogram4/ideogram4_model_loader.py diff --git a/invokeai/app/invocations/krea2/__init__.py b/invokeai/app/invocations/krea2/__init__.py new file mode 100644 index 00000000000..6194b40732c --- /dev/null +++ b/invokeai/app/invocations/krea2/__init__.py @@ -0,0 +1 @@ +"""Krea 2 nodes.""" diff --git a/invokeai/app/invocations/krea2_conditioning_rebalance.py b/invokeai/app/invocations/krea2/krea2_conditioning_rebalance.py similarity index 100% rename from invokeai/app/invocations/krea2_conditioning_rebalance.py rename to invokeai/app/invocations/krea2/krea2_conditioning_rebalance.py diff --git a/invokeai/app/invocations/krea2_denoise.py b/invokeai/app/invocations/krea2/krea2_denoise.py similarity index 100% rename from invokeai/app/invocations/krea2_denoise.py rename to invokeai/app/invocations/krea2/krea2_denoise.py diff --git a/invokeai/app/invocations/krea2_lora_loader.py b/invokeai/app/invocations/krea2/krea2_lora_loader.py similarity index 100% rename from invokeai/app/invocations/krea2_lora_loader.py rename to invokeai/app/invocations/krea2/krea2_lora_loader.py diff --git a/invokeai/app/invocations/krea2_model_loader.py b/invokeai/app/invocations/krea2/krea2_model_loader.py similarity index 100% rename from invokeai/app/invocations/krea2_model_loader.py rename to invokeai/app/invocations/krea2/krea2_model_loader.py diff --git a/invokeai/app/invocations/krea2_seed_variance.py b/invokeai/app/invocations/krea2/krea2_seed_variance.py similarity index 100% rename from invokeai/app/invocations/krea2_seed_variance.py rename to invokeai/app/invocations/krea2/krea2_seed_variance.py diff --git a/invokeai/app/invocations/metadata_linked.py b/invokeai/app/invocations/metadata_linked.py index 3ee70440436..e714e6bc8ff 100644 --- a/invokeai/app/invocations/metadata_linked.py +++ b/invokeai/app/invocations/metadata_linked.py @@ -14,8 +14,6 @@ invocation, invocation_output, ) -from invokeai.app.invocations.controlnet import ControlField, ControlNetInvocation -from invokeai.app.invocations.denoise_latents import DenoiseLatentsInvocation from invokeai.app.invocations.fields import ( FieldDescriptions, ImageField, @@ -26,8 +24,7 @@ UIType, WithMetadata, ) -from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation -from invokeai.app.invocations.ip_adapter import IPAdapterField, IPAdapterInvocation +from invokeai.app.invocations.flux.flux_denoise import FluxDenoiseInvocation from invokeai.app.invocations.metadata import LoRAMetadataField, MetadataOutput from invokeai.app.invocations.model import ( CLIPField, @@ -51,8 +48,11 @@ StringOutput, ) from invokeai.app.invocations.scheduler import SchedulerOutput -from invokeai.app.invocations.t2i_adapter import T2IAdapterField, T2IAdapterInvocation -from invokeai.app.invocations.z_image_denoise import ZImageDenoiseInvocation +from invokeai.app.invocations.sd.controlnet import ControlField, ControlNetInvocation +from invokeai.app.invocations.sd.denoise_latents import DenoiseLatentsInvocation +from invokeai.app.invocations.sd.ip_adapter import IPAdapterField, IPAdapterInvocation +from invokeai.app.invocations.sd.t2i_adapter import T2IAdapterField, T2IAdapterInvocation +from invokeai.app.invocations.z_image.z_image_denoise import ZImageDenoiseInvocation from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES diff --git a/invokeai/app/invocations/minimax_h3/__init__.py b/invokeai/app/invocations/minimax_h3/__init__.py new file mode 100644 index 00000000000..ef5f0a1cde8 --- /dev/null +++ b/invokeai/app/invocations/minimax_h3/__init__.py @@ -0,0 +1 @@ +"""MiniMax H3 (Hailuo 3.0) nodes — video with jointly-denoised audio.""" diff --git a/invokeai/app/invocations/minimax_h3_denoise.py b/invokeai/app/invocations/minimax_h3/minimax_h3_denoise.py similarity index 100% rename from invokeai/app/invocations/minimax_h3_denoise.py rename to invokeai/app/invocations/minimax_h3/minimax_h3_denoise.py diff --git a/invokeai/app/invocations/minimax_h3_frame_conditioning.py b/invokeai/app/invocations/minimax_h3/minimax_h3_frame_conditioning.py similarity index 100% rename from invokeai/app/invocations/minimax_h3_frame_conditioning.py rename to invokeai/app/invocations/minimax_h3/minimax_h3_frame_conditioning.py diff --git a/invokeai/app/invocations/minimax_h3_ideal_dimensions.py b/invokeai/app/invocations/minimax_h3/minimax_h3_ideal_dimensions.py similarity index 100% rename from invokeai/app/invocations/minimax_h3_ideal_dimensions.py rename to invokeai/app/invocations/minimax_h3/minimax_h3_ideal_dimensions.py diff --git a/invokeai/app/invocations/minimax_h3_lora_loader.py b/invokeai/app/invocations/minimax_h3/minimax_h3_lora_loader.py similarity index 100% rename from invokeai/app/invocations/minimax_h3_lora_loader.py rename to invokeai/app/invocations/minimax_h3/minimax_h3_lora_loader.py diff --git a/invokeai/app/invocations/minimax_h3_model_loader.py b/invokeai/app/invocations/minimax_h3/minimax_h3_model_loader.py similarity index 100% rename from invokeai/app/invocations/minimax_h3_model_loader.py rename to invokeai/app/invocations/minimax_h3/minimax_h3_model_loader.py diff --git a/invokeai/app/invocations/pid/__init__.py b/invokeai/app/invocations/pid/__init__.py new file mode 100644 index 00000000000..72c158e7711 --- /dev/null +++ b/invokeai/app/invocations/pid/__init__.py @@ -0,0 +1,2 @@ +"""PiD nodes. PiD decodes and upscales *on top of* a base architecture, so its nodes are +grouped by that role rather than filed under each architecture they serve.""" diff --git a/invokeai/app/invocations/flux2_pid_decode.py b/invokeai/app/invocations/pid/flux2_pid_decode.py similarity index 100% rename from invokeai/app/invocations/flux2_pid_decode.py rename to invokeai/app/invocations/pid/flux2_pid_decode.py diff --git a/invokeai/app/invocations/flux_pid_decode.py b/invokeai/app/invocations/pid/flux_pid_decode.py similarity index 100% rename from invokeai/app/invocations/flux_pid_decode.py rename to invokeai/app/invocations/pid/flux_pid_decode.py diff --git a/invokeai/app/invocations/pid_decoder_loader.py b/invokeai/app/invocations/pid/pid_decoder_loader.py similarity index 100% rename from invokeai/app/invocations/pid_decoder_loader.py rename to invokeai/app/invocations/pid/pid_decoder_loader.py diff --git a/invokeai/app/invocations/pid_upscale.py b/invokeai/app/invocations/pid/pid_upscale.py similarity index 99% rename from invokeai/app/invocations/pid_upscale.py rename to invokeai/app/invocations/pid/pid_upscale.py index de108f749ad..b44d83afd96 100644 --- a/invokeai/app/invocations/pid_upscale.py +++ b/invokeai/app/invocations/pid/pid_upscale.py @@ -33,9 +33,9 @@ WithBoard, WithMetadata, ) -from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation from invokeai.app.invocations.model import Gemma2EncoderField, PiDDecoderField, VAEField from invokeai.app.invocations.primitives import ImageOutput +from invokeai.app.invocations.vae.flux_vae_encode import FluxVaeEncodeInvocation from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.flux.modules.autoencoder import AutoEncoder from invokeai.backend.flux.util import get_flux_ae_params diff --git a/invokeai/app/invocations/qwen_image_pid_decode.py b/invokeai/app/invocations/pid/qwen_image_pid_decode.py similarity index 100% rename from invokeai/app/invocations/qwen_image_pid_decode.py rename to invokeai/app/invocations/pid/qwen_image_pid_decode.py diff --git a/invokeai/app/invocations/sd3_pid_decode.py b/invokeai/app/invocations/pid/sd3_pid_decode.py similarity index 100% rename from invokeai/app/invocations/sd3_pid_decode.py rename to invokeai/app/invocations/pid/sd3_pid_decode.py diff --git a/invokeai/app/invocations/sdxl_pid_decode.py b/invokeai/app/invocations/pid/sdxl_pid_decode.py similarity index 100% rename from invokeai/app/invocations/sdxl_pid_decode.py rename to invokeai/app/invocations/pid/sdxl_pid_decode.py diff --git a/invokeai/app/invocations/z_image_pid_decode.py b/invokeai/app/invocations/pid/z_image_pid_decode.py similarity index 100% rename from invokeai/app/invocations/z_image_pid_decode.py rename to invokeai/app/invocations/pid/z_image_pid_decode.py diff --git a/invokeai/app/invocations/qwen_image/__init__.py b/invokeai/app/invocations/qwen_image/__init__.py new file mode 100644 index 00000000000..42dbb955b69 --- /dev/null +++ b/invokeai/app/invocations/qwen_image/__init__.py @@ -0,0 +1 @@ +"""Qwen-Image nodes.""" diff --git a/invokeai/app/invocations/qwen_image_denoise.py b/invokeai/app/invocations/qwen_image/qwen_image_denoise.py similarity index 100% rename from invokeai/app/invocations/qwen_image_denoise.py rename to invokeai/app/invocations/qwen_image/qwen_image_denoise.py diff --git a/invokeai/app/invocations/qwen_image_lora_loader.py b/invokeai/app/invocations/qwen_image/qwen_image_lora_loader.py similarity index 100% rename from invokeai/app/invocations/qwen_image_lora_loader.py rename to invokeai/app/invocations/qwen_image/qwen_image_lora_loader.py diff --git a/invokeai/app/invocations/qwen_image_model_loader.py b/invokeai/app/invocations/qwen_image/qwen_image_model_loader.py similarity index 100% rename from invokeai/app/invocations/qwen_image_model_loader.py rename to invokeai/app/invocations/qwen_image/qwen_image_model_loader.py diff --git a/invokeai/app/invocations/sd/__init__.py b/invokeai/app/invocations/sd/__init__.py new file mode 100644 index 00000000000..94c919d32f9 --- /dev/null +++ b/invokeai/app/invocations/sd/__init__.py @@ -0,0 +1,2 @@ +"""Stable Diffusion 1.x / 2.x / XL nodes. These share a UNet, a VAE and a conditioning +format, so they are one folder rather than three.""" diff --git a/invokeai/app/invocations/controlnet.py b/invokeai/app/invocations/sd/controlnet.py similarity index 100% rename from invokeai/app/invocations/controlnet.py rename to invokeai/app/invocations/sd/controlnet.py diff --git a/invokeai/app/invocations/create_denoise_mask.py b/invokeai/app/invocations/sd/create_denoise_mask.py similarity index 97% rename from invokeai/app/invocations/create_denoise_mask.py rename to invokeai/app/invocations/sd/create_denoise_mask.py index 419a516bcdc..214f630b0b1 100644 --- a/invokeai/app/invocations/create_denoise_mask.py +++ b/invokeai/app/invocations/sd/create_denoise_mask.py @@ -7,9 +7,9 @@ from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation from invokeai.app.invocations.fields import FieldDescriptions, ImageField, Input, InputField -from invokeai.app.invocations.image_to_latents import ImageToLatentsInvocation from invokeai.app.invocations.model import VAEField from invokeai.app.invocations.primitives import DenoiseMaskOutput +from invokeai.app.invocations.vae.image_to_latents import ImageToLatentsInvocation from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor diff --git a/invokeai/app/invocations/create_gradient_mask.py b/invokeai/app/invocations/sd/create_gradient_mask.py similarity index 99% rename from invokeai/app/invocations/create_gradient_mask.py rename to invokeai/app/invocations/sd/create_gradient_mask.py index 08826cc5efc..98a1813a44c 100644 --- a/invokeai/app/invocations/create_gradient_mask.py +++ b/invokeai/app/invocations/sd/create_gradient_mask.py @@ -17,8 +17,8 @@ InputField, OutputField, ) -from invokeai.app.invocations.image_to_latents import ImageToLatentsInvocation from invokeai.app.invocations.model import UNetField, VAEField +from invokeai.app.invocations.vae.image_to_latents import ImageToLatentsInvocation from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.model_manager.taxonomy import FluxVariantType, ModelType, ModelVariantType from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor diff --git a/invokeai/app/invocations/denoise_latents.py b/invokeai/app/invocations/sd/denoise_latents.py similarity index 99% rename from invokeai/app/invocations/denoise_latents.py rename to invokeai/app/invocations/sd/denoise_latents.py index 2d48dd87607..1aaa3499c83 100644 --- a/invokeai/app/invocations/denoise_latents.py +++ b/invokeai/app/invocations/sd/denoise_latents.py @@ -22,7 +22,6 @@ from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR -from invokeai.app.invocations.controlnet import ControlField from invokeai.app.invocations.fields import ( ConditioningField, DenoiseMaskField, @@ -32,10 +31,11 @@ LatentsField, UIType, ) -from invokeai.app.invocations.ip_adapter import IPAdapterField from invokeai.app.invocations.model import ModelIdentifierField, UNetField from invokeai.app.invocations.primitives import LatentsOutput -from invokeai.app.invocations.t2i_adapter import T2IAdapterField +from invokeai.app.invocations.sd.controlnet import ControlField +from invokeai.app.invocations.sd.ip_adapter import IPAdapterField +from invokeai.app.invocations.sd.t2i_adapter import T2IAdapterField from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.controlnet_utils import prepare_control_image from invokeai.backend.ip_adapter.ip_adapter import IPAdapter diff --git a/invokeai/app/invocations/ip_adapter.py b/invokeai/app/invocations/sd/ip_adapter.py similarity index 100% rename from invokeai/app/invocations/ip_adapter.py rename to invokeai/app/invocations/sd/ip_adapter.py diff --git a/invokeai/app/invocations/sdxl.py b/invokeai/app/invocations/sd/sdxl.py similarity index 100% rename from invokeai/app/invocations/sdxl.py rename to invokeai/app/invocations/sd/sdxl.py diff --git a/invokeai/app/invocations/t2i_adapter.py b/invokeai/app/invocations/sd/t2i_adapter.py similarity index 100% rename from invokeai/app/invocations/t2i_adapter.py rename to invokeai/app/invocations/sd/t2i_adapter.py diff --git a/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py b/invokeai/app/invocations/sd/tiled_multi_diffusion_denoise_latents.py similarity index 98% rename from invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py rename to invokeai/app/invocations/sd/tiled_multi_diffusion_denoise_latents.py index 72e1308e733..7e4e93428ab 100644 --- a/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py +++ b/invokeai/app/invocations/sd/tiled_multi_diffusion_denoise_latents.py @@ -9,8 +9,6 @@ from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR -from invokeai.app.invocations.controlnet import ControlField -from invokeai.app.invocations.denoise_latents import DenoiseLatentsInvocation, get_scheduler from invokeai.app.invocations.fields import ( ConditioningField, FieldDescriptions, @@ -21,6 +19,8 @@ ) from invokeai.app.invocations.model import UNetField from invokeai.app.invocations.primitives import LatentsOutput +from invokeai.app.invocations.sd.controlnet import ControlField +from invokeai.app.invocations.sd.denoise_latents import DenoiseLatentsInvocation, get_scheduler from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.patches.layer_patcher import LayerPatcher, PatchSpec from invokeai.backend.patches.model_patch_raw import ModelPatchRaw diff --git a/invokeai/app/invocations/sd3/__init__.py b/invokeai/app/invocations/sd3/__init__.py new file mode 100644 index 00000000000..96880580de0 --- /dev/null +++ b/invokeai/app/invocations/sd3/__init__.py @@ -0,0 +1 @@ +"""Stable Diffusion 3.5 nodes.""" diff --git a/invokeai/app/invocations/sd3_denoise.py b/invokeai/app/invocations/sd3/sd3_denoise.py similarity index 99% rename from invokeai/app/invocations/sd3_denoise.py rename to invokeai/app/invocations/sd3/sd3_denoise.py index 10c9080ac5e..d512fb284f5 100644 --- a/invokeai/app/invocations/sd3_denoise.py +++ b/invokeai/app/invocations/sd3/sd3_denoise.py @@ -21,7 +21,7 @@ from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape from invokeai.app.invocations.model import TransformerField from invokeai.app.invocations.primitives import LatentsOutput -from invokeai.app.invocations.sd3_text_encoder import SD3_T5_MAX_SEQ_LEN +from invokeai.app.invocations.text_encoder.sd3_text_encoder import SD3_T5_MAX_SEQ_LEN from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.flux.sampling_utils import clip_timestep_schedule_fractional from invokeai.backend.model_manager.taxonomy import BaseModelType diff --git a/invokeai/app/invocations/sd3_model_loader.py b/invokeai/app/invocations/sd3/sd3_model_loader.py similarity index 100% rename from invokeai/app/invocations/sd3_model_loader.py rename to invokeai/app/invocations/sd3/sd3_model_loader.py diff --git a/invokeai/app/invocations/text_encoder/__init__.py b/invokeai/app/invocations/text_encoder/__init__.py new file mode 100644 index 00000000000..3c06a03952d --- /dev/null +++ b/invokeai/app/invocations/text_encoder/__init__.py @@ -0,0 +1,2 @@ +"""Text-encoder nodes. An encoder is picked to match the *encoder*, not the base +architecture — several are shared or mixed across architectures — so they are grouped by that axis.""" diff --git a/invokeai/app/invocations/anima_text_encoder.py b/invokeai/app/invocations/text_encoder/anima_text_encoder.py similarity index 100% rename from invokeai/app/invocations/anima_text_encoder.py rename to invokeai/app/invocations/text_encoder/anima_text_encoder.py diff --git a/invokeai/app/invocations/cogview4_text_encoder.py b/invokeai/app/invocations/text_encoder/cogview4_text_encoder.py similarity index 100% rename from invokeai/app/invocations/cogview4_text_encoder.py rename to invokeai/app/invocations/text_encoder/cogview4_text_encoder.py diff --git a/invokeai/app/invocations/compel.py b/invokeai/app/invocations/text_encoder/compel.py similarity index 100% rename from invokeai/app/invocations/compel.py rename to invokeai/app/invocations/text_encoder/compel.py diff --git a/invokeai/app/invocations/ernie_image_text_encoder.py b/invokeai/app/invocations/text_encoder/ernie_image_text_encoder.py similarity index 100% rename from invokeai/app/invocations/ernie_image_text_encoder.py rename to invokeai/app/invocations/text_encoder/ernie_image_text_encoder.py diff --git a/invokeai/app/invocations/flux2_dev_text_encoder.py b/invokeai/app/invocations/text_encoder/flux2_dev_text_encoder.py similarity index 100% rename from invokeai/app/invocations/flux2_dev_text_encoder.py rename to invokeai/app/invocations/text_encoder/flux2_dev_text_encoder.py diff --git a/invokeai/app/invocations/flux2_klein_text_encoder.py b/invokeai/app/invocations/text_encoder/flux2_klein_text_encoder.py similarity index 100% rename from invokeai/app/invocations/flux2_klein_text_encoder.py rename to invokeai/app/invocations/text_encoder/flux2_klein_text_encoder.py diff --git a/invokeai/app/invocations/flux_text_encoder.py b/invokeai/app/invocations/text_encoder/flux_text_encoder.py similarity index 100% rename from invokeai/app/invocations/flux_text_encoder.py rename to invokeai/app/invocations/text_encoder/flux_text_encoder.py diff --git a/invokeai/app/invocations/gemma2_encoder_loader.py b/invokeai/app/invocations/text_encoder/gemma2_encoder_loader.py similarity index 100% rename from invokeai/app/invocations/gemma2_encoder_loader.py rename to invokeai/app/invocations/text_encoder/gemma2_encoder_loader.py diff --git a/invokeai/app/invocations/ideogram4_text_encoder.py b/invokeai/app/invocations/text_encoder/ideogram4_text_encoder.py similarity index 100% rename from invokeai/app/invocations/ideogram4_text_encoder.py rename to invokeai/app/invocations/text_encoder/ideogram4_text_encoder.py diff --git a/invokeai/app/invocations/krea2_text_encoder.py b/invokeai/app/invocations/text_encoder/krea2_text_encoder.py similarity index 100% rename from invokeai/app/invocations/krea2_text_encoder.py rename to invokeai/app/invocations/text_encoder/krea2_text_encoder.py diff --git a/invokeai/app/invocations/minimax_h3_text_encoder.py b/invokeai/app/invocations/text_encoder/minimax_h3_text_encoder.py similarity index 100% rename from invokeai/app/invocations/minimax_h3_text_encoder.py rename to invokeai/app/invocations/text_encoder/minimax_h3_text_encoder.py diff --git a/invokeai/app/invocations/qwen_image_text_encoder.py b/invokeai/app/invocations/text_encoder/qwen_image_text_encoder.py similarity index 100% rename from invokeai/app/invocations/qwen_image_text_encoder.py rename to invokeai/app/invocations/text_encoder/qwen_image_text_encoder.py diff --git a/invokeai/app/invocations/sd3_text_encoder.py b/invokeai/app/invocations/text_encoder/sd3_text_encoder.py similarity index 100% rename from invokeai/app/invocations/sd3_text_encoder.py rename to invokeai/app/invocations/text_encoder/sd3_text_encoder.py diff --git a/invokeai/app/invocations/wan_text_encoder.py b/invokeai/app/invocations/text_encoder/wan_text_encoder.py similarity index 100% rename from invokeai/app/invocations/wan_text_encoder.py rename to invokeai/app/invocations/text_encoder/wan_text_encoder.py diff --git a/invokeai/app/invocations/z_image_text_encoder.py b/invokeai/app/invocations/text_encoder/z_image_text_encoder.py similarity index 100% rename from invokeai/app/invocations/z_image_text_encoder.py rename to invokeai/app/invocations/text_encoder/z_image_text_encoder.py diff --git a/invokeai/app/invocations/vae/__init__.py b/invokeai/app/invocations/vae/__init__.py new file mode 100644 index 00000000000..b4c38dea1b9 --- /dev/null +++ b/invokeai/app/invocations/vae/__init__.py @@ -0,0 +1,2 @@ +"""VAE nodes (encode/decode). A VAE follows the VAE, not the architecture: the same latent +space is served by several architectures, so these are grouped by that axis.""" diff --git a/invokeai/app/invocations/anima_image_to_latents.py b/invokeai/app/invocations/vae/anima_image_to_latents.py similarity index 100% rename from invokeai/app/invocations/anima_image_to_latents.py rename to invokeai/app/invocations/vae/anima_image_to_latents.py diff --git a/invokeai/app/invocations/anima_latents_to_image.py b/invokeai/app/invocations/vae/anima_latents_to_image.py similarity index 100% rename from invokeai/app/invocations/anima_latents_to_image.py rename to invokeai/app/invocations/vae/anima_latents_to_image.py diff --git a/invokeai/app/invocations/cogview4_image_to_latents.py b/invokeai/app/invocations/vae/cogview4_image_to_latents.py similarity index 100% rename from invokeai/app/invocations/cogview4_image_to_latents.py rename to invokeai/app/invocations/vae/cogview4_image_to_latents.py diff --git a/invokeai/app/invocations/cogview4_latents_to_image.py b/invokeai/app/invocations/vae/cogview4_latents_to_image.py similarity index 100% rename from invokeai/app/invocations/cogview4_latents_to_image.py rename to invokeai/app/invocations/vae/cogview4_latents_to_image.py diff --git a/invokeai/app/invocations/ernie_image_vae_decode.py b/invokeai/app/invocations/vae/ernie_image_vae_decode.py similarity index 100% rename from invokeai/app/invocations/ernie_image_vae_decode.py rename to invokeai/app/invocations/vae/ernie_image_vae_decode.py diff --git a/invokeai/app/invocations/flux2_vae_decode.py b/invokeai/app/invocations/vae/flux2_vae_decode.py similarity index 100% rename from invokeai/app/invocations/flux2_vae_decode.py rename to invokeai/app/invocations/vae/flux2_vae_decode.py diff --git a/invokeai/app/invocations/flux2_vae_encode.py b/invokeai/app/invocations/vae/flux2_vae_encode.py similarity index 100% rename from invokeai/app/invocations/flux2_vae_encode.py rename to invokeai/app/invocations/vae/flux2_vae_encode.py diff --git a/invokeai/app/invocations/flux_vae_decode.py b/invokeai/app/invocations/vae/flux_vae_decode.py similarity index 100% rename from invokeai/app/invocations/flux_vae_decode.py rename to invokeai/app/invocations/vae/flux_vae_decode.py diff --git a/invokeai/app/invocations/flux_vae_encode.py b/invokeai/app/invocations/vae/flux_vae_encode.py similarity index 100% rename from invokeai/app/invocations/flux_vae_encode.py rename to invokeai/app/invocations/vae/flux_vae_encode.py diff --git a/invokeai/app/invocations/ideogram4_latents_to_image.py b/invokeai/app/invocations/vae/ideogram4_latents_to_image.py similarity index 100% rename from invokeai/app/invocations/ideogram4_latents_to_image.py rename to invokeai/app/invocations/vae/ideogram4_latents_to_image.py diff --git a/invokeai/app/invocations/image_to_latents.py b/invokeai/app/invocations/vae/image_to_latents.py similarity index 100% rename from invokeai/app/invocations/image_to_latents.py rename to invokeai/app/invocations/vae/image_to_latents.py diff --git a/invokeai/app/invocations/latents_to_image.py b/invokeai/app/invocations/vae/latents_to_image.py similarity index 100% rename from invokeai/app/invocations/latents_to_image.py rename to invokeai/app/invocations/vae/latents_to_image.py diff --git a/invokeai/app/invocations/minimax_h3_latents_to_image.py b/invokeai/app/invocations/vae/minimax_h3_latents_to_image.py similarity index 96% rename from invokeai/app/invocations/minimax_h3_latents_to_image.py rename to invokeai/app/invocations/vae/minimax_h3_latents_to_image.py index 4606920b78f..2d81ae54263 100644 --- a/invokeai/app/invocations/minimax_h3_latents_to_image.py +++ b/invokeai/app/invocations/vae/minimax_h3_latents_to_image.py @@ -16,9 +16,9 @@ WithBoard, WithMetadata, ) -from invokeai.app.invocations.minimax_h3_latents_to_video import decode_video_latents from invokeai.app.invocations.model import VAEField from invokeai.app.invocations.primitives import ImageOutput +from invokeai.app.invocations.vae.minimax_h3_latents_to_video import decode_video_latents from invokeai.app.services.shared.invocation_context import InvocationContext diff --git a/invokeai/app/invocations/minimax_h3_latents_to_video.py b/invokeai/app/invocations/vae/minimax_h3_latents_to_video.py similarity index 99% rename from invokeai/app/invocations/minimax_h3_latents_to_video.py rename to invokeai/app/invocations/vae/minimax_h3_latents_to_video.py index bbaf8e1bc80..db63265657d 100644 --- a/invokeai/app/invocations/minimax_h3_latents_to_video.py +++ b/invokeai/app/invocations/vae/minimax_h3_latents_to_video.py @@ -24,7 +24,7 @@ ) from invokeai.app.invocations.model import VAEField from invokeai.app.invocations.primitives import VideoOutput -from invokeai.app.invocations.wan_latents_to_video import _write_video_frames +from invokeai.app.invocations.vae.wan_latents_to_video import _write_video_frames from invokeai.app.services.session_processor.session_processor_common import CanceledException from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.video_encoding import make_mp4_writer, write_stereo_wav diff --git a/invokeai/app/invocations/qwen_image_image_to_latents.py b/invokeai/app/invocations/vae/qwen_image_image_to_latents.py similarity index 100% rename from invokeai/app/invocations/qwen_image_image_to_latents.py rename to invokeai/app/invocations/vae/qwen_image_image_to_latents.py diff --git a/invokeai/app/invocations/qwen_image_latents_to_image.py b/invokeai/app/invocations/vae/qwen_image_latents_to_image.py similarity index 100% rename from invokeai/app/invocations/qwen_image_latents_to_image.py rename to invokeai/app/invocations/vae/qwen_image_latents_to_image.py diff --git a/invokeai/app/invocations/sd3_image_to_latents.py b/invokeai/app/invocations/vae/sd3_image_to_latents.py similarity index 100% rename from invokeai/app/invocations/sd3_image_to_latents.py rename to invokeai/app/invocations/vae/sd3_image_to_latents.py diff --git a/invokeai/app/invocations/sd3_latents_to_image.py b/invokeai/app/invocations/vae/sd3_latents_to_image.py similarity index 100% rename from invokeai/app/invocations/sd3_latents_to_image.py rename to invokeai/app/invocations/vae/sd3_latents_to_image.py diff --git a/invokeai/app/invocations/wan_image_to_latents.py b/invokeai/app/invocations/vae/wan_image_to_latents.py similarity index 100% rename from invokeai/app/invocations/wan_image_to_latents.py rename to invokeai/app/invocations/vae/wan_image_to_latents.py diff --git a/invokeai/app/invocations/wan_latents_to_image.py b/invokeai/app/invocations/vae/wan_latents_to_image.py similarity index 100% rename from invokeai/app/invocations/wan_latents_to_image.py rename to invokeai/app/invocations/vae/wan_latents_to_image.py diff --git a/invokeai/app/invocations/wan_latents_to_video.py b/invokeai/app/invocations/vae/wan_latents_to_video.py similarity index 100% rename from invokeai/app/invocations/wan_latents_to_video.py rename to invokeai/app/invocations/vae/wan_latents_to_video.py diff --git a/invokeai/app/invocations/z_image_image_to_latents.py b/invokeai/app/invocations/vae/z_image_image_to_latents.py similarity index 100% rename from invokeai/app/invocations/z_image_image_to_latents.py rename to invokeai/app/invocations/vae/z_image_image_to_latents.py diff --git a/invokeai/app/invocations/z_image_latents_to_image.py b/invokeai/app/invocations/vae/z_image_latents_to_image.py similarity index 100% rename from invokeai/app/invocations/z_image_latents_to_image.py rename to invokeai/app/invocations/vae/z_image_latents_to_image.py diff --git a/invokeai/app/invocations/wan/__init__.py b/invokeai/app/invocations/wan/__init__.py new file mode 100644 index 00000000000..597cafe6e5f --- /dev/null +++ b/invokeai/app/invocations/wan/__init__.py @@ -0,0 +1 @@ +"""Wan 2.1 / 2.2 nodes.""" diff --git a/invokeai/app/invocations/wan_denoise.py b/invokeai/app/invocations/wan/wan_denoise.py similarity index 100% rename from invokeai/app/invocations/wan_denoise.py rename to invokeai/app/invocations/wan/wan_denoise.py diff --git a/invokeai/app/invocations/wan_ideal_dimensions.py b/invokeai/app/invocations/wan/wan_ideal_dimensions.py similarity index 100% rename from invokeai/app/invocations/wan_ideal_dimensions.py rename to invokeai/app/invocations/wan/wan_ideal_dimensions.py diff --git a/invokeai/app/invocations/wan_lora_loader.py b/invokeai/app/invocations/wan/wan_lora_loader.py similarity index 100% rename from invokeai/app/invocations/wan_lora_loader.py rename to invokeai/app/invocations/wan/wan_lora_loader.py diff --git a/invokeai/app/invocations/wan_model_loader.py b/invokeai/app/invocations/wan/wan_model_loader.py similarity index 100% rename from invokeai/app/invocations/wan_model_loader.py rename to invokeai/app/invocations/wan/wan_model_loader.py diff --git a/invokeai/app/invocations/wan_ref_image_encoder.py b/invokeai/app/invocations/wan/wan_ref_image_encoder.py similarity index 100% rename from invokeai/app/invocations/wan_ref_image_encoder.py rename to invokeai/app/invocations/wan/wan_ref_image_encoder.py diff --git a/invokeai/app/invocations/wan_video_denoise.py b/invokeai/app/invocations/wan/wan_video_denoise.py similarity index 99% rename from invokeai/app/invocations/wan_video_denoise.py rename to invokeai/app/invocations/wan/wan_video_denoise.py index 0d76d27c108..bb23f3e14a1 100644 --- a/invokeai/app/invocations/wan_video_denoise.py +++ b/invokeai/app/invocations/wan/wan_video_denoise.py @@ -27,7 +27,7 @@ ) from invokeai.app.invocations.model import WanTransformerField from invokeai.app.invocations.primitives import LatentsOutput -from invokeai.app.invocations.wan_denoise import ( +from invokeai.app.invocations.wan.wan_denoise import ( WAN_MAX_RESIDENT_TRANSFORMER_BYTES, WanDenoiseInvocation, _ExpertSwapper, diff --git a/invokeai/app/invocations/z_image/__init__.py b/invokeai/app/invocations/z_image/__init__.py new file mode 100644 index 00000000000..9f49b6685b5 --- /dev/null +++ b/invokeai/app/invocations/z_image/__init__.py @@ -0,0 +1 @@ +"""Z-Image nodes, including Z-Image-Turbo.""" diff --git a/invokeai/app/invocations/z_image_control.py b/invokeai/app/invocations/z_image/z_image_control.py similarity index 100% rename from invokeai/app/invocations/z_image_control.py rename to invokeai/app/invocations/z_image/z_image_control.py diff --git a/invokeai/app/invocations/z_image_denoise.py b/invokeai/app/invocations/z_image/z_image_denoise.py similarity index 99% rename from invokeai/app/invocations/z_image_denoise.py rename to invokeai/app/invocations/z_image/z_image_denoise.py index 07658fe96eb..5af637b3dbd 100644 --- a/invokeai/app/invocations/z_image_denoise.py +++ b/invokeai/app/invocations/z_image/z_image_denoise.py @@ -24,8 +24,8 @@ from invokeai.app.invocations.latent_noise import validate_noise_tensor_shape from invokeai.app.invocations.model import TransformerField, VAEField from invokeai.app.invocations.primitives import LatentsOutput -from invokeai.app.invocations.z_image_control import ZImageControlField -from invokeai.app.invocations.z_image_image_to_latents import ZImageImageToLatentsInvocation +from invokeai.app.invocations.vae.z_image_image_to_latents import ZImageImageToLatentsInvocation +from invokeai.app.invocations.z_image.z_image_control import ZImageControlField from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.backend.flux.schedulers import ZIMAGE_SCHEDULER_LABELS, ZIMAGE_SCHEDULER_MAP, ZIMAGE_SCHEDULER_NAME_VALUES from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat diff --git a/invokeai/app/invocations/z_image_lora_loader.py b/invokeai/app/invocations/z_image/z_image_lora_loader.py similarity index 100% rename from invokeai/app/invocations/z_image_lora_loader.py rename to invokeai/app/invocations/z_image/z_image_lora_loader.py diff --git a/invokeai/app/invocations/z_image_model_loader.py b/invokeai/app/invocations/z_image/z_image_model_loader.py similarity index 100% rename from invokeai/app/invocations/z_image_model_loader.py rename to invokeai/app/invocations/z_image/z_image_model_loader.py diff --git a/invokeai/app/invocations/z_image_seed_variance_enhancer.py b/invokeai/app/invocations/z_image/z_image_seed_variance_enhancer.py similarity index 100% rename from invokeai/app/invocations/z_image_seed_variance_enhancer.py rename to invokeai/app/invocations/z_image/z_image_seed_variance_enhancer.py diff --git a/invokeai/backend/flux/extensions/instantx_controlnet_extension.py b/invokeai/backend/flux/extensions/instantx_controlnet_extension.py index f03d2d21aa3..d7d774c8cda 100644 --- a/invokeai/backend/flux/extensions/instantx_controlnet_extension.py +++ b/invokeai/backend/flux/extensions/instantx_controlnet_extension.py @@ -5,7 +5,7 @@ from PIL.Image import Image from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR -from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation +from invokeai.app.invocations.vae.flux_vae_encode import FluxVaeEncodeInvocation from invokeai.app.util.controlnet_utils import CONTROLNET_RESIZE_VALUES, prepare_control_image from invokeai.backend.flux.controlnet.controlnet_flux_output import ControlNetFluxOutput from invokeai.backend.flux.controlnet.instantx_controlnet_flux import ( diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts index 10ae0b66c99..a46d5c40d6d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/addFLUXRedux.ts @@ -44,7 +44,7 @@ export const addFLUXReduxes = ({ entities, g, collector, model }: AddFLUXReduxAr * - downsampling_function: the function used to downsample the image. Defaults to 'area'. Dunno about how it affects the image. * - weight: 0 to 1. the conditioning is multiplied by the square of this value. 1 means no change. * - * See invokeai/app/invocations/flux_redux.py for more details. + * See invokeai/app/invocations/flux/flux_redux.py for more details. */ export const IMAGE_INFLUENCE_TO_SETTINGS: Record< FLUXReduxImageInfluence, diff --git a/pyproject.toml b/pyproject.toml index 5bd6ee1540a..bf3c588ce4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -398,8 +398,8 @@ exclude = ["tests/*"] follow_imports = "skip" # skips type checking of the modules listed below module = [ "invokeai.app.api.routers.models", - "invokeai.app.invocations.compel", - "invokeai.app.invocations.denoise_latents", + "invokeai.app.invocations.text_encoder.compel", + "invokeai.app.invocations.sd.denoise_latents", "invokeai.app.services.invocation_stats.invocation_stats_default", "invokeai.app.services.model_manager.model_manager_base", "invokeai.app.services.model_manager.model_manager_default", diff --git a/tests/app/invocations/test_anima_denoise.py b/tests/app/invocations/test_anima_denoise.py index b377066df60..38cfa298cb7 100644 --- a/tests/app/invocations/test_anima_denoise.py +++ b/tests/app/invocations/test_anima_denoise.py @@ -3,7 +3,7 @@ import pytest import torch -from invokeai.app.invocations.anima_denoise import ( +from invokeai.app.invocations.anima.anima_denoise import ( ANIMA_LATENT_CHANNELS, ANIMA_LATENT_SCALE_FACTOR, ANIMA_SHIFT, diff --git a/tests/app/invocations/test_anima_text_encoder.py b/tests/app/invocations/test_anima_text_encoder.py index 4d92c69ce04..8a5cf19299f 100644 --- a/tests/app/invocations/test_anima_text_encoder.py +++ b/tests/app/invocations/test_anima_text_encoder.py @@ -4,7 +4,7 @@ import torch -from invokeai.app.invocations.anima_text_encoder import AnimaTextEncoderInvocation +from invokeai.app.invocations.text_encoder.anima_text_encoder import AnimaTextEncoderInvocation class FakeQwen3Encoder(torch.nn.Module): @@ -64,7 +64,7 @@ def model_on_device(self): def _run_encode(monkeypatch, compute_device: torch.device) -> FakeQwen3Encoder: - module_path = "invokeai.app.invocations.anima_text_encoder" + module_path = "invokeai.app.invocations.text_encoder.anima_text_encoder" text_encoder = FakeQwen3Encoder() tokenizer = FakeQwen3Tokenizer() diff --git a/tests/app/invocations/test_anima_vae.py b/tests/app/invocations/test_anima_vae.py index e8d5740d49b..ecd5ab820f9 100644 --- a/tests/app/invocations/test_anima_vae.py +++ b/tests/app/invocations/test_anima_vae.py @@ -8,13 +8,13 @@ import torch from diffusers.models.autoencoders import AutoencoderKLWan -from invokeai.app.invocations.anima_image_to_latents import AnimaImageToLatentsInvocation -from invokeai.app.invocations.anima_latents_to_image import ( +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.app.invocations.vae.anima_image_to_latents import AnimaImageToLatentsInvocation +from invokeai.app.invocations.vae.anima_latents_to_image import ( ANIMA_VAE_TILE_SIZE, ANIMA_VAE_TILE_STRIDE, AnimaLatentsToImageInvocation, ) -from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR from invokeai.backend.util.devices import TorchDevice from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_anima @@ -175,7 +175,7 @@ def test_decode_requests_estimated_working_memory(self): decoded = torch.zeros(1, 3, 1, 64, 64) vae, vae_info, context = _build_decode_mocks(latents=torch.zeros(1, 16, 32, 32), decoded=decoded) - estimation_path = "invokeai.app.invocations.anima_latents_to_image.estimate_vae_working_memory_anima" + estimation_path = "invokeai.app.invocations.vae.anima_latents_to_image.estimate_vae_working_memory_anima" expected_memory = 1024 * 1024 * 500 with ( patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")), @@ -204,7 +204,7 @@ def test_encode_disables_tiling_and_requests_working_memory(self): cm.__exit__ = MagicMock(return_value=None) vae_info.model_on_device.return_value = cm - estimation_path = "invokeai.app.invocations.anima_image_to_latents.estimate_vae_working_memory_anima" + estimation_path = "invokeai.app.invocations.vae.anima_image_to_latents.estimate_vae_working_memory_anima" expected_memory = 1024 * 1024 * 250 with ( patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")), diff --git a/tests/app/invocations/test_cogview4_text_encoder.py b/tests/app/invocations/test_cogview4_text_encoder.py index df203717926..62c3716842c 100644 --- a/tests/app/invocations/test_cogview4_text_encoder.py +++ b/tests/app/invocations/test_cogview4_text_encoder.py @@ -4,7 +4,7 @@ import torch -from invokeai.app.invocations.cogview4_text_encoder import CogView4TextEncoderInvocation +from invokeai.app.invocations.text_encoder.cogview4_text_encoder import CogView4TextEncoderInvocation class FakeGlmModel(torch.nn.Module): @@ -72,7 +72,7 @@ def test_cogview4_text_encoder_repairs_model_before_forward(monkeypatch): glm_encoder=SimpleNamespace(text_encoder=SimpleNamespace(), tokenizer=SimpleNamespace()), ) - module_path = "invokeai.app.invocations.cogview4_text_encoder" + module_path = "invokeai.app.invocations.text_encoder.cogview4_text_encoder" monkeypatch.setattr(f"{module_path}.GlmModel", FakeGlmModel) monkeypatch.setattr(f"{module_path}.PreTrainedTokenizerFast", FakeTokenizer) diff --git a/tests/app/invocations/test_compel.py b/tests/app/invocations/test_compel.py index ba89702e85f..5ef1c4b1245 100644 --- a/tests/app/invocations/test_compel.py +++ b/tests/app/invocations/test_compel.py @@ -4,7 +4,7 @@ import torch -from invokeai.app.invocations.compel import SDXLPromptInvocationBase +from invokeai.app.invocations.text_encoder.compel import SDXLPromptInvocationBase class FakeClipTextEncoder(torch.nn.Module): @@ -73,7 +73,7 @@ def test_sdxl_run_clip_compel_uses_compute_device_for_partially_loaded_model(mon # Regression test for #9373: the encoder's weights are all offloaded to CPU (effective device == CPU), but its # intended compute device is the accelerator. compel must build on the intended compute device, not the current # residency, or the whole encode silently runs on the CPU. - module_path = "invokeai.app.invocations.compel" + module_path = "invokeai.app.invocations.text_encoder.compel" compute_device = torch.device("meta") text_encoder = FakeClipTextEncoder(effective_device=torch.device("cpu")) tokenizer = FakeTokenizer() @@ -115,7 +115,7 @@ def test_sdxl_run_clip_compel_uses_compute_device_for_partially_loaded_model(mon def test_sdxl_run_clip_compel_uses_cpu_for_cpu_only_model(monkeypatch): # A cpu_only text encoder has compute_device == CPU; compel must build on the CPU. - module_path = "invokeai.app.invocations.compel" + module_path = "invokeai.app.invocations.text_encoder.compel" text_encoder = FakeClipTextEncoder(effective_device=torch.device("cpu")) tokenizer = FakeTokenizer() text_encoder_info = FakeLoadedModel( diff --git a/tests/app/invocations/test_denoise_noise_inputs.py b/tests/app/invocations/test_denoise_noise_inputs.py index eb11568f961..73089c96331 100644 --- a/tests/app/invocations/test_denoise_noise_inputs.py +++ b/tests/app/invocations/test_denoise_noise_inputs.py @@ -5,18 +5,18 @@ import pytest import torch -from invokeai.app.invocations.anima_denoise import AnimaDenoiseInvocation -from invokeai.app.invocations.cogview4_denoise import CogView4DenoiseInvocation -from invokeai.app.invocations.flux2_denoise import Flux2DenoiseInvocation -from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation +from invokeai.app.invocations.anima.anima_denoise import AnimaDenoiseInvocation +from invokeai.app.invocations.cogview4.cogview4_denoise import CogView4DenoiseInvocation +from invokeai.app.invocations.flux.flux_denoise import FluxDenoiseInvocation +from invokeai.app.invocations.flux2.flux2_denoise import Flux2DenoiseInvocation from invokeai.app.invocations.metadata_linked import ( DenoiseLatentsMetaInvocation, FluxDenoiseLatentsMetaInvocation, ZImageDenoiseMetaInvocation, ) from invokeai.app.invocations.primitives import LatentsOutput -from invokeai.app.invocations.sd3_denoise import SD3DenoiseInvocation -from invokeai.app.invocations.z_image_denoise import ZImageDenoiseInvocation +from invokeai.app.invocations.sd3.sd3_denoise import SD3DenoiseInvocation +from invokeai.app.invocations.z_image.z_image_denoise import ZImageDenoiseInvocation from invokeai.backend.flux.sampling_utils import clip_timestep_schedule_fractional, get_schedule from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP, FLUX_SCHEDULER_MAP, ZIMAGE_SCHEDULER_MAP from invokeai.backend.flux2.sampling_utils import get_schedule_flux2 @@ -31,7 +31,7 @@ def test_flux_prepare_noise_uses_external_noise(): expected = torch.zeros(1, 16, 8, 8) mock_context.tensors.load.return_value = expected - with patch("invokeai.app.invocations.flux_denoise.get_noise") as mock_get_noise: + with patch("invokeai.app.invocations.flux.flux_denoise.get_noise") as mock_get_noise: noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu")) assert torch.equal(noise, expected.to(dtype=torch.bfloat16)) @@ -78,16 +78,17 @@ def test_flux_add_noise_false_ignores_connected_noise(): with ( patch( - "invokeai.app.invocations.flux_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu") + "invokeai.app.invocations.flux.flux_denoise.TorchDevice.choose_torch_device", + return_value=torch.device("cpu"), ), - patch("invokeai.app.invocations.flux_denoise.FLUXConditioningInfo", object), + patch("invokeai.app.invocations.flux.flux_denoise.FLUXConditioningInfo", object), patch( - "invokeai.app.invocations.flux_denoise.RegionalPromptingExtension.from_text_conditioning", + "invokeai.app.invocations.flux.flux_denoise.RegionalPromptingExtension.from_text_conditioning", return_value=MagicMock(), ), patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")), patch.object(invocation, "_load_redux_conditioning", return_value=[]), - patch("invokeai.app.invocations.flux_denoise.get_schedule", return_value=[0.75]), + patch("invokeai.app.invocations.flux.flux_denoise.get_schedule", return_value=[0.75]), ): result = invocation._run_diffusion(mock_context) @@ -102,7 +103,7 @@ def test_flux2_prepare_noise_uses_external_noise(): expected = torch.zeros(1, 32, 8, 8) mock_context.tensors.load.return_value = expected - with patch("invokeai.app.invocations.flux2_denoise.get_noise_flux2") as mock_get_noise: + with patch("invokeai.app.invocations.flux2.flux2_denoise.get_noise_flux2") as mock_get_noise: noise = invocation._prepare_noise_tensor(mock_context, torch.bfloat16, torch.device("cpu")) assert torch.equal(noise, expected.to(dtype=torch.bfloat16)) @@ -226,15 +227,16 @@ def test_z_image_add_noise_false_ignores_connected_noise(): with ( patch( - "invokeai.app.invocations.z_image_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu") + "invokeai.app.invocations.z_image.z_image_denoise.TorchDevice.choose_torch_device", + return_value=torch.device("cpu"), ), patch( - "invokeai.app.invocations.z_image_denoise.TorchDevice.choose_bfloat16_safe_dtype", + "invokeai.app.invocations.z_image.z_image_denoise.TorchDevice.choose_bfloat16_safe_dtype", return_value=torch.bfloat16, ), - patch("invokeai.app.invocations.z_image_denoise.ZImageConditioningInfo", object), + patch("invokeai.app.invocations.z_image.z_image_denoise.ZImageConditioningInfo", object), patch( - "invokeai.app.invocations.z_image_denoise.ZImageRegionalPromptingExtension.from_text_conditionings", + "invokeai.app.invocations.z_image.z_image_denoise.ZImageRegionalPromptingExtension.from_text_conditionings", return_value=regional_extension, ), patch.object(invocation, "_load_text_conditioning", return_value=loaded_text_conditioning), @@ -295,10 +297,12 @@ def test_anima_add_noise_false_ignores_connected_noise(): with ( patch( - "invokeai.app.invocations.anima_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu") + "invokeai.app.invocations.anima.anima_denoise.TorchDevice.choose_torch_device", + return_value=torch.device("cpu"), ), patch( - "invokeai.app.invocations.anima_denoise.TorchDevice.choose_bfloat16_safe_dtype", return_value=torch.bfloat16 + "invokeai.app.invocations.anima.anima_denoise.TorchDevice.choose_bfloat16_safe_dtype", + return_value=torch.bfloat16, ), patch.object(invocation, "_load_text_conditionings", return_value=loaded_text_conditioning), patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")), @@ -338,9 +342,10 @@ def test_flux2_add_noise_false_ignores_connected_noise(): with ( patch( - "invokeai.app.invocations.flux2_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu") + "invokeai.app.invocations.flux2.flux2_denoise.TorchDevice.choose_torch_device", + return_value=torch.device("cpu"), ), - patch("invokeai.app.invocations.flux2_denoise.FLUXConditioningInfo", object), + patch("invokeai.app.invocations.flux2.flux2_denoise.FLUXConditioningInfo", object), patch.object(invocation, "_get_bn_stats", return_value=None), patch.object(invocation, "_prepare_noise_tensor", side_effect=AssertionError("noise should be ignored")), ): @@ -498,9 +503,10 @@ def test_flux2_partial_denoise_short_circuit_uses_first_clipped_timestep(): with ( patch( - "invokeai.app.invocations.flux2_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu") + "invokeai.app.invocations.flux2.flux2_denoise.TorchDevice.choose_torch_device", + return_value=torch.device("cpu"), ), - patch("invokeai.app.invocations.flux2_denoise.FLUXConditioningInfo", object), + patch("invokeai.app.invocations.flux2.flux2_denoise.FLUXConditioningInfo", object), patch.object(invocation, "_get_bn_stats", return_value=None), patch.object(invocation, "_prepare_noise_tensor", return_value=noise), ): @@ -647,8 +653,10 @@ def test_sd3_partial_denoise_short_circuit_uses_first_clipped_timestep(): ) with ( - patch("invokeai.app.invocations.sd3_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu")), - patch("invokeai.app.invocations.sd3_denoise.TorchDevice.choose_torch_dtype", return_value=torch.float32), + patch( + "invokeai.app.invocations.sd3.sd3_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu") + ), + patch("invokeai.app.invocations.sd3.sd3_denoise.TorchDevice.choose_torch_dtype", return_value=torch.float32), patch.object(invocation, "_prepare_noise_tensor", return_value=noise), patch.object(invocation, "_load_text_conditioning", return_value=(torch.zeros(1, 1, 1), torch.zeros(1, 1))), ): @@ -680,9 +688,9 @@ def test_cogview4_partial_denoise_short_circuit_uses_first_clipped_sigma(): mock_context.models.load.return_value = MagicMock(model=transformer_model) with ( - patch("invokeai.app.invocations.cogview4_denoise.CogView4Transformer2DModel", object), + patch("invokeai.app.invocations.cogview4.cogview4_denoise.CogView4Transformer2DModel", object), patch( - "invokeai.app.invocations.cogview4_denoise.TorchDevice.choose_torch_device", + "invokeai.app.invocations.cogview4.cogview4_denoise.TorchDevice.choose_torch_device", return_value=torch.device("cpu"), ), patch.object(invocation, "_prepare_noise_tensor", return_value=noise), diff --git a/tests/app/invocations/test_ernie_image_denoise.py b/tests/app/invocations/test_ernie_image_denoise.py index 57eb414fb0f..477b539ab84 100644 --- a/tests/app/invocations/test_ernie_image_denoise.py +++ b/tests/app/invocations/test_ernie_image_denoise.py @@ -16,7 +16,7 @@ import pytest import torch -from invokeai.app.invocations.ernie_image_denoise import ErnieImageDenoiseInvocation +from invokeai.app.invocations.ernie_image.ernie_image_denoise import ErnieImageDenoiseInvocation from invokeai.app.invocations.fields import LatentsField diff --git a/tests/app/invocations/test_ernie_image_model_loader.py b/tests/app/invocations/test_ernie_image_model_loader.py index 80ca0b7453d..2f7675fc385 100644 --- a/tests/app/invocations/test_ernie_image_model_loader.py +++ b/tests/app/invocations/test_ernie_image_model_loader.py @@ -13,7 +13,7 @@ import pytest -from invokeai.app.invocations.ernie_image_model_loader import ErnieImageModelLoaderInvocation +from invokeai.app.invocations.ernie_image.ernie_image_model_loader import ErnieImageModelLoaderInvocation _FULL_INDEX = { "_class_name": "ErnieImagePipeline", diff --git a/tests/app/invocations/test_ernie_image_prompt_enhancer.py b/tests/app/invocations/test_ernie_image_prompt_enhancer.py index cb0b55d8694..10addb98918 100644 --- a/tests/app/invocations/test_ernie_image_prompt_enhancer.py +++ b/tests/app/invocations/test_ernie_image_prompt_enhancer.py @@ -15,7 +15,7 @@ def test_prompt_enhancer_is_not_idle_gpu_offloadable(): """The whole point of the split. Marking this node would reintroduce the stall the split fixes.""" - from invokeai.app.invocations.ernie_image_prompt_enhancer import ErnieImagePromptEnhancerInvocation + from invokeai.app.invocations.ernie_image.ernie_image_prompt_enhancer import ErnieImagePromptEnhancerInvocation assert ErnieImagePromptEnhancerInvocation.idle_gpu_offloadable is False @@ -23,7 +23,7 @@ def test_prompt_enhancer_is_not_idle_gpu_offloadable(): def test_text_encoder_no_longer_carries_enhancer_fields(): """If the enhancer is ever merged back into the encoder, the encoder's `idle_gpu_offloadable=True` silently becomes wrong again — nothing else would fail.""" - from invokeai.app.invocations.ernie_image_text_encoder import ErnieImageTextEncoderInvocation + from invokeai.app.invocations.text_encoder.ernie_image_text_encoder import ErnieImageTextEncoderInvocation fields = set(ErnieImageTextEncoderInvocation.model_fields) assert ErnieImageTextEncoderInvocation.idle_gpu_offloadable is True @@ -35,7 +35,7 @@ def test_text_encoder_no_longer_carries_enhancer_fields(): def test_prompt_passes_through_when_no_enhancer_is_connected(): """The loader emits `prompt_enhancer=None` for a pipeline that ships no PE submodel. That must not load a model or fail — the graph builder still wires this node when the toggle is on.""" - from invokeai.app.invocations.ernie_image_prompt_enhancer import ErnieImagePromptEnhancerInvocation + from invokeai.app.invocations.ernie_image.ernie_image_prompt_enhancer import ErnieImagePromptEnhancerInvocation invocation = ErnieImagePromptEnhancerInvocation.model_construct(prompt="a prompt", prompt_enhancer=None) context = MagicMock() @@ -54,8 +54,8 @@ def test_generation_is_capped_and_cancelable(monkeypatch): not a bound. - a `StoppingCriteria` is passed, so a cancel takes effect mid-rewrite rather than after it. """ - import invokeai.app.invocations.ernie_image_prompt_enhancer as pe_module - from invokeai.app.invocations.ernie_image_prompt_enhancer import ( + import invokeai.app.invocations.ernie_image.ernie_image_prompt_enhancer as pe_module + from invokeai.app.invocations.ernie_image.ernie_image_prompt_enhancer import ( PE_MAX_NEW_TOKENS, ErnieImagePromptEnhancerInvocation, ) @@ -101,8 +101,8 @@ def _load(identifier): def test_cancel_discards_the_partial_rewrite(monkeypatch): """The stopping criterion makes `generate()` return a truncated sequence. Encoding that would silently generate an image from half a prompt, so the node must raise instead.""" - import invokeai.app.invocations.ernie_image_prompt_enhancer as pe_module - from invokeai.app.invocations.ernie_image_prompt_enhancer import ErnieImagePromptEnhancerInvocation + import invokeai.app.invocations.ernie_image.ernie_image_prompt_enhancer as pe_module + from invokeai.app.invocations.ernie_image.ernie_image_prompt_enhancer import ErnieImagePromptEnhancerInvocation from invokeai.app.services.session_processor.session_processor_common import CanceledException invocation = ErnieImagePromptEnhancerInvocation.model_construct( diff --git a/tests/app/invocations/test_flux2_dev_output_device.py b/tests/app/invocations/test_flux2_dev_output_device.py index fe81cb0f69d..68f4472bcdc 100644 --- a/tests/app/invocations/test_flux2_dev_output_device.py +++ b/tests/app/invocations/test_flux2_dev_output_device.py @@ -8,7 +8,7 @@ import torch -from invokeai.app.invocations.flux2_dev_text_encoder import Flux2DevTextEncoderInvocation +from invokeai.app.invocations.text_encoder.flux2_dev_text_encoder import Flux2DevTextEncoderInvocation def test_flux2_dev_conditioning_is_saved_on_cpu(monkeypatch): diff --git a/tests/app/invocations/test_flux2_klein_model_loader.py b/tests/app/invocations/test_flux2_klein_model_loader.py index 8366bccf182..3bd20232e9c 100644 --- a/tests/app/invocations/test_flux2_klein_model_loader.py +++ b/tests/app/invocations/test_flux2_klein_model_loader.py @@ -11,7 +11,7 @@ import pytest -from invokeai.app.invocations.flux2_klein_model_loader import Flux2KleinModelLoaderInvocation +from invokeai.app.invocations.flux2.flux2_klein_model_loader import Flux2KleinModelLoaderInvocation from invokeai.app.invocations.model import ModelIdentifierField from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, SubModelType diff --git a/tests/app/invocations/test_flux2_klein_output_device.py b/tests/app/invocations/test_flux2_klein_output_device.py index 4ea96473925..9945cbeaaab 100644 --- a/tests/app/invocations/test_flux2_klein_output_device.py +++ b/tests/app/invocations/test_flux2_klein_output_device.py @@ -8,7 +8,7 @@ import torch -from invokeai.app.invocations.flux2_klein_text_encoder import Flux2KleinTextEncoderInvocation +from invokeai.app.invocations.text_encoder.flux2_klein_text_encoder import Flux2KleinTextEncoderInvocation def test_flux2_klein_conditioning_is_saved_on_cpu(monkeypatch): diff --git a/tests/app/invocations/test_flux2_model_loader_source_guards.py b/tests/app/invocations/test_flux2_model_loader_source_guards.py index 7e312076ef1..82fac315e2c 100644 --- a/tests/app/invocations/test_flux2_model_loader_source_guards.py +++ b/tests/app/invocations/test_flux2_model_loader_source_guards.py @@ -22,8 +22,8 @@ import pytest -from invokeai.app.invocations.flux2_dev_model_loader import Flux2DevModelLoaderInvocation -from invokeai.app.invocations.flux2_klein_model_loader import Flux2KleinModelLoaderInvocation +from invokeai.app.invocations.flux2.flux2_dev_model_loader import Flux2DevModelLoaderInvocation +from invokeai.app.invocations.flux2.flux2_klein_model_loader import Flux2KleinModelLoaderInvocation from invokeai.app.invocations.model import ModelIdentifierField from invokeai.backend.model_manager.taxonomy import ( BaseModelType, diff --git a/tests/app/invocations/test_flux_denoise.py b/tests/app/invocations/test_flux_denoise.py index 412ef7a490a..97444295fee 100644 --- a/tests/app/invocations/test_flux_denoise.py +++ b/tests/app/invocations/test_flux_denoise.py @@ -1,6 +1,6 @@ import pytest -from invokeai.app.invocations.flux_denoise import FluxDenoiseInvocation +from invokeai.app.invocations.flux.flux_denoise import FluxDenoiseInvocation TIMESTEPS = [1.0, 0.75, 0.5, 0.25, 0.0] diff --git a/tests/app/invocations/test_flux_model_loader_self_contained.py b/tests/app/invocations/test_flux_model_loader_self_contained.py index b3d423c70a4..c345f5fd1e0 100644 --- a/tests/app/invocations/test_flux_model_loader_self_contained.py +++ b/tests/app/invocations/test_flux_model_loader_self_contained.py @@ -11,7 +11,7 @@ import pytest -from invokeai.app.invocations.flux_model_loader import FluxModelLoaderInvocation +from invokeai.app.invocations.flux.flux_model_loader import FluxModelLoaderInvocation from invokeai.app.invocations.model import ModelIdentifierField from invokeai.backend.model_manager.configs.main import Main_SDNQ_Diffusers_FLUX_Config from invokeai.backend.model_manager.taxonomy import ( diff --git a/tests/app/invocations/test_flux_redux_output_device.py b/tests/app/invocations/test_flux_redux_output_device.py index 109c9b84f0d..b32b4a452e6 100644 --- a/tests/app/invocations/test_flux_redux_output_device.py +++ b/tests/app/invocations/test_flux_redux_output_device.py @@ -8,7 +8,7 @@ import torch from invokeai.app.invocations.fields import ImageField -from invokeai.app.invocations.flux_redux import FluxReduxInvocation +from invokeai.app.invocations.flux.flux_redux import FluxReduxInvocation def test_flux_redux_conditioning_is_saved_on_cpu(monkeypatch): diff --git a/tests/app/invocations/test_flux_vae_decode.py b/tests/app/invocations/test_flux_vae_decode.py index 8f52ae794dc..4d7c5e152c0 100644 --- a/tests/app/invocations/test_flux_vae_decode.py +++ b/tests/app/invocations/test_flux_vae_decode.py @@ -12,7 +12,7 @@ import torch from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL -from invokeai.app.invocations.flux_vae_decode import FluxVaeDecodeInvocation +from invokeai.app.invocations.vae.flux_vae_decode import FluxVaeDecodeInvocation def _loaded_vae(shift_factor: float | None, scaling_factor: float = 0.3611) -> MagicMock: diff --git a/tests/app/invocations/test_idle_offload_encoder_output_devices.py b/tests/app/invocations/test_idle_offload_encoder_output_devices.py index 5049294f055..1cfebcaaeb4 100644 --- a/tests/app/invocations/test_idle_offload_encoder_output_devices.py +++ b/tests/app/invocations/test_idle_offload_encoder_output_devices.py @@ -22,7 +22,7 @@ def _gpu_tensor_yielding(cpu_tensor: MagicMock) -> MagicMock: def test_wan_conditioning_is_saved_on_cpu(monkeypatch): - from invokeai.app.invocations.wan_text_encoder import WanTextEncoderInvocation + from invokeai.app.invocations.text_encoder.wan_text_encoder import WanTextEncoderInvocation invocation = WanTextEncoderInvocation.model_construct(prompt="a prompt", wan_t5_encoder=MagicMock()) @@ -47,7 +47,7 @@ def test_wan_conditioning_is_saved_on_cpu(monkeypatch): def test_wan_conditioning_tolerates_a_full_attention_mask(monkeypatch): """_encode returns None for the mask when every token is valid; that must not blow up the CPU move.""" - from invokeai.app.invocations.wan_text_encoder import WanTextEncoderInvocation + from invokeai.app.invocations.text_encoder.wan_text_encoder import WanTextEncoderInvocation invocation = WanTextEncoderInvocation.model_construct(prompt="a prompt", wan_t5_encoder=MagicMock()) @@ -65,7 +65,7 @@ def test_wan_conditioning_tolerates_a_full_attention_mask(monkeypatch): def test_krea2_conditioning_is_saved_on_cpu(monkeypatch): - from invokeai.app.invocations.krea2_text_encoder import Krea2TextEncoderInvocation + from invokeai.app.invocations.text_encoder.krea2_text_encoder import Krea2TextEncoderInvocation invocation = Krea2TextEncoderInvocation.model_construct(prompt="a prompt", mask=None, qwen3_vl_encoder=MagicMock()) @@ -92,7 +92,7 @@ def test_krea2_regional_mask_is_passed_through_untouched(monkeypatch): must forward it as-is and leave the load/device placement to krea2_denoise. Touching it here would resolve a tensor onto the borrowed GPU.""" from invokeai.app.invocations.fields import TensorField - from invokeai.app.invocations.krea2_text_encoder import Krea2TextEncoderInvocation + from invokeai.app.invocations.text_encoder.krea2_text_encoder import Krea2TextEncoderInvocation mask_field = TensorField(tensor_name="regional-mask") invocation = Krea2TextEncoderInvocation.model_construct( @@ -112,8 +112,8 @@ def test_krea2_regional_mask_is_passed_through_untouched(monkeypatch): def test_ideogram4_conditioning_is_saved_on_cpu(monkeypatch): - import invokeai.app.invocations.ideogram4_text_encoder as ideogram4_module - from invokeai.app.invocations.ideogram4_text_encoder import Ideogram4TextEncoderInvocation + import invokeai.app.invocations.text_encoder.ideogram4_text_encoder as ideogram4_module + from invokeai.app.invocations.text_encoder.ideogram4_text_encoder import Ideogram4TextEncoderInvocation invocation = Ideogram4TextEncoderInvocation.model_construct(prompt="a prompt", qwen3_encoder=MagicMock()) @@ -134,7 +134,7 @@ def test_ideogram4_conditioning_is_saved_on_cpu(monkeypatch): def test_ernie_image_conditioning_is_saved_on_cpu(monkeypatch): - from invokeai.app.invocations.ernie_image_text_encoder import ErnieImageTextEncoderInvocation + from invokeai.app.invocations.text_encoder.ernie_image_text_encoder import ErnieImageTextEncoderInvocation invocation = ErnieImageTextEncoderInvocation.model_construct(prompt="a prompt", text_encoder=MagicMock()) diff --git a/tests/app/invocations/test_krea2_denoise.py b/tests/app/invocations/test_krea2_denoise.py index 670bac2d67d..5112510afb1 100644 --- a/tests/app/invocations/test_krea2_denoise.py +++ b/tests/app/invocations/test_krea2_denoise.py @@ -6,7 +6,7 @@ import torch from invokeai.app.invocations.fields import DenoiseMaskField, Krea2ConditioningField, LatentsField, TensorField -from invokeai.app.invocations.krea2_denoise import KREA2_LATENT_CHANNELS, Krea2DenoiseInvocation +from invokeai.app.invocations.krea2.krea2_denoise import KREA2_LATENT_CHANNELS, Krea2DenoiseInvocation from invokeai.app.invocations.model import ModelIdentifierField, TransformerField from invokeai.backend.model_manager.taxonomy import BaseModelType, Krea2VariantType, ModelFormat, ModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData, Krea2ConditioningInfo @@ -406,14 +406,14 @@ def _patch_runtime(monkeypatch) -> None: "diffusers.schedulers.scheduling_flow_match_euler_discrete.FlowMatchEulerDiscreteScheduler", _Scheduler ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_denoise.TorchDevice.choose_torch_device", lambda: torch.device("cpu") + "invokeai.app.invocations.krea2.krea2_denoise.TorchDevice.choose_torch_device", lambda: torch.device("cpu") ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_denoise.TorchDevice.choose_bfloat16_safe_dtype", + "invokeai.app.invocations.krea2.krea2_denoise.TorchDevice.choose_bfloat16_safe_dtype", lambda _device: torch.float32, ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.krea2.krea2_denoise.LayerPatcher.apply_smart_model_patches", lambda **_kwargs: nullcontext(), ) @@ -524,7 +524,7 @@ def merge_intermediate_latents_with_init_latents(self, latents, sigma): merge_sigmas.append(sigma) return latents - monkeypatch.setattr("invokeai.app.invocations.krea2_denoise.RectifiedFlowInpaintExtension", _InpaintExtension) + monkeypatch.setattr("invokeai.app.invocations.krea2.krea2_denoise.RectifiedFlowInpaintExtension", _InpaintExtension) latents = _runtime_invocation(cfg_scale=1.0, with_mask=True)._run_diffusion(_runtime_context(tmp_path, transformer)) diff --git a/tests/app/invocations/test_krea2_enhancers.py b/tests/app/invocations/test_krea2_enhancers.py index 4eb5c4ac5dc..1b6784d48b4 100644 --- a/tests/app/invocations/test_krea2_enhancers.py +++ b/tests/app/invocations/test_krea2_enhancers.py @@ -12,8 +12,8 @@ import torch from invokeai.app.invocations.fields import Krea2ConditioningField, TensorField -from invokeai.app.invocations.krea2_conditioning_rebalance import Krea2ConditioningRebalanceInvocation -from invokeai.app.invocations.krea2_seed_variance import Krea2SeedVarianceInvocation +from invokeai.app.invocations.krea2.krea2_conditioning_rebalance import Krea2ConditioningRebalanceInvocation +from invokeai.app.invocations.krea2.krea2_seed_variance import Krea2SeedVarianceInvocation from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData, Krea2ConditioningInfo diff --git a/tests/app/invocations/test_krea2_lora_loader.py b/tests/app/invocations/test_krea2_lora_loader.py index 63087fc2073..06300967f94 100644 --- a/tests/app/invocations/test_krea2_lora_loader.py +++ b/tests/app/invocations/test_krea2_lora_loader.py @@ -2,7 +2,7 @@ import pytest -from invokeai.app.invocations.krea2_lora_loader import Krea2LoRACollectionLoader, Krea2LoRALoaderInvocation +from invokeai.app.invocations.krea2.krea2_lora_loader import Krea2LoRACollectionLoader, Krea2LoRALoaderInvocation from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, Qwen3VLEncoderField, TransformerField from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType diff --git a/tests/app/invocations/test_krea2_model_loader.py b/tests/app/invocations/test_krea2_model_loader.py index 21432ffa9d7..15a94d716aa 100644 --- a/tests/app/invocations/test_krea2_model_loader.py +++ b/tests/app/invocations/test_krea2_model_loader.py @@ -2,7 +2,7 @@ import pytest -from invokeai.app.invocations.krea2_model_loader import Krea2ModelLoaderInvocation +from invokeai.app.invocations.krea2.krea2_model_loader import Krea2ModelLoaderInvocation from invokeai.app.invocations.model import ModelIdentifierField from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType diff --git a/tests/app/invocations/test_krea2_text_encoder.py b/tests/app/invocations/test_krea2_text_encoder.py index c2686713735..34a02efc762 100644 --- a/tests/app/invocations/test_krea2_text_encoder.py +++ b/tests/app/invocations/test_krea2_text_encoder.py @@ -5,8 +5,8 @@ import torch from invokeai.app.invocations.fields import TensorField -from invokeai.app.invocations.krea2_text_encoder import Krea2TextEncoderInvocation from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, Qwen3VLEncoderField +from invokeai.app.invocations.text_encoder.krea2_text_encoder import Krea2TextEncoderInvocation from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, SubModelType from invokeai.backend.patches.lora_conversions.krea2_lora_constants import KREA2_LORA_QWEN3VL_PREFIX from invokeai.backend.patches.model_patch_raw import ModelPatchRaw @@ -105,10 +105,10 @@ def apply_patches(**kwargs): return nullcontext() monkeypatch.setattr( - "invokeai.app.invocations.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", apply_patches + "invokeai.app.invocations.text_encoder.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", apply_patches ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_text_encoder.TorchDevice.choose_bfloat16_safe_dtype", + "invokeai.app.invocations.text_encoder.krea2_text_encoder.TorchDevice.choose_bfloat16_safe_dtype", lambda _device: torch.float32, ) @@ -136,7 +136,7 @@ def apply_patches(**kwargs): return nullcontext() monkeypatch.setattr( - "invokeai.app.invocations.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", apply_patches + "invokeai.app.invocations.text_encoder.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", apply_patches ) with pytest.raises(TypeError, match="Expected ModelPatchRaw"): @@ -147,7 +147,7 @@ def test_encode_preserves_suffix_for_a_prompt_that_overflows_truncation(monkeypa # Regression: a prompt longer than the tokenizer budget must NOT lose the assistant-turn suffix. The # encoder tokenizes (prefix + prompt) with truncation and appends the suffix AFTER, so the final tokens # always end with the suffix template (building one string and truncating it would cut the suffix off). - from invokeai.app.invocations.krea2_text_encoder import _KREA2_SUFFIX + from invokeai.app.invocations.text_encoder.krea2_text_encoder import _KREA2_SUFFIX suffix_ids = [901, 902, 903, 904, 905] @@ -213,11 +213,11 @@ def load(identifier): ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.text_encoder.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", lambda **_kwargs: nullcontext(), ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_text_encoder.TorchDevice.choose_bfloat16_safe_dtype", + "invokeai.app.invocations.text_encoder.krea2_text_encoder.TorchDevice.choose_bfloat16_safe_dtype", lambda _device: torch.float32, ) @@ -240,7 +240,7 @@ def load(identifier): def test_encode_uses_reference_fixed_length_layout_and_position_ids(monkeypatch) -> None: - from invokeai.app.invocations.krea2_text_encoder import _KREA2_SUFFIX + from invokeai.app.invocations.text_encoder.krea2_text_encoder import _KREA2_SUFFIX captured: dict = {} @@ -312,11 +312,11 @@ def load(identifier): ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.text_encoder.krea2_text_encoder.LayerPatcher.apply_smart_model_patches", lambda **_kwargs: nullcontext(), ) monkeypatch.setattr( - "invokeai.app.invocations.krea2_text_encoder.TorchDevice.choose_bfloat16_safe_dtype", + "invokeai.app.invocations.text_encoder.krea2_text_encoder.TorchDevice.choose_bfloat16_safe_dtype", lambda _device: torch.float32, ) diff --git a/tests/app/invocations/test_minimax_h3_denoise_num_frames.py b/tests/app/invocations/test_minimax_h3_denoise_num_frames.py index 3a716931a9f..a7af16ed879 100644 --- a/tests/app/invocations/test_minimax_h3_denoise_num_frames.py +++ b/tests/app/invocations/test_minimax_h3_denoise_num_frames.py @@ -8,7 +8,7 @@ import pytest from pydantic import ValidationError -from invokeai.app.invocations.minimax_h3_denoise import ( +from invokeai.app.invocations.minimax_h3.minimax_h3_denoise import ( MINIMAX_H3_NUM_FRAMES_LABELS, MiniMaxH3DenoiseInvocation, ) diff --git a/tests/app/invocations/test_minimax_h3_ideal_dimensions.py b/tests/app/invocations/test_minimax_h3_ideal_dimensions.py index 2fb7d93e76b..5b9abba9f19 100644 --- a/tests/app/invocations/test_minimax_h3_ideal_dimensions.py +++ b/tests/app/invocations/test_minimax_h3_ideal_dimensions.py @@ -6,7 +6,7 @@ import pytest -from invokeai.app.invocations.minimax_h3_ideal_dimensions import MiniMaxH3IdealDimensionsInvocation +from invokeai.app.invocations.minimax_h3.minimax_h3_ideal_dimensions import MiniMaxH3IdealDimensionsInvocation from invokeai.backend.minimax_h3.packing import ( MINIMAX_H3_CANVAS_MULTIPLE, MINIMAX_H3_MAX_PIXELS, diff --git a/tests/app/invocations/test_minimax_h3_model_loader.py b/tests/app/invocations/test_minimax_h3_model_loader.py index c2f49f493f4..b8769a3d822 100644 --- a/tests/app/invocations/test_minimax_h3_model_loader.py +++ b/tests/app/invocations/test_minimax_h3_model_loader.py @@ -11,7 +11,7 @@ import pytest -from invokeai.app.invocations.minimax_h3_model_loader import MiniMaxH3ModelLoaderInvocation +from invokeai.app.invocations.minimax_h3.minimax_h3_model_loader import MiniMaxH3ModelLoaderInvocation from invokeai.app.invocations.model import ModelIdentifierField from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType diff --git a/tests/app/invocations/test_pid_memory_optimization_wiring.py b/tests/app/invocations/test_pid_memory_optimization_wiring.py index 8084ee5210b..4a6e45d50f3 100644 --- a/tests/app/invocations/test_pid_memory_optimization_wiring.py +++ b/tests/app/invocations/test_pid_memory_optimization_wiring.py @@ -26,7 +26,9 @@ def _modules_constructing_a_decode_config() -> list[Path]: - modules = sorted(p for p in _INVOCATIONS_DIR.glob("*.py") if "PiDDecodeConfig(" in p.read_text(encoding="utf-8")) + # `rglob`, not `glob`: node modules live in per-architecture subpackages, and the PiD decoders + # are spread across `pid/` and the architectures they serve. + modules = sorted(p for p in _INVOCATIONS_DIR.rglob("*.py") if "PiDDecodeConfig(" in p.read_text(encoding="utf-8")) assert modules, "no PiD nodes found - has the invocations directory moved?" return modules diff --git a/tests/app/invocations/test_qwen_image_denoise.py b/tests/app/invocations/test_qwen_image_denoise.py index 50187ea1535..85e84e0e3c8 100644 --- a/tests/app/invocations/test_qwen_image_denoise.py +++ b/tests/app/invocations/test_qwen_image_denoise.py @@ -2,7 +2,7 @@ import pytest -from invokeai.app.invocations.qwen_image_denoise import QwenImageDenoiseInvocation +from invokeai.app.invocations.qwen_image.qwen_image_denoise import QwenImageDenoiseInvocation class TestPrepareCfgScale: diff --git a/tests/app/invocations/test_qwen_image_model_loader.py b/tests/app/invocations/test_qwen_image_model_loader.py index 10cab34a649..65976a4f2a7 100644 --- a/tests/app/invocations/test_qwen_image_model_loader.py +++ b/tests/app/invocations/test_qwen_image_model_loader.py @@ -5,7 +5,7 @@ import pytest from invokeai.app.invocations.model import ModelIdentifierField -from invokeai.app.invocations.qwen_image_model_loader import QwenImageModelLoaderInvocation +from invokeai.app.invocations.qwen_image.qwen_image_model_loader import QwenImageModelLoaderInvocation from invokeai.backend.model_manager.taxonomy import ModelFormat, SubModelType diff --git a/tests/app/invocations/test_qwen_image_text_encoder.py b/tests/app/invocations/test_qwen_image_text_encoder.py index ab3beabae7f..f842e425bfb 100644 --- a/tests/app/invocations/test_qwen_image_text_encoder.py +++ b/tests/app/invocations/test_qwen_image_text_encoder.py @@ -2,7 +2,7 @@ from PIL import Image -from invokeai.app.invocations.qwen_image_text_encoder import ( +from invokeai.app.invocations.text_encoder.qwen_image_text_encoder import ( QwenImageTextEncoderInvocation, _build_prompt, ) diff --git a/tests/app/invocations/test_qwen_image_working_memory.py b/tests/app/invocations/test_qwen_image_working_memory.py index 02a6faedf18..5590d06182c 100644 --- a/tests/app/invocations/test_qwen_image_working_memory.py +++ b/tests/app/invocations/test_qwen_image_working_memory.py @@ -7,8 +7,8 @@ import torch from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage -from invokeai.app.invocations.qwen_image_image_to_latents import QwenImageImageToLatentsInvocation -from invokeai.app.invocations.qwen_image_latents_to_image import QwenImageLatentsToImageInvocation +from invokeai.app.invocations.vae.qwen_image_image_to_latents import QwenImageImageToLatentsInvocation +from invokeai.app.invocations.vae.qwen_image_latents_to_image import QwenImageLatentsToImageInvocation from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_qwen_image @@ -89,8 +89,10 @@ def test_qwen_latents_to_image_requests_working_memory(self): mock_latents = torch.zeros(1, 16, 1, 64, 64) mock_context.tensors.load.return_value = mock_latents - estimation_path = "invokeai.app.invocations.qwen_image_latents_to_image.estimate_vae_working_memory_qwen_image" - seamless_path = "invokeai.app.invocations.qwen_image_latents_to_image.SeamlessExt.static_patch_model" + estimation_path = ( + "invokeai.app.invocations.vae.qwen_image_latents_to_image.estimate_vae_working_memory_qwen_image" + ) + seamless_path = "invokeai.app.invocations.vae.qwen_image_latents_to_image.SeamlessExt.static_patch_model" with ( patch(estimation_path) as mock_estimate, @@ -130,19 +132,19 @@ def test_seamless_patch_is_applied_to_converted_anima_vae(self): with ( patch( - "invokeai.app.invocations.qwen_image_latents_to_image.estimate_vae_working_memory_qwen_image", + "invokeai.app.invocations.vae.qwen_image_latents_to_image.estimate_vae_working_memory_qwen_image", return_value=1, ), patch( - "invokeai.app.invocations.qwen_image_latents_to_image.as_qwen_image_vae", + "invokeai.app.invocations.vae.qwen_image_latents_to_image.as_qwen_image_vae", return_value=converted_vae, ), patch( - "invokeai.app.invocations.qwen_image_latents_to_image.SeamlessExt.static_patch_model", + "invokeai.app.invocations.vae.qwen_image_latents_to_image.SeamlessExt.static_patch_model", return_value=nullcontext(), ) as patch_seamless, patch( - "invokeai.app.invocations.qwen_image_latents_to_image.TorchDevice.choose_torch_device", + "invokeai.app.invocations.vae.qwen_image_latents_to_image.TorchDevice.choose_torch_device", return_value=torch.device("cpu"), ), ): @@ -162,7 +164,9 @@ def test_qwen_image_to_latents_requests_working_memory(self): mock_image_tensor = torch.zeros(1, 3, 512, 512) - estimation_path = "invokeai.app.invocations.qwen_image_image_to_latents.estimate_vae_working_memory_qwen_image" + estimation_path = ( + "invokeai.app.invocations.vae.qwen_image_image_to_latents.estimate_vae_working_memory_qwen_image" + ) with patch(estimation_path) as mock_estimate: expected_memory = 1024 * 1024 * 5000 # 5GB diff --git a/tests/app/invocations/test_sd3_text_encoder.py b/tests/app/invocations/test_sd3_text_encoder.py index feca0b10a33..ff68e0332f9 100644 --- a/tests/app/invocations/test_sd3_text_encoder.py +++ b/tests/app/invocations/test_sd3_text_encoder.py @@ -4,7 +4,7 @@ import torch -from invokeai.app.invocations.sd3_text_encoder import Sd3TextEncoderInvocation +from invokeai.app.invocations.text_encoder.sd3_text_encoder import Sd3TextEncoderInvocation from invokeai.backend.model_manager.taxonomy import ModelFormat @@ -94,7 +94,7 @@ def __exit__(self, exc_type, exc, tb): def test_sd3_clip_encode_uses_compute_device(monkeypatch): # Regression test for #9373: the encoder's weights are offloaded to CPU, but its intended compute device is the # accelerator. The encode must run on the intended compute device, not the current residency. - module_path = "invokeai.app.invocations.sd3_text_encoder" + module_path = "invokeai.app.invocations.text_encoder.sd3_text_encoder" compute_device = torch.device("meta") text_encoder = FakeSd3ClipTextEncoder(effective_device=torch.device("cpu")) tokenizer = FakeClipTokenizer() @@ -138,7 +138,7 @@ def forward(input_ids: torch.Tensor, output_hidden_states: bool = False): def test_sd3_t5_encode_uses_compute_device(monkeypatch): # Regression test for #9373: same as the CLIP case, for the T5 encode path. - module_path = "invokeai.app.invocations.sd3_text_encoder" + module_path = "invokeai.app.invocations.text_encoder.sd3_text_encoder" compute_device = torch.device("meta") text_encoder = FakeSd3T5Encoder(effective_device=torch.device("cpu")) tokenizer = FakeT5Tokenizer() diff --git a/tests/app/invocations/test_wan_denoise.py b/tests/app/invocations/test_wan_denoise.py index acf3d904f9c..f189e8900aa 100644 --- a/tests/app/invocations/test_wan_denoise.py +++ b/tests/app/invocations/test_wan_denoise.py @@ -22,13 +22,13 @@ from invokeai.app.invocations.fields import ImageField, LatentsField, WanConditioningField, WanRefImageConditioningField from invokeai.app.invocations.model import ModelIdentifierField, VAEField, WanTransformerField -from invokeai.app.invocations.wan_denoise import ( +from invokeai.app.invocations.wan.wan_denoise import ( WanDenoiseInvocation, _ExpertSwapper, _get_wan_transformer_working_mem_bytes, ) -from invokeai.app.invocations.wan_ref_image_encoder import WanRefImageEncoderInvocation -from invokeai.app.invocations.wan_video_denoise import WanVideoDenoiseInvocation +from invokeai.app.invocations.wan.wan_ref_image_encoder import WanRefImageEncoderInvocation +from invokeai.app.invocations.wan.wan_video_denoise import WanVideoDenoiseInvocation from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType, WanVariantType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( ConditioningFieldData, @@ -410,7 +410,9 @@ def record_memory_optimization(_transformer, *, enabled: bool): enabled_calls.append(enabled) yield - monkeypatch.setattr("invokeai.app.invocations.wan_denoise.wan_memory_optimization", record_memory_optimization) + monkeypatch.setattr( + "invokeai.app.invocations.wan.wan_denoise.wan_memory_optimization", record_memory_optimization + ) inv = _make_invocation( transformer_field=_wan_transformer_field(), pos_field=WanConditioningField(conditioning_name="pos"), @@ -1012,7 +1014,7 @@ def test_high_only_loras_do_not_leak_to_low_expert(self, fake_model_root) -> Non from unittest.mock import patch from invokeai.app.invocations.model import LoRAField - from invokeai.app.invocations.wan_denoise import _ExpertSwapper # noqa: F401 (documents the patch target) + from invokeai.app.invocations.wan.wan_denoise import _ExpertSwapper # noqa: F401 (documents the patch target) captured: dict = {} @@ -1056,7 +1058,7 @@ def close(self) -> None: steps=2, guidance_scale=1.0, ) - with patch("invokeai.app.invocations.wan_denoise._ExpertSwapper", _RecordingSwapper): + with patch("invokeai.app.invocations.wan.wan_denoise._ExpertSwapper", _RecordingSwapper): inv._run_diffusion(ctx) assert captured["high_lora_factory"] is not None @@ -1071,7 +1073,7 @@ class TestDefaultSchedulerForVariant: def test_ti2v_5b_returns_unipc_with_flow_config(self) -> None: from diffusers import UniPCMultistepScheduler - from invokeai.app.invocations.wan_denoise import _default_scheduler_for_variant + from invokeai.app.invocations.wan.wan_denoise import _default_scheduler_for_variant s = _default_scheduler_for_variant(WanVariantType.TI2V_5B) assert isinstance(s, UniPCMultistepScheduler) @@ -1089,7 +1091,7 @@ def test_a14b_variants_return_unipc_with_flow_shift_3(self) -> None: and skews how many steps land above the MoE expert boundary.""" from diffusers import UniPCMultistepScheduler - from invokeai.app.invocations.wan_denoise import _default_scheduler_for_variant + from invokeai.app.invocations.wan.wan_denoise import _default_scheduler_for_variant for v in (WanVariantType.T2V_A14B, WanVariantType.I2V_A14B): s = _default_scheduler_for_variant(v) diff --git a/tests/app/invocations/test_wan_expert_swapper.py b/tests/app/invocations/test_wan_expert_swapper.py index 977dfc1a8a5..9c8ed67ee70 100644 --- a/tests/app/invocations/test_wan_expert_swapper.py +++ b/tests/app/invocations/test_wan_expert_swapper.py @@ -21,7 +21,7 @@ import torch import torch.nn as nn -from invokeai.app.invocations.wan_denoise import _ExpertSwapper +from invokeai.app.invocations.wan.wan_denoise import _ExpertSwapper from invokeai.backend.patches.model_patch_raw import ModelPatchRaw @@ -170,7 +170,7 @@ def test_lifecycle_high_only(): stub, calls = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( @@ -210,7 +210,7 @@ def test_lifecycle_dual_expert_swap(): stub, calls = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( @@ -264,7 +264,7 @@ def test_quantized_flag_forwards_to_sidecar(): stub, calls = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( @@ -289,7 +289,7 @@ def test_no_lora_factory_skips_lora_context(): stub, calls = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( @@ -321,7 +321,7 @@ def test_repeat_get_same_label_is_a_no_op(): stub, calls = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( @@ -362,7 +362,7 @@ def test_lazy_load_per_swap_not_upfront(): stub, _ = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): # Construction alone must not trigger any models.load call. @@ -415,10 +415,10 @@ def test_empty_cache_called_on_swap(): stub, _ = _stub_lora_context_manager(log) with ( patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ), - patch("invokeai.app.invocations.wan_denoise.TorchDevice.empty_cache") as empty_cache_mock, + patch("invokeai.app.invocations.wan.wan_denoise.TorchDevice.empty_cache") as empty_cache_mock, ): swapper = _ExpertSwapper( context=ctx, @@ -463,7 +463,7 @@ def test_outgoing_expert_force_unloaded_from_vram(): stub, _ = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( @@ -515,7 +515,7 @@ def __exit__(self, *_args): return False with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=lambda **_kwargs: _RaisingLoraStub(), ): swapper = _ExpertSwapper( @@ -553,7 +553,7 @@ def __exit__(self, *_args): raise RuntimeError("LoRA weight restore blew up") with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=lambda **_kwargs: _ExitRaisingLoraStub(), ): swapper = _ExpertSwapper( @@ -593,7 +593,7 @@ def full_unload_from_vram(self): stub, _ = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( @@ -631,7 +631,7 @@ def model_on_device(self): ctx = _FakeContext({"high": _ExitRaisingInfo("HIGH", high_nn, log)}, log) stub, _calls = _stub_lora_context_manager(log) with patch( - "invokeai.app.invocations.wan_denoise.LayerPatcher.apply_smart_model_patches", + "invokeai.app.invocations.wan.wan_denoise.LayerPatcher.apply_smart_model_patches", side_effect=stub, ): swapper = _ExpertSwapper( diff --git a/tests/app/invocations/test_wan_ideal_dimensions.py b/tests/app/invocations/test_wan_ideal_dimensions.py index e2944043848..f5bf5cdda25 100644 --- a/tests/app/invocations/test_wan_ideal_dimensions.py +++ b/tests/app/invocations/test_wan_ideal_dimensions.py @@ -6,7 +6,7 @@ import pytest -from invokeai.app.invocations.wan_ideal_dimensions import ( +from invokeai.app.invocations.wan.wan_ideal_dimensions import ( WAN_TARGET_RESOLUTION_PX, WanI2VIdealDimensionsInvocation, ) diff --git a/tests/app/invocations/test_wan_latents_to_image.py b/tests/app/invocations/test_wan_latents_to_image.py index 9d2d2ee103d..13cb8b288c9 100644 --- a/tests/app/invocations/test_wan_latents_to_image.py +++ b/tests/app/invocations/test_wan_latents_to_image.py @@ -13,7 +13,7 @@ from invokeai.app.invocations.fields import LatentsField from invokeai.app.invocations.model import ModelIdentifierField, VAEField -from invokeai.app.invocations.wan_latents_to_image import WanLatentsToImageInvocation +from invokeai.app.invocations.vae.wan_latents_to_image import WanLatentsToImageInvocation from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType diff --git a/tests/app/invocations/test_wan_latents_to_video_encoding.py b/tests/app/invocations/test_wan_latents_to_video_encoding.py index e03e9d61335..4a4928855d7 100644 --- a/tests/app/invocations/test_wan_latents_to_video_encoding.py +++ b/tests/app/invocations/test_wan_latents_to_video_encoding.py @@ -4,7 +4,7 @@ import pytest import torch -from invokeai.app.invocations.wan_latents_to_video import ( +from invokeai.app.invocations.vae.wan_latents_to_video import ( WanLatentsToVideoInvocation, _iter_decoded_frames, _validate_video_latent_batch, diff --git a/tests/app/invocations/test_wan_lora_loader.py b/tests/app/invocations/test_wan_lora_loader.py index 05972c042de..5cf3f699c02 100644 --- a/tests/app/invocations/test_wan_lora_loader.py +++ b/tests/app/invocations/test_wan_lora_loader.py @@ -5,7 +5,7 @@ import pytest from invokeai.app.invocations.model import LoRAField, ModelIdentifierField, WanTransformerField -from invokeai.app.invocations.wan_lora_loader import ( +from invokeai.app.invocations.wan.wan_lora_loader import ( WanLoRACollectionLoader, WanLoRALoaderInvocation, _resolve_target, diff --git a/tests/app/invocations/test_wan_model_loader.py b/tests/app/invocations/test_wan_model_loader.py index 7779ca5ea60..e908e58eef5 100644 --- a/tests/app/invocations/test_wan_model_loader.py +++ b/tests/app/invocations/test_wan_model_loader.py @@ -4,7 +4,7 @@ import pytest from invokeai.app.invocations.model import ModelIdentifierField -from invokeai.app.invocations.wan_model_loader import WanModelLoaderInvocation +from invokeai.app.invocations.wan.wan_model_loader import WanModelLoaderInvocation from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, WanVariantType diff --git a/tests/app/invocations/test_wan_ti2v_ideal_dimensions.py b/tests/app/invocations/test_wan_ti2v_ideal_dimensions.py index 80a91b3e967..91696a82cac 100644 --- a/tests/app/invocations/test_wan_ti2v_ideal_dimensions.py +++ b/tests/app/invocations/test_wan_ti2v_ideal_dimensions.py @@ -8,7 +8,7 @@ import pytest -from invokeai.app.invocations.wan_ideal_dimensions import ( +from invokeai.app.invocations.wan.wan_ideal_dimensions import ( WAN_TARGET_RESOLUTION_PX, WAN_TI2V_PIXEL_MULTIPLE, WanTI2VIdealDimensionsInvocation, diff --git a/tests/app/invocations/test_wan_working_memory.py b/tests/app/invocations/test_wan_working_memory.py index dd4deacc84c..967bbefb788 100644 --- a/tests/app/invocations/test_wan_working_memory.py +++ b/tests/app/invocations/test_wan_working_memory.py @@ -6,10 +6,10 @@ import torch from diffusers.models.autoencoders import AutoencoderKLWan -from invokeai.app.invocations.wan_image_to_latents import WanImageToLatentsInvocation -from invokeai.app.invocations.wan_latents_to_image import WanLatentsToImageInvocation -from invokeai.app.invocations.wan_latents_to_video import WanLatentsToVideoInvocation -from invokeai.app.invocations.wan_ref_image_encoder import WanRefImageEncoderInvocation +from invokeai.app.invocations.vae.wan_image_to_latents import WanImageToLatentsInvocation +from invokeai.app.invocations.vae.wan_latents_to_image import WanLatentsToImageInvocation +from invokeai.app.invocations.vae.wan_latents_to_video import WanLatentsToVideoInvocation +from invokeai.app.invocations.wan.wan_ref_image_encoder import WanRefImageEncoderInvocation from invokeai.backend.util.devices import TorchDevice from invokeai.backend.util.vae_working_memory import estimate_vae_working_memory_wan @@ -136,7 +136,9 @@ def test_latents_to_image_requests_decode_memory(self): mock_context.models.load.return_value = vae_info mock_context.tensors.load.return_value = torch.zeros(1, 16, 64, 64) - with patch("invokeai.app.invocations.wan_latents_to_image.estimate_vae_working_memory_wan") as mock_estimate: + with patch( + "invokeai.app.invocations.vae.wan_latents_to_image.estimate_vae_working_memory_wan" + ) as mock_estimate: mock_estimate.return_value = 1234 invocation = WanLatentsToImageInvocation.model_construct( latents=MagicMock(latents_name="l"), vae=MagicMock(vae=MagicMock()) @@ -159,7 +161,9 @@ def test_latents_to_image_uses_config_spatial_scale_for_ti2v(self): mock_context.models.load.return_value = vae_info mock_context.tensors.load.return_value = torch.zeros(1, 48, 32, 32) - with patch("invokeai.app.invocations.wan_latents_to_image.estimate_vae_working_memory_wan") as mock_estimate: + with patch( + "invokeai.app.invocations.vae.wan_latents_to_image.estimate_vae_working_memory_wan" + ) as mock_estimate: mock_estimate.return_value = 1 invocation = WanLatentsToImageInvocation.model_construct( latents=MagicMock(latents_name="l"), vae=MagicMock(vae=MagicMock()) @@ -176,7 +180,9 @@ def test_image_to_latents_requests_encode_memory(self): vae = _mock_wan_vae() vae_info = _mock_vae_info(vae) - with patch("invokeai.app.invocations.wan_image_to_latents.estimate_vae_working_memory_wan") as mock_estimate: + with patch( + "invokeai.app.invocations.vae.wan_image_to_latents.estimate_vae_working_memory_wan" + ) as mock_estimate: mock_estimate.return_value = 4321 try: WanImageToLatentsInvocation.vae_encode(vae_info, torch.zeros(1, 3, 512, 512)) @@ -196,9 +202,11 @@ def test_ref_image_encoder_requests_encode_memory(self): mock_context.tensors.save.return_value = "t" with ( - patch("invokeai.app.invocations.wan_ref_image_encoder.estimate_vae_working_memory_wan") as mock_estimate, patch( - "invokeai.app.invocations.wan_ref_image_encoder.encode_reference_image_to_condition", + "invokeai.app.invocations.wan.wan_ref_image_encoder.estimate_vae_working_memory_wan" + ) as mock_estimate, + patch( + "invokeai.app.invocations.wan.wan_ref_image_encoder.encode_reference_image_to_condition", return_value=torch.zeros(1, 20, 1, 4, 4), ), ): @@ -230,7 +238,9 @@ def test_latents_to_video_requests_decode_memory_for_all_frames(self): vae_info = _mock_vae_info(vae) mock_context = self._video_context(vae_info) - with patch("invokeai.app.invocations.wan_latents_to_video.estimate_vae_working_memory_wan") as mock_estimate: + with patch( + "invokeai.app.invocations.vae.wan_latents_to_video.estimate_vae_working_memory_wan" + ) as mock_estimate: mock_estimate.return_value = 5678 invocation = WanLatentsToVideoInvocation.model_construct( latents=MagicMock(latents_name="l"), vae=MagicMock(vae=MagicMock()), fps=16 @@ -261,15 +271,15 @@ def test_latents_to_video_streams_decode_chunks_directly_to_mp4(self): with ( patch( - "invokeai.app.invocations.wan_latents_to_video.estimate_vae_working_memory_wan", + "invokeai.app.invocations.vae.wan_latents_to_video.estimate_vae_working_memory_wan", return_value=5678, ) as mock_estimate, patch( - "invokeai.app.invocations.wan_latents_to_video.iter_wan_vae_decode_chunks", + "invokeai.app.invocations.vae.wan_latents_to_video.iter_wan_vae_decode_chunks", return_value=iter(chunks), ) as mock_decode_chunks, - patch("invokeai.app.invocations.wan_latents_to_video.make_mp4_writer", return_value=writer), - patch("invokeai.app.invocations.wan_latents_to_video.VideoOutput.build", return_value=expected_output), + patch("invokeai.app.invocations.vae.wan_latents_to_video.make_mp4_writer", return_value=writer), + patch("invokeai.app.invocations.vae.wan_latents_to_video.VideoOutput.build", return_value=expected_output), patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cpu")), patch.object(TorchDevice, "empty_cache"), ): @@ -293,7 +303,7 @@ def test_latents_to_video_falls_back_to_tiling_when_estimate_exceeds_vram(self): with ( patch( - "invokeai.app.invocations.wan_latents_to_video.estimate_vae_working_memory_wan", + "invokeai.app.invocations.vae.wan_latents_to_video.estimate_vae_working_memory_wan", side_effect=[100 * 2**30, 4 * 2**30], ) as mock_estimate, patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cuda")), @@ -322,7 +332,7 @@ def test_latents_to_video_skips_tiling_for_cpu_only_vae(self): with ( patch( - "invokeai.app.invocations.wan_latents_to_video.estimate_vae_working_memory_wan", + "invokeai.app.invocations.vae.wan_latents_to_video.estimate_vae_working_memory_wan", return_value=100 * 2**30, ) as mock_estimate, patch.object(TorchDevice, "choose_torch_device", return_value=torch.device("cuda")), diff --git a/tests/app/invocations/test_z_image_model_loader.py b/tests/app/invocations/test_z_image_model_loader.py index 08400272716..41fc3f2ace2 100644 --- a/tests/app/invocations/test_z_image_model_loader.py +++ b/tests/app/invocations/test_z_image_model_loader.py @@ -12,7 +12,7 @@ import pytest from invokeai.app.invocations.model import ModelIdentifierField -from invokeai.app.invocations.z_image_model_loader import ZImageModelLoaderInvocation +from invokeai.app.invocations.z_image.z_image_model_loader import ZImageModelLoaderInvocation from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelFormat, ModelType, SubModelType diff --git a/tests/app/invocations/test_z_image_working_memory.py b/tests/app/invocations/test_z_image_working_memory.py index 74d8f835380..644da2ef5c2 100644 --- a/tests/app/invocations/test_z_image_working_memory.py +++ b/tests/app/invocations/test_z_image_working_memory.py @@ -6,7 +6,7 @@ import torch from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL -from invokeai.app.invocations.z_image_image_to_latents import ZImageImageToLatentsInvocation +from invokeai.app.invocations.vae.z_image_image_to_latents import ZImageImageToLatentsInvocation from invokeai.backend.flux.modules.autoencoder import AutoEncoder as FluxAutoEncoder @@ -49,7 +49,7 @@ def test_z_image_latents_to_image_requests_working_memory(self, vae_type): mock_latents = torch.zeros(1, 16, 64, 64) mock_context.tensors.load.return_value = mock_latents - estimation_path = "invokeai.app.invocations.z_image_latents_to_image.estimate_vae_working_memory_flux" + estimation_path = "invokeai.app.invocations.vae.z_image_latents_to_image.estimate_vae_working_memory_flux" with patch(estimation_path) as mock_estimate: expected_memory = 1024 * 1024 * 500 # 500MB @@ -66,7 +66,7 @@ def test_z_image_latents_to_image_requests_working_memory(self, vae_type): mock_context.images.save.return_value = mock_image_dto # Import and create invocation using model_construct to bypass validation - from invokeai.app.invocations.z_image_latents_to_image import ZImageLatentsToImageInvocation + from invokeai.app.invocations.vae.z_image_latents_to_image import ZImageLatentsToImageInvocation invocation = ZImageLatentsToImageInvocation.model_construct( latents=MagicMock(latents_name="test_latents"), @@ -113,7 +113,7 @@ def test_z_image_image_to_latents_requests_working_memory(self, vae_type): mock_image_tensor = torch.zeros(1, 3, 512, 512) # Mock the estimation function - estimation_path = "invokeai.app.invocations.z_image_image_to_latents.estimate_vae_working_memory_flux" + estimation_path = "invokeai.app.invocations.vae.z_image_image_to_latents.estimate_vae_working_memory_flux" with patch(estimation_path) as mock_estimate: expected_memory = 1024 * 1024 * 250 # 250MB diff --git a/tests/app/services/session_processor/test_encoder_offload.py b/tests/app/services/session_processor/test_encoder_offload.py index d9e582ab2be..331df8c2fd3 100644 --- a/tests/app/services/session_processor/test_encoder_offload.py +++ b/tests/app/services/session_processor/test_encoder_offload.py @@ -253,9 +253,9 @@ def ram_cache(self): def test_real_nodes_declare_the_marker_correctly(): """The @invocation(idle_gpu_offloadable=...) marker is wired through to the class, and is set on encoder nodes but not on ordinary nodes.""" - from invokeai.app.invocations.compel import CompelInvocation - from invokeai.app.invocations.flux_text_encoder import FluxTextEncoderInvocation from invokeai.app.invocations.primitives import IntegerInvocation + from invokeai.app.invocations.text_encoder.compel import CompelInvocation + from invokeai.app.invocations.text_encoder.flux_text_encoder import FluxTextEncoderInvocation assert FluxTextEncoderInvocation.idle_gpu_offloadable is True assert CompelInvocation.idle_gpu_offloadable is True @@ -275,17 +275,13 @@ def test_every_text_encoder_node_declares_the_marker(): enough that holding the lent GPU's lock would stall a session dequeued onto it) belongs in `_NOT_OFFLOADABLE` with a comment saying why. """ - import importlib - - import invokeai.app.invocations as invocations_package + # Importing the package imports every node module in its tree, which is what registers them. + import invokeai.app.invocations # noqa: F401 from invokeai.app.invocations.baseinvocation import InvocationRegistry # Encoders that must NOT be offloadable. Empty today; add with a justification. _NOT_OFFLOADABLE: set[str] = set() - for module_name in invocations_package.__all__: - importlib.import_module(f"invokeai.app.invocations.{module_name}") - encoders = { cls.get_type(): cls.idle_gpu_offloadable for cls in InvocationRegistry.get_invocation_classes() diff --git a/tests/backend/anima/test_control_net_lllite.py b/tests/backend/anima/test_control_net_lllite.py index 7b399c5f9b1..f09b7f1a48f 100644 --- a/tests/backend/anima/test_control_net_lllite.py +++ b/tests/backend/anima/test_control_net_lllite.py @@ -10,8 +10,8 @@ import torch.nn.functional as F from torch import nn -from invokeai.app.invocations.anima_denoise import AnimaDenoiseInvocation -from invokeai.app.invocations.anima_lllite import AnimaLLLiteField +from invokeai.app.invocations.anima.anima_denoise import AnimaDenoiseInvocation +from invokeai.app.invocations.anima.anima_lllite import AnimaLLLiteField from invokeai.app.invocations.model import ModelIdentifierField from invokeai.backend.anima.control_net_lllite import ( AnimaControlNetLLLite, diff --git a/tests/backend/anima/test_scheduler_driver.py b/tests/backend/anima/test_scheduler_driver.py index ec8c8f54e49..d484247c296 100644 --- a/tests/backend/anima/test_scheduler_driver.py +++ b/tests/backend/anima/test_scheduler_driver.py @@ -7,7 +7,7 @@ import pytest import torch -from invokeai.app.invocations.anima_denoise import loglinear_timestep_shift +from invokeai.app.invocations.anima.anima_denoise import loglinear_timestep_shift from invokeai.backend.anima.scheduler_driver import AnimaSchedulerDriver from invokeai.backend.flux.schedulers import ANIMA_SCHEDULER_MAP, ANIMA_SHIFT diff --git a/tests/backend/flux/test_anima_schedulers.py b/tests/backend/flux/test_anima_schedulers.py index b5b690c655d..c94c2678ff3 100644 --- a/tests/backend/flux/test_anima_schedulers.py +++ b/tests/backend/flux/test_anima_schedulers.py @@ -75,7 +75,7 @@ def test_anima_dpmpp_2m_produces_anima_compatible_sigma_schedule(): """ import inspect - from invokeai.app.invocations.anima_denoise import loglinear_timestep_shift + from invokeai.app.invocations.anima.anima_denoise import loglinear_timestep_shift from invokeai.backend.flux.schedulers import ANIMA_SHIFT num_steps = 10 @@ -107,7 +107,7 @@ def test_anima_dpmpp_2m_with_denoising_start_honors_clipped_schedule(): """ import inspect - from invokeai.app.invocations.anima_denoise import loglinear_timestep_shift + from invokeai.app.invocations.anima.anima_denoise import loglinear_timestep_shift from invokeai.backend.flux.schedulers import ANIMA_SHIFT num_steps = 30 @@ -145,7 +145,7 @@ def test_anima_set_begin_index_path_step_count_with_denoising_end(): """ import inspect - from invokeai.app.invocations.anima_denoise import loglinear_timestep_shift + from invokeai.app.invocations.anima.anima_denoise import loglinear_timestep_shift from invokeai.backend.flux.schedulers import ANIMA_SHIFT num_steps = 30 @@ -271,7 +271,7 @@ def test_anima_heun_uses_anima_shift_for_internal_schedule(): Fix: give Heun shift=ANIMA_SHIFT so its internal schedule approximates Anima's reference. """ - from invokeai.app.invocations.anima_denoise import loglinear_timestep_shift + from invokeai.app.invocations.anima.anima_denoise import loglinear_timestep_shift cls, kwargs = ANIMA_SCHEDULER_MAP["heun"] from invokeai.backend.flux.schedulers import ANIMA_SHIFT diff --git a/tests/backend/ideogram4/test_caption_builder_node.py b/tests/backend/ideogram4/test_caption_builder_node.py index a1403ffc2e1..19e5f2496bc 100644 --- a/tests/backend/ideogram4/test_caption_builder_node.py +++ b/tests/backend/ideogram4/test_caption_builder_node.py @@ -8,7 +8,7 @@ import pytest from pydantic import ValidationError -from invokeai.app.invocations.ideogram4_caption import Ideogram4Region +from invokeai.app.invocations.ideogram4.ideogram4_caption import Ideogram4Region def test_valid_bbox_is_accepted(): diff --git a/tests/backend/ideogram4/test_guidance_schedule.py b/tests/backend/ideogram4/test_guidance_schedule.py index aff2809e8f3..7fa06a2424c 100644 --- a/tests/backend/ideogram4/test_guidance_schedule.py +++ b/tests/backend/ideogram4/test_guidance_schedule.py @@ -8,7 +8,7 @@ import pytest -from invokeai.app.invocations.ideogram4_denoise import _effective_guidance_schedule +from invokeai.app.invocations.ideogram4.ideogram4_denoise import _effective_guidance_schedule from invokeai.backend.ideogram4.sampler_configs import PRESETS _QUALITY = PRESETS["V4_QUALITY_48"] # num_steps=48, schedule=(3.0,)*3 + (7.0,)*45 diff --git a/tests/backend/minimax_h3/test_denoise.py b/tests/backend/minimax_h3/test_denoise.py index 62a2778dd27..a4c3210906e 100644 --- a/tests/backend/minimax_h3/test_denoise.py +++ b/tests/backend/minimax_h3/test_denoise.py @@ -153,7 +153,7 @@ def test_denoise_working_memory_estimate(): (124 frames at 768x1344 = ~38k rows): far above the small cache default, below the size of the weights themselves. Change the constants deliberately - this test is meant to fail on accidental drift.""" - from invokeai.app.invocations.minimax_h3_denoise import MiniMaxH3DenoiseInvocation + from invokeai.app.invocations.minimax_h3.minimax_h3_denoise import MiniMaxH3DenoiseInvocation estimate = MiniMaxH3DenoiseInvocation._estimate_working_memory From aff689af5af7a3ba638b2bc2b4a2c781af8b6657 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 05:40:43 +0200 Subject: [PATCH 03/26] docs(contributing): place new-architecture nodes in their package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new-model integration guide showed every node file at the top of `invokeai/app/invocations/`. Point the code-block titles and both file-tree summaries at the package each node now belongs to, and state the rule once: one package per architecture, with VAE, text-encoder and PiD nodes grouped by role because they are shared across architectures. Also name the consequence of forgetting `__init__.py` — the package is discovered automatically, so there is no list to edit, but a directory that is not a package contributes no nodes at all. Co-Authored-By: Claude Opus 5 (1M context) --- .../contributing/new-model-integration.mdx | 56 ++++++++++++++----- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/src/content/docs/contributing/new-model-integration.mdx b/docs/src/content/docs/contributing/new-model-integration.mdx index c20c933bc83..f3d700bf48f 100644 --- a/docs/src/content/docs/contributing/new-model-integration.mdx +++ b/docs/src/content/docs/contributing/new-model-integration.mdx @@ -348,10 +348,32 @@ Loaders convert the files on disk (described by the config) into PyTorch models Invocations expose your PyTorch functions as isolated execution nodes in InvokeAI's graph. +:::note[Where node files go] +Node modules live in `invokeai/app/invocations/[newmodel]/`, one package per architecture. Three +kinds of node are grouped by role instead, because they are shared across architectures rather than +owned by one: + +| Node | Package | +| --- | --- | +| VAE encode / decode, latents-to-image, latents-to-video | `invocations/vae/` | +| Text encoders | `invocations/text_encoder/` | +| PiD decode and upscale | `invocations/pid/` | + +Generic nodes — noise, primitives, image ops, metadata — stay at the top level. Keep the filename +prefixed with the architecture (`newmodel_denoise.py`) even inside `newmodel/`: the prefix is what +makes a file findable by name, and the cross-cutting packages mix architectures by design. + +The package is discovered automatically; there is no registration list to edit. But an +`__init__.py` is required — a directory without one is not a package, and at runtime every node in +it would simply not exist. `tests/app/invocations/test_node_discovery.py` fails loudly when the +files on disk and the imported modules disagree, so this is caught in CI rather than by a user +opening a workflow. +::: + 1. **Model Loader Invocation** - ```python title="invokeai/app/invocations/[newmodel]_model_loader.py" + ```python title="invokeai/app/invocations/[newmodel]/[newmodel]_model_loader.py" @invocation("newmodel_model_loader", title="NewModel Loader", ...) class NewModelModelLoaderInvocation(BaseInvocation): model: ModelIdentifierField = InputField(description="Main model") @@ -375,7 +397,7 @@ Invocations expose your PyTorch functions as isolated execution nodes in InvokeA 2. **Text Encoder Invocation** - ```python title="invokeai/app/invocations/[newmodel]_text_encoder.py" + ```python title="invokeai/app/invocations/text_encoder/[newmodel]_text_encoder.py" @invocation("newmodel_text_encode", title="NewModel Text Encoder", ...) class NewModelTextEncoderInvocation(BaseInvocation): prompt: str = InputField() @@ -427,7 +449,7 @@ Invocations expose your PyTorch functions as isolated execution nodes in InvokeA 3. **Denoise Invocation** - ```python title="invokeai/app/invocations/[newmodel]_denoise.py" + ```python title="invokeai/app/invocations/[newmodel]/[newmodel]_denoise.py" @invocation("newmodel_denoise", title="NewModel Denoise", ...) class NewModelDenoiseInvocation(BaseInvocation): # Standard Fields @@ -478,7 +500,7 @@ Invocations expose your PyTorch functions as isolated execution nodes in InvokeA 4. **VAE Encode Invocation** - ```python title="invokeai/app/invocations/[newmodel]_vae_encode.py" + ```python title="invokeai/app/invocations/vae/[newmodel]_vae_encode.py" @invocation("newmodel_vae_encode", title="Image to Latents - NewModel", ...) class NewModelVaeEncodeInvocation(BaseInvocation): image: ImageField = InputField() @@ -497,7 +519,7 @@ Invocations expose your PyTorch functions as isolated execution nodes in InvokeA 5. **VAE Decode Invocation** - ```python title="invokeai/app/invocations/[newmodel]_vae_decode.py" + ```python title="invokeai/app/invocations/vae/[newmodel]_vae_decode.py" @invocation("newmodel_vae_decode", title="Latents to Image - NewModel", ...) class NewModelVaeDecodeInvocation(BaseInvocation): latents: LatentsField = InputField() @@ -1069,9 +1091,9 @@ class ControlNet_Checkpoint_NewModel_Config(ControlNet_Checkpoint_Base): return cls(...) ``` -**Backend Invocation** — `invokeai/app/invocations/[newmodel]_controlnet.py`: +**Backend Invocation** — `invokeai/app/invocations/[newmodel]/[newmodel]_controlnet.py`: -```python title="invokeai/app/invocations/[newmodel]_controlnet.py" +```python title="invokeai/app/invocations/[newmodel]/[newmodel]_controlnet.py" @invocation("newmodel_controlnet", ...) class NewModelControlNetInvocation(BaseInvocation): image: ImageField = InputField() @@ -1091,9 +1113,9 @@ const { controlNets } = await addControlNets({ g, manager, denoise }); ### IP-Adapter / Reference Images -**Backend Invocation** — `invokeai/app/invocations/[newmodel]_ip_adapter.py`: +**Backend Invocation** — `invokeai/app/invocations/[newmodel]/[newmodel]_ip_adapter.py`: -```python title="invokeai/app/invocations/[newmodel]_ip_adapter.py" +```python title="invokeai/app/invocations/[newmodel]/[newmodel]_ip_adapter.py" @invocation("newmodel_ip_adapter", ...) class NewModelIPAdapterInvocation(BaseInvocation): image: ImageField = InputField() @@ -1119,7 +1141,7 @@ class LoRA_LyCORIS_NewModel_Config(LoRA_LyCORIS_Base): **Backend Model Loader Integration:** -```python title="invokeai/app/invocations/[newmodel]_model_loader.py" +```python title="invokeai/app/invocations/[newmodel]/[newmodel]_model_loader.py" class NewModelModelLoaderOutput(BaseInvocationOutput): transformer: TransformerField # TransformerField already contains loras: list[LoRAField] ``` @@ -1185,10 +1207,13 @@ For a **minimal txt2img integration**, the following files are required: - invokeai - app/invocations - metadata.py - - `[newmodel]_model_loader.py` - - `[newmodel]_text_encoder.py` - - `[newmodel]_denoise.py` - - `[newmodel]_vae_decode.py` + - `[newmodel]` + - `[newmodel]_model_loader.py` + - `[newmodel]_denoise.py` + - text_encoder + - `[newmodel]_text_encoder.py` + - vae + - `[newmodel]_vae_decode.py` - backend - model_manager - taxonomy.py @@ -1213,7 +1238,8 @@ For **img2img / inpaint / outpaint**, additionally: - invokeai - app/invocations - - `[newmodel]_vae_encode.py` + - vae + - `[newmodel]_vae_encode.py` - frontend/web/src/features/nodes/util/graph/generation - addImageToImage.ts - addInpaint.ts From 80085e77165a8845b55ebfbcd065e6900bc1b4dc Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 05:50:28 +0200 Subject: [PATCH 04/26] refactor(util): extract the package walker used to fill registries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two registries are filled by importing modules rather than from a hand-maintained list: node invocations, and — next — model architectures. Both fail identically when discovery is subtly wrong: they find nothing, register nothing, and stay green. Having one walker means the pitfalls (descending into subpackages, not swallowing a broken one, skipping private paths) are handled and tested once instead of drifting apart in two copies. The walk returns names and leaves importing to the caller, which is what lets it be tested against a synthetic tree. That matters more than it looks: a walker with a bug returns an empty list, so a test asserting only "some modules were found" against the real package would pass. Splits the tests accordingly — the walker's own behaviour is pinned in tests/backend/util/, and what remains under tests/app/invocations/ is the check that this package's layout on disk agrees with what was imported. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/invocations/__init__.py | 38 ++------------ invokeai/backend/util/module_discovery.py | 45 ++++++++++++++++ tests/app/invocations/test_node_discovery.py | 49 +++--------------- tests/backend/util/test_module_discovery.py | 54 ++++++++++++++++++++ 4 files changed, 109 insertions(+), 77 deletions(-) create mode 100644 invokeai/backend/util/module_discovery.py create mode 100644 tests/backend/util/test_module_discovery.py diff --git a/invokeai/app/invocations/__init__.py b/invokeai/app/invocations/__init__.py index 86d9e7960a3..d71ed35319b 100644 --- a/invokeai/app/invocations/__init__.py +++ b/invokeai/app/invocations/__init__.py @@ -10,45 +10,15 @@ not surface at boot but later, as an "unknown node type" when a user opens a workflow that uses one. """ -import pkgutil from importlib import import_module from pathlib import Path from types import ModuleType -_PACKAGE_ROOT = Path(__file__).parent +from invokeai.backend.util.module_discovery import discover_modules - -def _on_discovery_error(name: str) -> None: - """Re-raise whatever broke while walking the tree. - - `pkgutil.walk_packages` swallows errors raised while importing a *subpackage* by default, which - would turn "this architecture's `__init__.py` is broken" into "these nodes quietly do not exist". - That is the exact failure mode this module exists to prevent, so refuse to continue. - """ - raise ImportError(f"Failed to walk invocation package {name!r} while discovering nodes.") - - -def discover_node_modules(root: Path = _PACKAGE_ROOT, prefix: str = f"{__name__}.") -> list[str]: - """Fully-qualified names of every non-private module in the package tree rooted at `root`. - - A path component starting with `_` excludes the module: that covers `__pycache__` and marks a - module as internal. Subpackages themselves are skipped — importing them is a side effect of the - walk, and their `__init__.py` files hold no nodes. - - Parameterized on `root`/`prefix` so the walk can be exercised against a synthetic tree. A walker - with a bug here finds nothing and stays green forever, so it needs a test that does not depend - on the layout of this package. - """ - names: list[str] = [] - for info in pkgutil.walk_packages([str(root)], prefix=prefix, onerror=_on_discovery_error): - relative = info.name.removeprefix(prefix) - if info.ispkg or any(part.startswith("_") for part in relative.split(".")): - continue - names.append(info.name) - return names - - -_MODULES: dict[str, ModuleType] = {name: import_module(name) for name in discover_node_modules()} +_MODULES: dict[str, ModuleType] = { + name: import_module(name) for name in discover_modules(Path(__file__).parent, f"{__name__}.") +} # `import *` binds names, and a dotted name is not one. Only the top component of each module path # is an attribute of this package, so a node in a subpackage contributes that subpackage's name. diff --git a/invokeai/backend/util/module_discovery.py b/invokeai/backend/util/module_discovery.py new file mode 100644 index 00000000000..375abe414ea --- /dev/null +++ b/invokeai/backend/util/module_discovery.py @@ -0,0 +1,45 @@ +"""Import-for-side-effect discovery of the modules in a package tree. + +Two registries in this codebase are filled by importing modules rather than from a hand-maintained +list: node invocations (`invokeai.app.invocations`) and model architectures +(`invokeai.backend.architectures.defs`). Both fail the same way when discovery is subtly wrong — +they find nothing, register nothing, and stay green — so both go through this one function, and its +pitfalls are handled and tested in one place. +""" + +import pkgutil +from pathlib import Path + + +def _reraise(name: str) -> None: + """Refuse to continue past a subpackage that would not import. + + `pkgutil.walk_packages` swallows such errors by default, which turns "this package's + `__init__.py` is broken" into "the things it holds quietly do not exist" — the exact failure + mode a registry filled by import is meant to avoid. + """ + raise ImportError(f"Failed to walk package {name!r} while discovering modules.") + + +def discover_modules(root: Path, prefix: str) -> list[str]: + """Fully-qualified names of every non-private module in the package tree rooted at `root`. + + `prefix` is the dotted path of the package that lives at `root`, trailing dot included; it is + what the returned names are prefixed with, and what `walk_packages` uses to import subpackages + so it can descend into them. + + A path component starting with `_` excludes the module: that covers `__pycache__` and marks a + module as internal. Packages themselves are skipped — importing them is a side effect of the + walk, and it is their contents that carry the registrations. + + Names only; importing them is the caller's job. Keeping the two apart is what lets the walk be + tested against a synthetic tree, which matters because a walker with a bug returns an empty list + and no test that merely asserts "some modules were found" would notice. + """ + names: list[str] = [] + for info in pkgutil.walk_packages([str(root)], prefix=prefix, onerror=_reraise): + relative = info.name.removeprefix(prefix) + if info.ispkg or any(part.startswith("_") for part in relative.split(".")): + continue + names.append(info.name) + return names diff --git a/tests/app/invocations/test_node_discovery.py b/tests/app/invocations/test_node_discovery.py index 1c0e1189bc2..a19d8e9f50f 100644 --- a/tests/app/invocations/test_node_discovery.py +++ b/tests/app/invocations/test_node_discovery.py @@ -1,56 +1,19 @@ -"""The invocation walker must find nodes in subpackages, not just in the top directory. +"""Every node module on disk must actually have been imported. -A walker that silently finds nothing is green forever, so the self-tests below run it against a -synthetic tree whose expected result is written out by hand — independent of how -`invokeai/app/invocations/` happens to be laid out today. +The walker itself is exercised against a synthetic tree in +`tests/backend/util/test_module_discovery.py`. What is left to check here is that this package's +real layout agrees with it — the realistic mistake being a new architecture folder that never got an +`__init__.py`, which is not a package and therefore contributes no nodes at all. """ -import sys -from collections.abc import Iterator from pathlib import Path -import pytest - -from invokeai.app.invocations import _MODULES, discover_node_modules +from invokeai.app.invocations import _MODULES PACKAGE = "invokeai.app.invocations" -@pytest.fixture -def synthetic_tree(tmp_path: Path) -> Iterator[Path]: - """A miniature invocations package: one flat module, one in a subpackage, plus things to skip.""" - root = tmp_path / "synthetic_nodes" - (root / "arch").mkdir(parents=True) - (root / "__pycache__").mkdir() - (root / "__init__.py").write_text("", encoding="utf-8") - (root / "flat_node.py").write_text("", encoding="utf-8") - (root / "_private.py").write_text("", encoding="utf-8") - (root / "arch" / "__init__.py").write_text("", encoding="utf-8") - (root / "arch" / "nested_node.py").write_text("", encoding="utf-8") - (root / "__pycache__" / "stale.py").write_text("", encoding="utf-8") - - sys.path.insert(0, str(tmp_path)) - try: - yield root - finally: - sys.path.remove(str(tmp_path)) - for name in [m for m in sys.modules if m.startswith("synthetic_nodes")]: - del sys.modules[name] - - -def test_walker_descends_into_subpackages(synthetic_tree: Path) -> None: - found = discover_node_modules(synthetic_tree, prefix="synthetic_nodes.") - assert sorted(found) == ["synthetic_nodes.arch.nested_node", "synthetic_nodes.flat_node"] - - -def test_walker_reports_a_broken_subpackage(synthetic_tree: Path) -> None: - (synthetic_tree / "arch" / "__init__.py").write_text("raise RuntimeError('boom')", encoding="utf-8") - with pytest.raises(ImportError, match="synthetic_nodes.arch"): - discover_node_modules(synthetic_tree, prefix="synthetic_nodes.") - - def test_every_node_module_on_disk_was_imported() -> None: - """Filesystem and registry agree. Catches a subpackage that never got an `__init__.py`.""" root = Path(__file__).parents[3] / "invokeai" / "app" / "invocations" on_disk = { f"{PACKAGE}." + p.relative_to(root).with_suffix("").as_posix().replace("/", ".") diff --git a/tests/backend/util/test_module_discovery.py b/tests/backend/util/test_module_discovery.py new file mode 100644 index 00000000000..f7f0d560c47 --- /dev/null +++ b/tests/backend/util/test_module_discovery.py @@ -0,0 +1,54 @@ +"""The package walker must descend into subpackages, and must not hide a broken one. + +A walker that silently finds nothing is green forever, so these run it against a synthetic tree +whose expected result is written out by hand — independent of any real package's layout. +""" + +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from invokeai.backend.util.module_discovery import discover_modules + + +@pytest.fixture +def tree(tmp_path: Path) -> Iterator[Path]: + """A miniature package: one flat module, one in a subpackage, plus things that must be skipped.""" + root = tmp_path / "synthetic_pkg" + (root / "sub").mkdir(parents=True) + (root / "__pycache__").mkdir() + (root / "__init__.py").write_text("", encoding="utf-8") + (root / "flat.py").write_text("", encoding="utf-8") + (root / "_private.py").write_text("", encoding="utf-8") + (root / "sub" / "__init__.py").write_text("", encoding="utf-8") + (root / "sub" / "nested.py").write_text("", encoding="utf-8") + (root / "__pycache__" / "stale.py").write_text("", encoding="utf-8") + + sys.path.insert(0, str(tmp_path)) + try: + yield root + finally: + sys.path.remove(str(tmp_path)) + for name in [m for m in sys.modules if m.startswith("synthetic_pkg")]: + del sys.modules[name] + + +def test_descends_into_subpackages(tree: Path) -> None: + assert sorted(discover_modules(tree, "synthetic_pkg.")) == [ + "synthetic_pkg.flat", + "synthetic_pkg.sub.nested", + ] + + +def test_skips_private_modules_and_pycache(tree: Path) -> None: + found = discover_modules(tree, "synthetic_pkg.") + assert not [n for n in found if "_private" in n or "__pycache__" in n or "stale" in n] + + +def test_reports_a_broken_subpackage(tree: Path) -> None: + """The default `walk_packages` behaviour is to swallow this, leaving the caller none the wiser.""" + (tree / "sub" / "__init__.py").write_text("raise RuntimeError('boom')", encoding="utf-8") + with pytest.raises(ImportError, match="synthetic_pkg.sub"): + discover_modules(tree, "synthetic_pkg.") From 1010b1651c48db5136df97d5545952ce7588ba22 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 06:10:34 +0200 Subject: [PATCH 05/26] feat(architectures): add the facet registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a model architecture means editing a long tail of core files — a `step_callback` branch, a `safe_globals` entry, a variant-enum lookup. The count is not the problem; the failure mode is. Almost all of those edits, when forgotten, fail at *generation* time rather than at boot: a missing preview branch raises "Unsupported base model" on the first step, an unregistered `*ConditioningInfo` breaks deserialization halfway through a graph. This is the container those facts will move into: one module per architecture under `defs/`, and a `validate()` that turns "you forgot one" into a startup error. Sixteen architectures are registered here declaring nothing at all, so the structure is sharp and the semantics are still empty. It becomes load-bearing when the first facet marks itself `REQUIRED`. The internal direction of dependency is facet.py <- registry.py <- facets/* <- defs/* <- __init__.py <- the rest of the codebase which is why `Facet` is its own module rather than part of `registry`: every facet needs both, and merging them would make each facet import the registry it is registered into. `defs/` and `facets/` are discovered by walking the package, not from an import list. That removes the most likely contributor mistake — a new file that nobody imports — and it moves the check to a better place: a `BaseModelType` member with no module under `defs/` is now caught by `validate()` directly, rather than by noticing an absent import line. `facets/` is walked for a sharper reason than symmetry: `validate()` learns which facets are required from `Facet.FACET_TYPES`, filled at class creation, so a facet module nothing happened to import would have its requirement silently unchecked — exactly for a facet so new that no architecture declares it yet. Modules under `defs/` import `architectures.registry` directly and never the `architectures` package. It is that package's own import that brings them into being, so reaching back for an attribute would find a half-initialized module. A layering test pins this. Two details that are easy to get wrong and are commented in place: `Facet.FACET_TYPES` is a dict rather than a set, because set iteration order is non-deterministic and has already produced a real bug here; and `ArchitectureError` subclasses `ValueError`, because it replaces `raise ValueError("Unsupported base model: ...")` at call sites whose handlers must keep working. Filenames under `defs/` are the base value with `-` replaced by `_`, derived from the one identifier that cannot change because it is persisted in the model database. That lets an error message compute the file a contributor has to open instead of consulting a second table that could disagree. The plan this follows expected `registry.py` to stay torch-free so later lightweight consumers could import it cheaply. That is not achievable: it needs `BaseModelType`, and `taxonomy` imports torch, onnxruntime and diffusers at module scope. Keying the registry on strings instead would avoid the import but break the enum's contract with `openapi.json`, which is worse. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/backend/architectures/__init__.py | 31 ++++ .../backend/architectures/defs/__init__.py | 20 +++ invokeai/backend/architectures/defs/anima.py | 6 + .../backend/architectures/defs/cogview4.py | 6 + .../backend/architectures/defs/ernie_image.py | 6 + invokeai/backend/architectures/defs/flux.py | 6 + invokeai/backend/architectures/defs/flux2.py | 6 + .../backend/architectures/defs/ideogram_4.py | 6 + invokeai/backend/architectures/defs/krea_2.py | 6 + .../backend/architectures/defs/minimax_h3.py | 6 + .../backend/architectures/defs/qwen_image.py | 6 + invokeai/backend/architectures/defs/sd_1.py | 6 + invokeai/backend/architectures/defs/sd_2.py | 6 + invokeai/backend/architectures/defs/sd_3.py | 6 + invokeai/backend/architectures/defs/sdxl.py | 6 + .../architectures/defs/sdxl_refiner.py | 6 + invokeai/backend/architectures/defs/wan.py | 6 + .../backend/architectures/defs/z_image.py | 6 + invokeai/backend/architectures/facet.py | 33 ++++ .../backend/architectures/facets/__init__.py | 18 ++ invokeai/backend/architectures/registry.py | 154 ++++++++++++++++++ 21 files changed, 352 insertions(+) create mode 100644 invokeai/backend/architectures/__init__.py create mode 100644 invokeai/backend/architectures/defs/__init__.py create mode 100644 invokeai/backend/architectures/defs/anima.py create mode 100644 invokeai/backend/architectures/defs/cogview4.py create mode 100644 invokeai/backend/architectures/defs/ernie_image.py create mode 100644 invokeai/backend/architectures/defs/flux.py create mode 100644 invokeai/backend/architectures/defs/flux2.py create mode 100644 invokeai/backend/architectures/defs/ideogram_4.py create mode 100644 invokeai/backend/architectures/defs/krea_2.py create mode 100644 invokeai/backend/architectures/defs/minimax_h3.py create mode 100644 invokeai/backend/architectures/defs/qwen_image.py create mode 100644 invokeai/backend/architectures/defs/sd_1.py create mode 100644 invokeai/backend/architectures/defs/sd_2.py create mode 100644 invokeai/backend/architectures/defs/sd_3.py create mode 100644 invokeai/backend/architectures/defs/sdxl.py create mode 100644 invokeai/backend/architectures/defs/sdxl_refiner.py create mode 100644 invokeai/backend/architectures/defs/wan.py create mode 100644 invokeai/backend/architectures/defs/z_image.py create mode 100644 invokeai/backend/architectures/facet.py create mode 100644 invokeai/backend/architectures/facets/__init__.py create mode 100644 invokeai/backend/architectures/registry.py diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py new file mode 100644 index 00000000000..173996707d0 --- /dev/null +++ b/invokeai/backend/architectures/__init__.py @@ -0,0 +1,31 @@ +"""Facts about model architectures, one file per architecture. + +Import this package to use the registry; importing it fills the registry as a side effect. Modules +*inside* the package import `architectures.registry` directly instead — see `defs/__init__.py`. +""" + +from invokeai.backend.architectures import defs as defs # noqa: F401 (imported for side effects) +from invokeai.backend.architectures import facets as facets # noqa: F401 (imported for side effects) +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.registry import ( + ArchitectureError, + defs_module_path, + facets_of, + generative_bases, + get, + register, + require, + validate, +) + +__all__ = [ + "ArchitectureError", + "Facet", + "defs_module_path", + "facets_of", + "generative_bases", + "get", + "register", + "require", + "validate", +] diff --git a/invokeai/backend/architectures/defs/__init__.py b/invokeai/backend/architectures/defs/__init__.py new file mode 100644 index 00000000000..d56e1846ca3 --- /dev/null +++ b/invokeai/backend/architectures/defs/__init__.py @@ -0,0 +1,20 @@ +"""One module per model architecture, each calling `registry.register(...)` exactly once. + +The modules here are discovered and imported automatically — there is no list to add to. The +filename is the base value with `-` replaced by `_`, which is what `registry.defs_module_path()` +computes, so an error message can name the file to edit without a second table to keep in sync. + +Modules here import `architectures.registry` directly and never the `architectures` package. Doing +the latter would ask a partially-initialized package for an attribute, since it is that package's +own import that brings us here. +""" + +from importlib import import_module +from pathlib import Path +from types import ModuleType + +from invokeai.backend.util.module_discovery import discover_modules + +_MODULES: dict[str, ModuleType] = { + name: import_module(name) for name in discover_modules(Path(__file__).parent, f"{__name__}.") +} diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py new file mode 100644 index 00000000000..73d4ead201a --- /dev/null +++ b/invokeai/backend/architectures/defs/anima.py @@ -0,0 +1,6 @@ +"""What the anima architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.Anima) diff --git a/invokeai/backend/architectures/defs/cogview4.py b/invokeai/backend/architectures/defs/cogview4.py new file mode 100644 index 00000000000..fd4cc68d192 --- /dev/null +++ b/invokeai/backend/architectures/defs/cogview4.py @@ -0,0 +1,6 @@ +"""What the cogview4 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.CogView4) diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py new file mode 100644 index 00000000000..76a023dffa5 --- /dev/null +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -0,0 +1,6 @@ +"""What the ernie-image architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.ErnieImage) diff --git a/invokeai/backend/architectures/defs/flux.py b/invokeai/backend/architectures/defs/flux.py new file mode 100644 index 00000000000..048aeb0f84f --- /dev/null +++ b/invokeai/backend/architectures/defs/flux.py @@ -0,0 +1,6 @@ +"""What the flux architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.Flux) diff --git a/invokeai/backend/architectures/defs/flux2.py b/invokeai/backend/architectures/defs/flux2.py new file mode 100644 index 00000000000..bbc1daa2afa --- /dev/null +++ b/invokeai/backend/architectures/defs/flux2.py @@ -0,0 +1,6 @@ +"""What the flux2 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.Flux2) diff --git a/invokeai/backend/architectures/defs/ideogram_4.py b/invokeai/backend/architectures/defs/ideogram_4.py new file mode 100644 index 00000000000..8d48d9fb5b9 --- /dev/null +++ b/invokeai/backend/architectures/defs/ideogram_4.py @@ -0,0 +1,6 @@ +"""What the ideogram-4 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.Ideogram4) diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py new file mode 100644 index 00000000000..7eeaca2c046 --- /dev/null +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -0,0 +1,6 @@ +"""What the krea-2 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.Krea2) diff --git a/invokeai/backend/architectures/defs/minimax_h3.py b/invokeai/backend/architectures/defs/minimax_h3.py new file mode 100644 index 00000000000..3c3cb0fd194 --- /dev/null +++ b/invokeai/backend/architectures/defs/minimax_h3.py @@ -0,0 +1,6 @@ +"""What the minimax-h3 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.MiniMaxH3) diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py new file mode 100644 index 00000000000..cf4714df776 --- /dev/null +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -0,0 +1,6 @@ +"""What the qwen-image architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.QwenImage) diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py new file mode 100644 index 00000000000..022d0385f7b --- /dev/null +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -0,0 +1,6 @@ +"""What the sd-1 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.StableDiffusion1) diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py new file mode 100644 index 00000000000..8e5f1a1cb28 --- /dev/null +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -0,0 +1,6 @@ +"""What the sd-2 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.StableDiffusion2) diff --git a/invokeai/backend/architectures/defs/sd_3.py b/invokeai/backend/architectures/defs/sd_3.py new file mode 100644 index 00000000000..21046883039 --- /dev/null +++ b/invokeai/backend/architectures/defs/sd_3.py @@ -0,0 +1,6 @@ +"""What the sd-3 architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.StableDiffusion3) diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py new file mode 100644 index 00000000000..f6ad6234229 --- /dev/null +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -0,0 +1,6 @@ +"""What the sdxl architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.StableDiffusionXL) diff --git a/invokeai/backend/architectures/defs/sdxl_refiner.py b/invokeai/backend/architectures/defs/sdxl_refiner.py new file mode 100644 index 00000000000..0b37e5eda4c --- /dev/null +++ b/invokeai/backend/architectures/defs/sdxl_refiner.py @@ -0,0 +1,6 @@ +"""What the sdxl-refiner architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.StableDiffusionXLRefiner) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py new file mode 100644 index 00000000000..5b90c87db9e --- /dev/null +++ b/invokeai/backend/architectures/defs/wan.py @@ -0,0 +1,6 @@ +"""What the wan architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.Wan) diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py new file mode 100644 index 00000000000..8bd63d08149 --- /dev/null +++ b/invokeai/backend/architectures/defs/z_image.py @@ -0,0 +1,6 @@ +"""What the z-image architecture declares.""" + +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.taxonomy import BaseModelType + +register(BaseModelType.ZImage) diff --git a/invokeai/backend/architectures/facet.py b/invokeai/backend/architectures/facet.py new file mode 100644 index 00000000000..cc6c1140355 --- /dev/null +++ b/invokeai/backend/architectures/facet.py @@ -0,0 +1,33 @@ +"""The marker base class for architecture facets. + +Deliberately its own module rather than part of `registry`: `facets/*` needs both, and putting +`Facet` in `registry` would make every facet module import the registry it is registered into. +""" + +from typing import Any, ClassVar + + +class Facet: + """One immutable, self-contained fact about a model architecture. + + Concrete facets are frozen dataclasses carrying plain data — never services, never model + instances. They are keyed in the registry by their *exact* runtime type, so a facet class must + not be subclassed by another facet class. + + Subclasses are collected at class-creation time so `registry.validate()` can check every + registered architecture against the facets that declare themselves required. This mirrors + `Config_Base.CONFIG_CLASSES`, but uses a dict rather than a set: a set iterates in a + non-deterministic order, which has already produced a real bug in this codebase (see + tests/backend/model_manager/configs/test_wan_lora_probe_independence.py). Error messages built + by walking this collection would otherwise reorder between runs. + """ + + REQUIRED: ClassVar[bool] = False + """Whether every architecture must declare this facet. Checked at boot, not at first use.""" + + FACET_TYPES: ClassVar[dict[type["Facet"], None]] = {} + """Every concrete facet class, in definition order. A dict used as an ordered set.""" + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + Facet.FACET_TYPES[cls] = None diff --git a/invokeai/backend/architectures/facets/__init__.py b/invokeai/backend/architectures/facets/__init__.py new file mode 100644 index 00000000000..96d26bb417e --- /dev/null +++ b/invokeai/backend/architectures/facets/__init__.py @@ -0,0 +1,18 @@ +"""Concrete facets — the kinds of fact an architecture can declare. + +Imported automatically, like `defs/`, and for a sharper reason than symmetry: `validate()` checks +each architecture against the facets that marked themselves `REQUIRED`, and it learns which those +are from `Facet.FACET_TYPES`, which is filled at class-creation time. A facet module that nothing +happened to import would be absent from that collection, so its requirement would go unchecked — +silently, and precisely for a facet so new that no architecture declares it yet. +""" + +from importlib import import_module +from pathlib import Path +from types import ModuleType + +from invokeai.backend.util.module_discovery import discover_modules + +_MODULES: dict[str, ModuleType] = { + name: import_module(name) for name in discover_modules(Path(__file__).parent, f"{__name__}.") +} diff --git a/invokeai/backend/architectures/registry.py b/invokeai/backend/architectures/registry.py new file mode 100644 index 00000000000..5392674d899 --- /dev/null +++ b/invokeai/backend/architectures/registry.py @@ -0,0 +1,154 @@ +"""The architecture registry: which facets each `BaseModelType` declares. + +Adding a model architecture means editing a long tail of core files — a `step_callback` branch, a +`safe_globals` entry, a variant-enum lookup — and forgetting one of them fails at *generation* time +rather than at boot. The registry moves those facts next to each other, in one file per +architecture, and `validate()` turns "you forgot one" into a startup error. + +Storage and error behaviour follow `model_manager.load.model_loader_registry`: a plain dict, and a +double registration raises with the full context rather than overwriting. +""" + +from typing import Final, TypeVar + +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.model_manager.taxonomy import BaseModelType + +FacetT = TypeVar("FacetT", bound=Facet) + +_ARCHITECTURES: Final[dict[BaseModelType, dict[type[Facet], Facet]]] = {} + +_NOT_ARCHITECTURES: Final = frozenset( + { + # A fallback for models with no architecture association at all (CLIP, and the like). + BaseModelType.Any, + # Not an architecture but a hosting mode: the model runs at an external provider. + BaseModelType.External, + # Identification failed. Nothing can be declared about it by definition. + BaseModelType.Unknown, + } +) +"""The `BaseModelType` members that are not architectures. The single definition of that set: both +`register()` and `validate()` read it, so there is no second list to keep in sync.""" + +_PACKAGE = "invokeai/backend/architectures" + + +class ArchitectureError(ValueError): + """Raised for a missing or malformed architecture declaration. + + Subclasses `ValueError` deliberately: it replaces `raise ValueError(f"Unsupported base model: + ...")` at the call sites it takes over, so existing `except ValueError` handlers keep behaving + exactly as they did. + """ + + +def defs_module_path(base: BaseModelType) -> str: + """The file a contributor has to edit to change what `base` declares. + + Derived from `base.value` — the one identifier that cannot change, because it is persisted in + the model database — rather than read from a second table that could disagree with reality. + """ + return f"{_PACKAGE}/defs/{base.value.replace('-', '_')}.py" + + +def register(base: BaseModelType, *facets: Facet) -> None: + """Declare what `base` is. Called once per architecture, from its own module under `defs/`.""" + if base in _NOT_ARCHITECTURES: + raise ArchitectureError( + f"'{base.value}' is not a model architecture and cannot be registered. " + f"It is one of {sorted(b.value for b in _NOT_ARCHITECTURES)}." + ) + if base in _ARCHITECTURES: + raise ArchitectureError( + f"Architecture '{base.value}' is already registered. Every architecture is declared " + f"exactly once, in {defs_module_path(base)}." + ) + + by_type: dict[type[Facet], Facet] = {} + for facet in facets: + facet_type = type(facet) + if facet_type in by_type: + raise ArchitectureError( + f"Architecture '{base.value}' declares {facet_type.__name__} more than once in the " + f"same register() call. See {defs_module_path(base)}." + ) + by_type[facet_type] = facet + _ARCHITECTURES[base] = by_type + + +def get(base: BaseModelType, facet_type: type[FacetT]) -> FacetT | None: + """The facet of that type declared by `base`, or None. Use `require` unless None is meaningful.""" + facet = _ARCHITECTURES.get(base, {}).get(facet_type) + # `isinstance` narrows `Facet | None` to `FacetT`. The registry keys by exact runtime type, so a + # hit always passes; the check is here to satisfy the type checker without a cast. + return facet if isinstance(facet, facet_type) else None + + +def require(base: BaseModelType, facet_type: type[FacetT]) -> FacetT: + """The facet of that type declared by `base`. Raises, naming the file to edit, if it is missing. + + The message is the point of this function. It is read by someone whose new architecture just + failed mid-generation, and it has to say which file to open — not merely which base was + unsupported. + """ + facet = get(base, facet_type) + if facet is not None: + return facet + if base not in _ARCHITECTURES: + raise ArchitectureError( + f"Architecture '{base.value}' is not registered, so it cannot declare " + f"{facet_type.__name__}. Create {defs_module_path(base)} with a " + f"`register(BaseModelType.{base.name}, {facet_type.__name__}(...))` call. It is picked " + f"up automatically; there is no import list to edit." + ) + raise ArchitectureError( + f"Architecture '{base.value}' does not declare {facet_type.__name__}. Add " + f"{facet_type.__name__}(...) to the `register(...)` call in {defs_module_path(base)}." + ) + + +def generative_bases() -> frozenset[BaseModelType]: + """Every registered architecture.""" + return frozenset(_ARCHITECTURES) + + +def facets_of(base: BaseModelType) -> tuple[Facet, ...]: + """Everything `base` declares. For diagnostics and tests, not for dispatch.""" + return tuple(_ARCHITECTURES.get(base, {}).values()) + + +def validate() -> None: + """Check the registry is complete. Called at boot; raises rather than warns. + + Two things are checked, and both are boot errors rather than CI-only assertions because the + whole point is that an incompletely declared architecture cannot start the app: + + 1. Every `BaseModelType` that is not a sentinel has a module under `defs/`. Discovery is + automatic, so the way to fail here is to add an enum member and no file. + 2. Every registered architecture declares every facet marked `REQUIRED`. + + Unlike the neighbouring custom-node check in `run_app`, which warns, this raises: architectures + are first-party and the set is closed, so an incomplete one is a bug in this repository. + """ + missing_bases = sorted( + base.value for base in BaseModelType if base not in _NOT_ARCHITECTURES and base not in _ARCHITECTURES + ) + if missing_bases: + raise ArchitectureError( + "These architectures are not registered: " + + ", ".join(f"'{b}'" for b in missing_bases) + + ". Each needs a module under " + + f"{_PACKAGE}/defs/ calling `register(...)`; the filename is the base value with " + + "'-' replaced by '_'." + ) + + required = [facet_type for facet_type in Facet.FACET_TYPES if facet_type.REQUIRED] + problems = [ + f"'{base.value}' does not declare {facet_type.__name__} (add it in {defs_module_path(base)})" + for base in sorted(_ARCHITECTURES, key=lambda b: b.value) + for facet_type in required + if facet_type not in _ARCHITECTURES[base] + ] + if problems: + raise ArchitectureError("Incomplete architecture declarations:\n " + "\n ".join(problems)) From 5e785659071837dbeb85ed23607ed0195a2fa777 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 06:11:01 +0200 Subject: [PATCH 06/26] test(architectures): registry mechanics, completeness gate and layering policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three files, guarding three different things. `test_registry.py` exercises the mechanics against throwaway facets rather than the real ones, so it does not restate what production declares — otherwise it would fail on every legitimate change to an architecture, and pass for the wrong reason when a facet is quietly dropped. It includes the negative probe for the boot gate: with a required facet nobody declares, `validate()` must raise and name the file to edit. Without that, `validate()` could be a no-op forever and nothing would notice. The test doubles are removed from `Facet.FACET_TYPES` at import. They land there via `__init_subclass__` the moment the module is read, which is before any fixture runs, and a leftover `REQUIRED` double makes the real registry fail validation for the rest of the session. This was not theoretical — it is how the completeness test caught the leak. `test_registry_completeness.py` runs against the real registry and is what makes the boot check meaningful while no facet is required yet: `generative_bases()` must equal the enum minus the three sentinels. That equation appears in exactly one place, here. Production never computes it — being registered is what makes an architecture generative — so writing it down anywhere else would create a second source of truth. It also checks the directory in both directions, because a stale module for a renamed base is as invisible as a missing one. `test_layering.py` is an AST policy modelled on the frontend's dependencyPolicy.test.ts: named rules, one assertion listing every violation at once, and self-tests proving the checker catches things. The self-tests are the important half. A walker with a bug reports no violations and stays green forever, which is indistinguishable from a codebase that obeys the rules. Two of the seven exist for mistakes already made while writing this: one pins that `from a.b.c import X` is reported as depending on `a.b.c` and not on `a.b`, and one pins the one-character distinction between `facet` (allowed from the registry) and `facets` (forbidden) from both sides. The relative-import ban is scoped to the architecture package rather than the repository. Applied everywhere it flags `app/services/model_records/__init__.py`, which is real but unrelated debt — that file writes `# noqa F401` without the colon, which ruff reads as a blanket suppression, so TID252 has been silenced there by accident. Co-Authored-By: Claude Opus 5 (1M context) --- tests/backend/architectures/test_layering.py | 183 ++++++++++++++++++ tests/backend/architectures/test_registry.py | 136 +++++++++++++ .../test_registry_completeness.py | 59 ++++++ 3 files changed, 378 insertions(+) create mode 100644 tests/backend/architectures/test_layering.py create mode 100644 tests/backend/architectures/test_registry.py create mode 100644 tests/backend/architectures/test_registry_completeness.py diff --git a/tests/backend/architectures/test_layering.py b/tests/backend/architectures/test_layering.py new file mode 100644 index 00000000000..41fd2debe55 --- /dev/null +++ b/tests/backend/architectures/test_layering.py @@ -0,0 +1,183 @@ +"""The architecture package has an internal direction of dependency; this pins it. + + facet.py <- registry.py <- facets/* <- defs/* <- __init__.py <- the rest of the codebase + +Two of these edges are load-bearing rather than tidy. `defs/*` must import `architectures.registry` +directly and never the `architectures` package, because it is that package's own import that brings +the defs modules into being — reaching back for an attribute would find a half-initialized module. +And everything outside must go through the facade, so that importing the registry always means the +registry has been filled. + +Modelled on invokeai/frontend/webv2/src/architecture/dependencyPolicy.test.ts: named rules, a single +assertion listing every violation at once, and self-tests proving the checker actually catches +things. The self-tests are the important half — an AST walker with a bug reports no violations and +stays green forever, which is indistinguishable from a codebase that obeys the rules. +""" + +import ast +from collections.abc import Iterable +from pathlib import Path + +REPO_ROOT = Path(__file__).parents[3] +ARCH = "invokeai.backend.architectures" +ARCH_DIR = "invokeai/backend/architectures" +TAXONOMY = "invokeai.backend.model_manager.taxonomy" +DISCOVERY = "invokeai.backend.util.module_discovery" + +# Vendored third-party trees, mirroring [tool.ruff] exclude, plus the frontend. +EXCLUDED = ( + "invokeai/backend/image_util/mediapipe_face", + "invokeai/backend/image_util/mlsd", + "invokeai/backend/image_util/normal_bae", + "invokeai/backend/image_util/pidi", + "invokeai/backend/image_util/imwatermark", + "invokeai/frontend", +) + + +def _allowed_for(path: str) -> tuple[str, frozenset[str], tuple[str, ...]] | None: + """The rule governing `path`: its name, the exact imports it may make, and allowed prefixes. + + Returns None for a file no rule covers, which is most of the repository — those are only + subject to `aggregate-only` below. + """ + if path == f"{ARCH_DIR}/facet.py": + return "facet-is-a-leaf", frozenset(), () + if path == f"{ARCH_DIR}/registry.py": + return "registry-is-a-leaf", frozenset({f"{ARCH}.facet", TAXONOMY}), () + if path == f"{ARCH_DIR}/__init__.py": + return "aggregate-is-a-facade", frozenset({ARCH}), (f"{ARCH}.",) + if path.startswith(f"{ARCH_DIR}/facets/"): + return "facets-allowlist", frozenset({f"{ARCH}.facet", f"{ARCH}.registry", TAXONOMY, DISCOVERY}), () + if path.startswith(f"{ARCH_DIR}/defs/"): + return ( + "defs-allowlist", + frozenset({f"{ARCH}.facet", f"{ARCH}.facets", f"{ARCH}.registry", TAXONOMY, DISCOVERY}), + (f"{ARCH}.facets.",), + ) + return None + + +def _imported_modules(tree: ast.AST) -> set[str]: + """Every `invokeai.*` module the file depends on, plus a marker for any relative import. + + `from a.b import c` yields `a.b`, and also `a.b.c` when that is a module on disk — otherwise + `from ...defs import wan` would be invisible while `import ...defs.wan` was not. The on-disk + check is what keeps `from ...registry import require` from being read as a module import. + + Imports guarded by `if TYPE_CHECKING:` count. A type-only edge is still an architecture edge: + it is the reason a module cannot later be moved or split. + """ + modules: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + modules.update(alias.name for alias in node.names if alias.name.startswith("invokeai.")) + elif isinstance(node, ast.ImportFrom): + if node.level > 0: + modules.add("") + continue + if not node.module or not node.module.startswith("invokeai."): + continue + modules.add(node.module) + for alias in node.names: + candidate = f"{node.module}.{alias.name}" + relative = candidate.replace(".", "/") + if (REPO_ROOT / f"{relative}.py").exists() or (REPO_ROOT / relative / "__init__.py").exists(): + modules.add(candidate) + return modules + + +def _violations(path: str, source: str) -> list[str]: + """Rule violations in one file, as `rule-name: importer -> imported` strings.""" + dotted = path.removesuffix(".py").replace("/", ".").removesuffix(".__init__") + rule = _allowed_for(path) + found: list[str] = [] + + for imported in sorted(_imported_modules(ast.parse(source))): + if rule is None: + # No rule covers this file, so it is outside the package: the facade or nothing. The + # relative-import ban is deliberately not applied here — ruff's TID252 owns the rest of + # the repository, and duplicating it would make this test fail on pre-existing debt that + # has nothing to do with the architecture package. + if imported.startswith(f"{ARCH}."): + found.append(f"aggregate-only: {dotted} -> {imported}") + elif imported == "": + found.append(f"no-relative-imports: {dotted}") + else: + name, exact, prefixes = rule + if imported not in exact and not imported.startswith(prefixes): + found.append(f"{name}: {dotted} -> {imported}") + return found + + +def _production_files() -> Iterable[tuple[str, str]]: + for p in sorted((REPO_ROOT / "invokeai").rglob("*.py")): + path = p.relative_to(REPO_ROOT).as_posix() + if path.startswith(EXCLUDED): + continue + yield path, p.read_text(encoding="utf-8") + + +def test_no_layering_violations() -> None: + violations = sorted(v for path, source in _production_files() for v in _violations(path, source)) + assert violations == [] + + +# --- self-tests: does the checker actually catch anything? ---------------------------------------- + + +def test_catches_defs_reaching_into_core() -> None: + v = _violations(f"{ARCH_DIR}/defs/wan.py", "from invokeai.app.util.step_callback import calc_percentage\n") + assert v == [f"defs-allowlist: {ARCH}.defs.wan -> invokeai.app.util.step_callback"] + + +def test_catches_defs_importing_the_aggregate() -> None: + """The circular-import trap: `defs` is imported *by* the package it would be reaching into.""" + v = _violations(f"{ARCH_DIR}/defs/wan.py", "from invokeai.backend.architectures import register\n") + assert v == [f"defs-allowlist: {ARCH}.defs.wan -> {ARCH}"] + + +def test_catches_core_bypassing_the_facade() -> None: + v = _violations("invokeai/app/util/step_callback.py", f"from {ARCH}.registry import require\n") + assert v == [f"aggregate-only: invokeai.app.util.step_callback -> {ARCH}.registry"] + + +def test_catches_a_type_checking_only_edge() -> None: + source = "from typing import TYPE_CHECKING\n\nif TYPE_CHECKING:\n from invokeai.app.invocations import model\n" + assert _violations(f"{ARCH_DIR}/registry.py", source) == [ + f"registry-is-a-leaf: {ARCH}.registry -> invokeai.app.invocations", + f"registry-is-a-leaf: {ARCH}.registry -> invokeai.app.invocations.model", + ] + + +def test_catches_a_relative_import() -> None: + assert _violations(f"{ARCH_DIR}/defs/wan.py", "from ..registry import register\n") == [ + f"no-relative-imports: {ARCH}.defs.wan" + ] + + +def test_catches_registry_importing_a_facet() -> None: + """`facet.py` is a separate module precisely so this edge cannot exist. + + Note the reported module is `...facets.latent_space`, not `...facets`: `from a.b.c import X` + depends on `a.b.c`. The one-character difference between `facet` (allowed) and `facets` + (forbidden) is the whole point of the rule, so the assertion spells the name out in full. + """ + v = _violations(f"{ARCH_DIR}/registry.py", f"from {ARCH}.facets.latent_space import LatentSpaceFacet\n") + assert v == [f"registry-is-a-leaf: {ARCH}.registry -> {ARCH}.facets.latent_space"] + + +def test_registry_may_import_facet_but_not_facets() -> None: + """Pins the one-character distinction from both sides.""" + assert _violations(f"{ARCH_DIR}/registry.py", f"from {ARCH}.facet import Facet\n") == [] + assert _violations(f"{ARCH_DIR}/registry.py", f"from {ARCH}.facets import something\n") == [ + f"registry-is-a-leaf: {ARCH}.registry -> {ARCH}.facets" + ] + + +def test_allows_the_legitimate_cases() -> None: + """A checker that flagged everything would also pass every test above.""" + assert _violations(f"{ARCH_DIR}/defs/wan.py", f"from {ARCH}.registry import register\n") == [] + assert _violations(f"{ARCH_DIR}/registry.py", f"from {TAXONOMY} import BaseModelType\n") == [] + assert _violations("invokeai/app/util/step_callback.py", f"from {ARCH} import require\n") == [] + assert _violations("invokeai/app/util/step_callback.py", "import torch\nfrom PIL import Image\n") == [] diff --git a/tests/backend/architectures/test_registry.py b/tests/backend/architectures/test_registry.py new file mode 100644 index 00000000000..0a7b9151e7f --- /dev/null +++ b/tests/backend/architectures/test_registry.py @@ -0,0 +1,136 @@ +"""Registry mechanics, exercised against throwaway facets rather than the real ones. + +Using dummy facets keeps these tests from restating what production declares — otherwise they would +fail on every legitimate change to an architecture, and pass for the wrong reason when a facet is +quietly dropped. +""" + +from collections.abc import Iterator +from dataclasses import dataclass + +import pytest + +from invokeai.backend.architectures import registry +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.model_manager.taxonomy import BaseModelType + + +@dataclass(frozen=True) +class _Colour(Facet): + name: str + + +@dataclass(frozen=True) +class _Size(Facet): + value: int + + +@dataclass(frozen=True) +class _Mandatory(Facet): + REQUIRED = True + + +# `Facet.__init_subclass__` put the three above into the real `Facet.FACET_TYPES` the moment this +# module was imported, and `validate()` reads that collection to decide what every architecture must +# declare — so leaving `_Mandatory` in it would make the real registry fail validation for the rest +# of the session. Undone here rather than in a fixture: the damage is done at import time, which is +# before any fixture runs. +for _test_double in (_Colour, _Size, _Mandatory): + Facet.FACET_TYPES.pop(_test_double, None) + + +@pytest.fixture +def isolated_registry(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """An empty registry, and an empty facet-type collection. + + Both have to be reset. `Facet.FACET_TYPES` is class state filled at import time by every facet + in the codebase, so leaving it alone would let a real `REQUIRED` facet leak into `validate()` + here and fail against architectures this test never registered. + """ + monkeypatch.setattr(registry, "_ARCHITECTURES", {}) + monkeypatch.setattr(Facet, "FACET_TYPES", {}) + yield + + +def test_register_then_get_round_trips(isolated_registry: None) -> None: + registry.register(BaseModelType.Flux, _Colour("blue"), _Size(3)) + assert registry.get(BaseModelType.Flux, _Colour) == _Colour("blue") + assert registry.get(BaseModelType.Flux, _Size) == _Size(3) + + +def test_get_returns_none_for_an_undeclared_facet(isolated_registry: None) -> None: + registry.register(BaseModelType.Flux, _Colour("blue")) + assert registry.get(BaseModelType.Flux, _Size) is None + + +def test_get_returns_none_for_an_unregistered_base(isolated_registry: None) -> None: + assert registry.get(BaseModelType.Wan, _Colour) is None + + +def test_facets_of_returns_everything_declared(isolated_registry: None) -> None: + registry.register(BaseModelType.Flux, _Colour("blue"), _Size(3)) + assert set(registry.facets_of(BaseModelType.Flux)) == {_Colour("blue"), _Size(3)} + + +def test_registering_twice_raises(isolated_registry: None) -> None: + registry.register(BaseModelType.Flux, _Colour("blue")) + with pytest.raises(registry.ArchitectureError, match="already registered"): + registry.register(BaseModelType.Flux, _Colour("red")) + + +def test_the_same_facet_type_twice_in_one_call_raises(isolated_registry: None) -> None: + with pytest.raises(registry.ArchitectureError, match="more than once"): + registry.register(BaseModelType.Flux, _Colour("blue"), _Colour("red")) + + +@pytest.mark.parametrize("base", [BaseModelType.Any, BaseModelType.External, BaseModelType.Unknown]) +def test_a_sentinel_cannot_be_registered(isolated_registry: None, base: BaseModelType) -> None: + with pytest.raises(registry.ArchitectureError, match="not a model architecture"): + registry.register(base, _Colour("blue")) + + +def test_require_names_the_file_to_edit_when_the_facet_is_missing(isolated_registry: None) -> None: + registry.register(BaseModelType.ZImage, _Colour("blue")) + with pytest.raises(registry.ArchitectureError) as exc: + registry.require(BaseModelType.ZImage, _Size) + assert "invokeai/backend/architectures/defs/z_image.py" in str(exc.value) + assert "_Size" in str(exc.value) + + +def test_require_says_how_to_create_a_missing_architecture(isolated_registry: None) -> None: + """The other half of the message: there is no file yet, so say what to put in it.""" + with pytest.raises(registry.ArchitectureError) as exc: + registry.require(BaseModelType.ZImage, _Size) + message = str(exc.value) + assert "invokeai/backend/architectures/defs/z_image.py" in message + assert "register(BaseModelType.ZImage, _Size(...))" in message + assert "no import list to edit" in message + + +def test_architecture_error_is_a_value_error() -> None: + """It replaces `raise ValueError("Unsupported base model: ...")`, so handlers must still catch.""" + assert issubclass(registry.ArchitectureError, ValueError) + + +def test_validate_reports_an_undeclared_required_facet(isolated_registry: None) -> None: + """The negative probe for the boot gate. Without this, `validate()` could be a no-op forever.""" + for base in BaseModelType: + if base not in registry._NOT_ARCHITECTURES: + registry.register(base) + Facet.FACET_TYPES[_Mandatory] = None + + with pytest.raises(registry.ArchitectureError) as exc: + registry.validate() + assert "_Mandatory" in str(exc.value) + assert "invokeai/backend/architectures/defs/flux.py" in str(exc.value) + + +def test_validate_reports_an_unregistered_architecture(isolated_registry: None) -> None: + """Discovery is automatic, so the way to fail is a new enum member with no module under defs/.""" + for base in BaseModelType: + if base not in registry._NOT_ARCHITECTURES and base is not BaseModelType.Wan: + registry.register(base) + + with pytest.raises(registry.ArchitectureError) as exc: + registry.validate() + assert "'wan'" in str(exc.value) diff --git a/tests/backend/architectures/test_registry_completeness.py b/tests/backend/architectures/test_registry_completeness.py new file mode 100644 index 00000000000..df4620bc48d --- /dev/null +++ b/tests/backend/architectures/test_registry_completeness.py @@ -0,0 +1,59 @@ +"""The registry covers every architecture, and the files on disk agree with it. + +These run against the *real* registry, unlike test_registry.py. They are what makes the boot check +meaningful before any facet is required: with no required facets, `validate()` still has to catch a +`BaseModelType` member that nobody declared. +""" + +from pathlib import Path + +from invokeai.backend.architectures import facets, generative_bases, validate +from invokeai.backend.architectures.registry import _NOT_ARCHITECTURES, defs_module_path +from invokeai.backend.model_manager.taxonomy import BaseModelType + +REPO_ROOT = Path(__file__).parents[3] +DEFS_DIR = REPO_ROOT / "invokeai" / "backend" / "architectures" / "defs" + + +def test_every_architecture_is_registered() -> None: + """The one place the sentinel subtraction is written down. + + Production code never computes `set(BaseModelType) - sentinels` to decide what is generative — + being registered is what makes an architecture generative. This asserts the two agree, which is + what catches a new enum member whose `defs/` module was never written. + """ + assert generative_bases() == set(BaseModelType) - _NOT_ARCHITECTURES + + +def test_each_registered_architecture_has_the_file_its_errors_name() -> None: + """`defs_module_path` is quoted in every error message; a wrong path is worse than no path.""" + missing = [ + defs_module_path(base) for base in generative_bases() if not (REPO_ROOT / defs_module_path(base)).exists() + ] + assert missing == [] + + +def test_no_defs_module_is_left_over() -> None: + """The other direction: a file for a base that no longer exists would never be noticed. + + Discovery imports whatever is in the directory, and a stale module's `register()` call would + either raise on an unknown enum member or, worse, keep registering a base that was renamed. + """ + on_disk = {p.stem for p in DEFS_DIR.glob("*.py") if not p.stem.startswith("_")} + derived = {base.value.replace("-", "_") for base in BaseModelType if base not in _NOT_ARCHITECTURES} + assert on_disk == derived + + +def test_every_facet_module_was_imported() -> None: + """`validate()` only checks requirements it knows about, and it learns them at import time.""" + facets_dir = REPO_ROOT / "invokeai" / "backend" / "architectures" / "facets" + on_disk = { + f"invokeai.backend.architectures.facets.{p.stem}" + for p in facets_dir.rglob("*.py") + if not any(part.startswith("_") for part in p.relative_to(facets_dir).parts) + } + assert on_disk == set(facets._MODULES) + + +def test_validate_passes() -> None: + validate() From 2af9d7d5a1781206519edc68a6d2139dbcf47fba Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 06:11:24 +0200 Subject: [PATCH 07/26] feat(app): validate the architecture registry at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An incompletely declared architecture must not be able to start the app. That is the point of the registry, and this is where it becomes true. Two call sites. `run_app` covers the normal path, next to the existing check that invocation outputs are registered — but this one raises where that one warns: the neighbouring check inspects third-party node packs, while architectures are first-party and the set is closed, so an incomplete one is a bug in this repository rather than in someone else's. `dependencies` covers every embedder that never goes through `run_app` — the test suite, and `scripts/generate_openapi_schema.py`. Calling `validate()` twice is harmless; it is idempotent and has no side effects. The import in `dependencies` sits at module scope, not lazily inside `initialize()`, and that is a constraint rather than a style choice: `initialize()` constructs `ObjectSerializerDisk`, which mutates process-global torch state through `add_safe_globals`. Anything the registry is meant to contribute there has to be registered before that point. It buys nothing yet, but establishing it now means the facet that depends on it does not have to also discover it. That constraint cannot be checked in-process — by the time any test runs, half the codebase has been imported and the registry would be full wherever the import sat — so the test runs a fresh interpreter. Its first assertion is on `sys.modules`, before it touches the registry at all, and the ordering is the entire test: importing anything under `invokeai.backend.architectures` fills the registry as a side effect, so reading the registry first would make the assertion true even with no import in `dependencies` whatsoever. The first version of this test did exactly that and passed against a deliberately broken `dependencies`. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/dependencies.py | 7 +++ invokeai/app/run_app.py | 6 +++ .../architectures/test_import_isolation.py | 47 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 tests/backend/architectures/test_import_isolation.py diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index aad84485ed7..c6a2f4b1581 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -64,6 +64,7 @@ from invokeai.app.services.wildcard_records.wildcard_records_sqlite import SqliteWildcardRecordsStorage from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk +from invokeai.backend.architectures import validate as validate_architectures from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( AnimaConditioningInfo, BasicConditioningInfo, @@ -115,6 +116,12 @@ def initialize( loop: asyncio.AbstractEventLoop, logger: Logger = logger, ) -> None: + # Before anything else, and at module scope above rather than lazily inside a function: the + # registry has to be filled before `ObjectSerializerDisk` is constructed below, because that + # mutates process-global torch state (`add_safe_globals`). Also covers every embedder that + # never goes through `run_app` — tests, and scripts/generate_openapi_schema.py. + validate_architectures() + logger.info(f"InvokeAI version {__version__}") logger.info(f"Root directory = {str(config.root_path)}") diff --git a/invokeai/app/run_app.py b/invokeai/app/run_app.py index 56cfa632467..317df472737 100644 --- a/invokeai/app/run_app.py +++ b/invokeai/app/run_app.py @@ -59,6 +59,7 @@ def run_app() -> None: # This import must happen after configure_torch_cuda_allocator() is called, because the module imports torch. from invokeai.app.invocations.baseinvocation import InvocationRegistry from invokeai.app.invocations.load_custom_nodes import load_custom_nodes + from invokeai.backend.architectures import validate as validate_architectures from invokeai.backend.util.devices import TorchDevice torch_device_name = TorchDevice.get_generation_devices_summary(app_config.generation_devices) @@ -102,6 +103,11 @@ def run_app() -> None: f'Invocation "{invocation_type}" has unregistered output class "{output_annotation.__name__}"' ) + # Every registered architecture must declare every required facet. Unlike the invocation-output + # check above, this raises rather than warns: that one inspects third-party node packs, while + # architectures are first-party and the set is closed, so an incomplete one is a bug here. + validate_architectures() + if app_config.dev_reload: # load_custom_nodes seems to bypass jurrigged's import sniffer, so be sure to call it *after* they're already # imported. diff --git a/tests/backend/architectures/test_import_isolation.py b/tests/backend/architectures/test_import_isolation.py new file mode 100644 index 00000000000..e2db7e9d9e0 --- /dev/null +++ b/tests/backend/architectures/test_import_isolation.py @@ -0,0 +1,47 @@ +"""Importing `dependencies` alone fills the registry. + +This is the constraint the module-scope import in `invokeai/app/api/dependencies.py` exists for, and +it cannot be checked in-process: by the time any test runs, half the codebase has been imported and +the registry would be full no matter where the import sat. A fresh interpreter is the only way to +tell a module-scope import from a lazy one inside a function. + +The check matters because `ApiDependencies.initialize()` builds `ObjectSerializerDisk`, which mutates +process-global torch state via `add_safe_globals`. Anything the registry is meant to contribute there +has to be registered before that point, not on first use. + +Deliberately not marked `slow`: pytest's `addopts` carries `-m "not slow"`, so a slow marker would +mean this never runs in CI. +""" + +from tests.dangerously_run_function_in_subprocess import dangerously_run_function_in_subprocess + + +def _registry_is_full_after_importing_dependencies() -> None: + # No arguments and no closure: the whole function body is re-executed in a fresh interpreter, so + # every name it uses has to be imported inside it. + import sys + + import invokeai.app.api.dependencies # noqa: F401 + + # `sys.modules` first, and this ordering is the whole test. Importing anything under + # `invokeai.backend.architectures` runs that package's `__init__`, which fills the registry as a + # side effect — so reading the registry before this check would make the assertion true no + # matter where `dependencies` puts its import, or whether it has one at all. + assert "invokeai.backend.architectures.defs" in sys.modules, ( + "importing invokeai.app.api.dependencies did not import the architecture registry. " + "Its import must stay at module scope; a lazy one inside initialize() is too late." + ) + + from invokeai.backend.architectures.registry import _ARCHITECTURES, _NOT_ARCHITECTURES + from invokeai.backend.model_manager.taxonomy import BaseModelType + + expected = set(BaseModelType) - _NOT_ARCHITECTURES + missing = sorted(b.value for b in expected - set(_ARCHITECTURES)) + assert not missing, f"not registered after importing dependencies: {missing}" + + +def test_importing_dependencies_fills_the_registry() -> None: + _stdout, stderr, returncode = dangerously_run_function_in_subprocess(_registry_is_full_after_importing_dependencies) + # Asserted on the return code, not on empty stderr: importing this much of the codebase emits + # library deprecation warnings and an "InvokeAI" log line, none of which are failures. + assert returncode == 0, stderr From 74207b855793a83b7510e0f07728a361cc9af494 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 06:42:52 +0200 Subject: [PATCH 08/26] feat(architectures): declare latent spaces, and resolve previews through them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Which matrix projects latents to RGB, whether a bias or a smoothing kernel applies, and how much the VAE downscales are all facts about the architecture. They were spread over a fifteen-branch if/elif in `step_callback.py` ending in `raise ValueError(f"Unsupported base model: {base_model}")`, which fired on the first preview step — after the model had loaded and generation had begun. This is the first facet, and it is `REQUIRED`, so the boot check stops being structural and starts enforcing something: an architecture that declares no latent space cannot start the app, and the error names the file to add it to. Nine latent spaces serve sixteen architectures. The sharing was already there and was expressed by duplicating matrices: `QWEN_IMAGE_LATENT_RGB_FACTORS`, `ANIMA_LATENT_RGB_FACTORS` and `WAN_LATENT_RGB_FACTORS` were byte-identical, as were their three biases — all of them ComfyUI's Wan21 latent_format, which is what the merged name now says. A test fails the next time a matrix is pasted rather than shared. Two findings that fell out of doing this rather than planning it: Ideogram 4 was absent from the dispatch entirely. Its node carried a second, divergent copy of the preview logic — the FLUX.2 constants inlined and the 8x downscale hardcoded — so neither call site could have revealed the other by being read. Both now resolve through the registry. Its preview is unchanged; it was already using the right matrix by hand. MiniMax H3 had grown a second `spatial_scale = 16` special case beside Wan's, in a block whose comment still described only Wan. `spatial_compression` is a property of the space now, so there is no place left to put such a case. Verified by comparing the old chain against the new facet across all sixteen architectures plus Wan's 48-channel alternate: seventeen for seventeen, byte-identical previews and identical reported sizes. `step_callback.py` goes from 435 lines to 64. `sample_to_lowres_estimated_image` moves onto `LatentSpace.preview`, and the test that covered it moves with it — it asserted a pixel value it recomputed from the very matrix under test, which made it a tautology, and its docstring's claimed column sums (0.3677/0.4577/0.9101) had drifted from the truth (0.3887/0.8771/1.3152) without anything noticing. The replacement writes the pixel down. Co-Authored-By: Claude Opus 5 (1M context) --- .../ideogram4/ideogram4_denoise.py | 29 +- invokeai/app/util/step_callback.py | 401 +----------------- invokeai/backend/architectures/__init__.py | 8 + invokeai/backend/architectures/defs/anima.py | 7 +- .../backend/architectures/defs/cogview4.py | 6 +- .../backend/architectures/defs/ernie_image.py | 9 +- invokeai/backend/architectures/defs/flux.py | 6 +- invokeai/backend/architectures/defs/flux2.py | 6 +- .../backend/architectures/defs/ideogram_4.py | 8 +- invokeai/backend/architectures/defs/krea_2.py | 7 +- .../backend/architectures/defs/minimax_h3.py | 6 +- .../backend/architectures/defs/qwen_image.py | 7 +- invokeai/backend/architectures/defs/sd_1.py | 6 +- invokeai/backend/architectures/defs/sd_2.py | 7 +- invokeai/backend/architectures/defs/sd_3.py | 6 +- invokeai/backend/architectures/defs/sdxl.py | 6 +- .../architectures/defs/sdxl_refiner.py | 7 +- invokeai/backend/architectures/defs/wan.py | 9 +- .../backend/architectures/defs/z_image.py | 7 +- .../architectures/facets/latent_space.py | 380 +++++++++++++++++ tests/app/util/test_step_callback.py | 119 ------ .../architectures/test_latent_space.py | 116 +++++ 22 files changed, 627 insertions(+), 536 deletions(-) create mode 100644 invokeai/backend/architectures/facets/latent_space.py delete mode 100644 tests/app/util/test_step_callback.py create mode 100644 tests/backend/architectures/test_latent_space.py diff --git a/invokeai/app/invocations/ideogram4/ideogram4_denoise.py b/invokeai/app/invocations/ideogram4/ideogram4_denoise.py index f9bc9ea855d..c501b41a9f8 100644 --- a/invokeai/app/invocations/ideogram4/ideogram4_denoise.py +++ b/invokeai/app/invocations/ideogram4/ideogram4_denoise.py @@ -12,16 +12,13 @@ from invokeai.app.invocations.model import TransformerField from invokeai.app.invocations.primitives import LatentsOutput from invokeai.app.services.shared.invocation_context import InvocationContext -from invokeai.app.util.step_callback import ( - FLUX2_LATENT_RGB_BIAS, - FLUX2_LATENT_RGB_FACTORS, - sample_to_lowres_estimated_image, -) +from invokeai.backend.architectures import resolve_latent_space from invokeai.backend.ideogram4 import run_ideogram4_denoise from invokeai.backend.ideogram4.latent_norm import get_latent_norm from invokeai.backend.ideogram4.sampler_configs import PRESETS from invokeai.backend.ideogram4.sampling_utils import unpatchify_and_denormalize from invokeai.backend.ideogram4.transformer_pair import Ideogram4TransformerPair +from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import Ideogram4ConditioningInfo from invokeai.backend.util.devices import TorchDevice @@ -122,15 +119,16 @@ def invoke(self, context: InvocationContext) -> LatentsOutput: assert isinstance(info, Ideogram4ConditioningInfo) llm_features = info.prompt_embeds.to(device=device, dtype=torch.float32) - # Progress-preview setup: Ideogram uses a FLUX.2-style 32-channel VAE, so the FLUX.2 - # latent->RGB factors give a usable (approximate) low-res preview of the forming image at each - # step, without a full VAE decode. Denormalization params come from get_latent_norm (no VAE). + # Denormalization params come from get_latent_norm (no VAE). latent_shift, latent_scale = get_latent_norm() - rgb_factors = torch.tensor(FLUX2_LATENT_RGB_FACTORS, dtype=torch.float32) - rgb_bias = torch.tensor(FLUX2_LATENT_RGB_BIAS, dtype=torch.float32) def step_callback(step: int, total: int, packed_latents: torch.Tensor) -> None: + # The projection and the downscale come from what this architecture declares, which is + # the same source the shared denoise callback reads. This was a second copy of the + # FLUX.2 constants with the 8x downscale hardcoded — and Ideogram 4 was missing from + # that shared dispatch entirely, so reading either one could not have revealed the other. preview = None + preview_size = None try: # packed_latents: (1, LATENT_DIM, grid_h, grid_w) -> VAE latent (1, 32, H/8, W/8). vae_latent = unpatchify_and_denormalize( @@ -138,10 +136,11 @@ def step_callback(step: int, total: int, packed_latents: torch.Tensor) -> None: latent_shift.to(packed_latents.device), latent_scale.to(packed_latents.device), ) - preview = sample_to_lowres_estimated_image( - samples=vae_latent, - latent_rgb_factors=rgb_factors.to(vae_latent.device), - latent_rgb_bias=rgb_bias.to(vae_latent.device), + latent_space = resolve_latent_space(BaseModelType.Ideogram4, vae_latent) + preview = latent_space.preview(vae_latent) + preview_size = ( + preview.width * latent_space.spatial_compression, + preview.height * latent_space.spatial_compression, ) except Exception: # A preview must never break generation — fall back to a plain progress signal. @@ -151,7 +150,7 @@ def step_callback(step: int, total: int, packed_latents: torch.Tensor) -> None: "Running Ideogram 4 denoising", step / total, preview, - (preview.width * 8, preview.height * 8), + preview_size, ) else: context.util.signal_progress("Running Ideogram 4 denoising", step / total) diff --git a/invokeai/app/util/step_callback.py b/invokeai/app/util/step_callback.py index f70dfda9cb0..4f6b7d22c40 100644 --- a/invokeai/app/util/step_callback.py +++ b/invokeai/app/util/step_callback.py @@ -1,321 +1,13 @@ from math import floor -from typing import Callable, Optional, TypeAlias +from typing import Callable, TypeAlias -import torch from PIL import Image from invokeai.app.services.session_processor.session_processor_common import CanceledException +from invokeai.backend.architectures import resolve_latent_space from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState -# See scripts/generate_vae_linear_approximation.py for generating these factors. - -# fast latents preview matrix for sdxl -# generated by @StAlKeR7779 -SDXL_LATENT_RGB_FACTORS = [ - # R G B - [0.3816, 0.4930, 0.5320], - [-0.3753, 0.1631, 0.1739], - [0.1770, 0.3588, -0.2048], - [-0.4350, -0.2644, -0.4289], -] -SDXL_SMOOTH_MATRIX = [ - [0.0358, 0.0964, 0.0358], - [0.0964, 0.4711, 0.0964], - [0.0358, 0.0964, 0.0358], -] - -# origingally adapted from code by @erucipe and @keturn here: -# https://discuss.huggingface.co/t/decoding-latents-to-rgb-without-upscaling/23204/7 -# these updated numbers for v1.5 are from @torridgristle -SD1_5_LATENT_RGB_FACTORS = [ - # R G B - [0.3444, 0.1385, 0.0670], # L1 - [0.1247, 0.4027, 0.1494], # L2 - [-0.3192, 0.2513, 0.2103], # L3 - [-0.1307, -0.1874, -0.7445], # L4 -] - -SD3_5_LATENT_RGB_FACTORS = [ - [-0.05240681, 0.03251581, 0.0749016], - [-0.0580572, 0.00759826, 0.05729818], - [0.16144888, 0.01270368, -0.03768577], - [0.14418615, 0.08460266, 0.15941818], - [0.04894035, 0.0056485, -0.06686988], - [0.05187166, 0.19222395, 0.06261094], - [0.1539433, 0.04818359, 0.07103094], - [-0.08601796, 0.09013458, 0.10893912], - [-0.12398469, -0.06766567, 0.0033688], - [-0.0439737, 0.07825329, 0.02258823], - [0.03101129, 0.06382551, 0.07753657], - [-0.01315361, 0.08554491, -0.08772475], - [0.06464487, 0.05914605, 0.13262741], - [-0.07863674, -0.02261737, -0.12761454], - [-0.09923835, -0.08010759, -0.06264447], - [-0.03392309, -0.0804029, -0.06078822], -] - -FLUX_LATENT_RGB_FACTORS = [ - [-0.0412, 0.0149, 0.0521], - [0.0056, 0.0291, 0.0768], - [0.0342, -0.0681, -0.0427], - [-0.0258, 0.0092, 0.0463], - [0.0863, 0.0784, 0.0547], - [-0.0017, 0.0402, 0.0158], - [0.0501, 0.1058, 0.1152], - [-0.0209, -0.0218, -0.0329], - [-0.0314, 0.0083, 0.0896], - [0.0851, 0.0665, -0.0472], - [-0.0534, 0.0238, -0.0024], - [0.0452, -0.0026, 0.0048], - [0.0892, 0.0831, 0.0881], - [-0.1117, -0.0304, -0.0789], - [0.0027, -0.0479, -0.0043], - [-0.1146, -0.0827, -0.0598], -] - -COGVIEW4_LATENT_RGB_FACTORS = [ - [0.00408832, -0.00082485, -0.00214816], - [0.00084172, 0.00132241, 0.00842067], - [-0.00466737, -0.00983181, -0.00699561], - [0.03698397, -0.04797235, 0.03585809], - [0.00234701, -0.00124326, 0.00080869], - [-0.00723903, -0.00388422, -0.00656606], - [-0.00970917, -0.00467356, -0.00971113], - [0.17292486, -0.03452463, -0.1457515], - [0.02330308, 0.02942557, 0.02704329], - [-0.00903131, -0.01499841, -0.01432564], - [0.01250298, 0.0019407, -0.02168986], - [0.01371188, 0.00498283, -0.01302135], - [0.42396525, 0.4280575, 0.42148206], - [0.00983825, 0.00613302, 0.00610316], - [0.00473307, -0.00889551, -0.00915924], - [-0.00955853, -0.00980067, -0.00977842], -] - -# Qwen Image uses the same VAE as Wan 2.1 (16-channel). -# Factors from ComfyUI: https://github.com/comfyanonymous/ComfyUI/blob/master/comfy/latent_formats.py -QWEN_IMAGE_LATENT_RGB_FACTORS = [ - [-0.1299, -0.1692, 0.2932], - [0.0671, 0.0406, 0.0442], - [0.3568, 0.2548, 0.1747], - [0.0372, 0.2344, 0.1420], - [0.0313, 0.0189, -0.0328], - [0.0296, -0.0956, -0.0665], - [-0.3477, -0.4059, -0.2925], - [0.0166, 0.1902, 0.1975], - [-0.0412, 0.0267, -0.1364], - [-0.1293, 0.0740, 0.1636], - [0.0680, 0.3019, 0.1128], - [0.0032, 0.0581, 0.0639], - [-0.1251, 0.0927, 0.1699], - [0.0060, -0.0633, 0.0005], - [0.3477, 0.2275, 0.2950], - [0.1984, 0.0913, 0.1861], -] - -QWEN_IMAGE_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360] - -# FLUX.2 uses 32 latent channels. -# Factors from ComfyUI: https://github.com/Comfy-Org/ComfyUI/blob/main/comfy/latent_formats.py -FLUX2_LATENT_RGB_FACTORS = [ - # R G B - [0.0058, 0.0113, 0.0073], - [0.0495, 0.0443, 0.0836], - [-0.0099, 0.0096, 0.0644], - [0.2144, 0.3009, 0.3652], - [0.0166, -0.0039, -0.0054], - [0.0157, 0.0103, -0.0160], - [-0.0398, 0.0902, -0.0235], - [-0.0052, 0.0095, 0.0109], - [-0.3527, -0.2712, -0.1666], - [-0.0301, -0.0356, -0.0180], - [-0.0107, 0.0078, 0.0013], - [0.0746, 0.0090, -0.0941], - [0.0156, 0.0169, 0.0070], - [-0.0034, -0.0040, -0.0114], - [0.0032, 0.0181, 0.0080], - [-0.0939, -0.0008, 0.0186], - [0.0018, 0.0043, 0.0104], - [0.0284, 0.0056, -0.0127], - [-0.0024, -0.0022, -0.0030], - [0.1207, -0.0026, 0.0065], - [0.0128, 0.0101, 0.0142], - [0.0137, -0.0072, -0.0007], - [0.0095, 0.0092, -0.0059], - [0.0000, -0.0077, -0.0049], - [-0.0465, -0.0204, -0.0312], - [0.0095, 0.0012, -0.0066], - [0.0290, -0.0034, 0.0025], - [0.0220, 0.0169, -0.0048], - [-0.0332, -0.0457, -0.0468], - [-0.0085, 0.0389, 0.0609], - [-0.0076, 0.0003, -0.0043], - [-0.0111, -0.0460, -0.0614], -] - -FLUX2_LATENT_RGB_BIAS = [-0.0329, -0.0718, -0.0851] - -# Anima uses Wan 2.1 VAE with 16 latent channels. -# Factors from ComfyUI: https://github.com/Comfy-Org/ComfyUI/blob/main/comfy/latent_formats.py -ANIMA_LATENT_RGB_FACTORS = [ - [-0.1299, -0.1692, 0.2932], - [0.0671, 0.0406, 0.0442], - [0.3568, 0.2548, 0.1747], - [0.0372, 0.2344, 0.1420], - [0.0313, 0.0189, -0.0328], - [0.0296, -0.0956, -0.0665], - [-0.3477, -0.4059, -0.2925], - [0.0166, 0.1902, 0.1975], - [-0.0412, 0.0267, -0.1364], - [-0.1293, 0.0740, 0.1636], - [0.0680, 0.3019, 0.1128], - [0.0032, 0.0581, 0.0639], - [-0.1251, 0.0927, 0.1699], - [0.0060, -0.0633, 0.0005], - [0.3477, 0.2275, 0.2950], - [0.1984, 0.0913, 0.1861], -] - -ANIMA_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360] - -# Wan 2.2 A14B uses the standard 16-channel Wan VAE. -# Factors come from ComfyUI's Wan21 latent_format (same VAE as A14B). -WAN_LATENT_RGB_FACTORS = [ - [-0.1299, -0.1692, 0.2932], - [0.0671, 0.0406, 0.0442], - [0.3568, 0.2548, 0.1747], - [0.0372, 0.2344, 0.1420], - [0.0313, 0.0189, -0.0328], - [0.0296, -0.0956, -0.0665], - [-0.3477, -0.4059, -0.2925], - [0.0166, 0.1902, 0.1975], - [-0.0412, 0.0267, -0.1364], - [-0.1293, 0.0740, 0.1636], - [0.0680, 0.3019, 0.1128], - [0.0032, 0.0581, 0.0639], - [-0.1251, 0.0927, 0.1699], - [0.0060, -0.0633, 0.0005], - [0.3477, 0.2275, 0.2950], - [0.1984, 0.0913, 0.1861], -] - -WAN_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360] - -# Wan 2.2 TI2V-5B uses Wan2.2-VAE with 48 latent channels and 16x spatial downscale. -# Factors come from ComfyUI's Wan22 latent_format. -WAN22_LATENT_RGB_FACTORS = [ - [0.0119, 0.0103, 0.0046], - [-0.1062, -0.0504, 0.0165], - [0.0140, 0.0409, 0.0491], - [-0.0813, -0.0677, 0.0607], - [0.0656, 0.0851, 0.0808], - [0.0264, 0.0463, 0.0912], - [0.0295, 0.0326, 0.0590], - [-0.0244, -0.0270, 0.0025], - [0.0443, -0.0102, 0.0288], - [-0.0465, -0.0090, -0.0205], - [0.0359, 0.0236, 0.0082], - [-0.0776, 0.0854, 0.1048], - [0.0564, 0.0264, 0.0561], - [0.0006, 0.0594, 0.0418], - [-0.0319, -0.0542, -0.0637], - [-0.0268, 0.0024, 0.0260], - [0.0539, 0.0265, 0.0358], - [-0.0359, -0.0312, -0.0287], - [-0.0285, -0.1032, -0.1237], - [0.1041, 0.0537, 0.0622], - [-0.0086, -0.0374, -0.0051], - [0.0390, 0.0670, 0.2863], - [0.0069, 0.0144, 0.0082], - [0.0006, -0.0167, 0.0079], - [0.0313, -0.0574, -0.0232], - [-0.1454, -0.0902, -0.0481], - [0.0714, 0.0827, 0.0447], - [-0.0304, -0.0574, -0.0196], - [0.0401, 0.0384, 0.0204], - [-0.0758, -0.0297, -0.0014], - [0.0568, 0.1307, 0.1372], - [-0.0055, -0.0310, -0.0380], - [0.0239, -0.0305, 0.0325], - [-0.0663, -0.0673, -0.0140], - [-0.0416, -0.0047, -0.0023], - [0.0166, 0.0112, -0.0093], - [-0.0211, 0.0011, 0.0331], - [0.1833, 0.1466, 0.2250], - [-0.0368, 0.0370, 0.0295], - [-0.3441, -0.3543, -0.2008], - [-0.0479, -0.0489, -0.0420], - [-0.0660, -0.0153, 0.0800], - [-0.0101, 0.0068, 0.0156], - [-0.0690, -0.0452, -0.0927], - [-0.0145, 0.0041, 0.0015], - [0.0421, 0.0451, 0.0373], - [0.0504, -0.0483, -0.0356], - [-0.0837, 0.0168, 0.0055], -] - -WAN22_LATENT_RGB_BIAS = [0.0317, -0.0878, -0.1388] - -# MiniMax H3's video VAE: 24 latent channels, 16x spatial downscale. Least-squares fit of -# NORMALIZED posterior-mean latents against 16x-downscaled RGB in [-1, 1], over real photos -# plus synthetic gradients/patches, using the released H3 video VAE encoder (fit rms ~0.09). -# This is the fallback path only — when the taeh3 preview decoder is available, the denoise -# node decodes previews with it instead. -MINIMAX_H3_LATENT_RGB_FACTORS = [ - [-0.0127, -0.0944, -0.1146], - [-0.0083, 0.0638, -0.0942], - [0.3635, 0.4082, 0.1479], - [0.2079, 0.1357, -0.5101], - [0.0178, 0.3250, -0.3183], - [0.0567, 0.2060, -0.2453], - [0.0343, -0.0136, -0.0482], - [0.0079, 0.0299, -0.0814], - [0.0220, 0.0043, 0.0158], - [0.2984, 0.0988, 0.1576], - [-0.0066, 0.0184, 0.1134], - [-0.0794, -0.0416, 0.0628], - [0.0419, -0.0184, 0.0618], - [-0.0274, 0.0420, -0.0235], - [-0.0231, -0.0312, 0.0310], - [0.0089, 0.0368, -0.0387], - [0.0126, 0.0085, -0.0299], - [-0.0187, 0.0028, 0.0194], - [0.0264, -0.0304, 0.0089], - [0.0512, 0.0168, 0.0110], - [0.0168, -0.0357, -0.0001], - [0.0063, -0.0116, -0.0509], - [-0.0237, -0.0347, 0.0324], - [-0.0099, 0.0042, -0.0358], -] - -MINIMAX_H3_LATENT_RGB_BIAS = [0.1189, 0.1415, -0.0034] - - -def sample_to_lowres_estimated_image( - samples: torch.Tensor, - latent_rgb_factors: torch.Tensor, - smooth_matrix: Optional[torch.Tensor] = None, - latent_rgb_bias: Optional[torch.Tensor] = None, -): - if samples.dim() == 4: - samples = samples[0] - latent_image = samples.permute(1, 2, 0) @ latent_rgb_factors - - if latent_rgb_bias is not None: - latent_image = latent_image + latent_rgb_bias - - if smooth_matrix is not None: - latent_image = latent_image.unsqueeze(0).permute(3, 0, 1, 2) - latent_image = torch.nn.functional.conv2d(latent_image, smooth_matrix.reshape((1, 1, 3, 3)), padding=1) - latent_image = latent_image.permute(1, 2, 3, 0).squeeze(0) - - latents_ubyte = ( - ((latent_image + 1) / 2).clamp(0, 1).mul(0xFF).byte() # change scale from -1..1 to 0..1 # to 0..255 - ).cpu() - - return Image.fromarray(latents_ubyte.numpy()) - def calc_percentage(intermediate_state: PipelineIntermediateState) -> float: """Calculate the percentage of completion of denoising.""" @@ -356,80 +48,17 @@ def diffusion_step_callback( else: sample = intermediate_state.latents - smooth_matrix: list[list[float]] | None = None - latent_rgb_bias: list[float] | None = None - if base_model in [BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2]: - latent_rgb_factors = SD1_5_LATENT_RGB_FACTORS - elif base_model in [BaseModelType.StableDiffusionXL, BaseModelType.StableDiffusionXLRefiner]: - latent_rgb_factors = SDXL_LATENT_RGB_FACTORS - smooth_matrix = SDXL_SMOOTH_MATRIX - elif base_model == BaseModelType.StableDiffusion3: - latent_rgb_factors = SD3_5_LATENT_RGB_FACTORS - elif base_model == BaseModelType.CogView4: - latent_rgb_factors = COGVIEW4_LATENT_RGB_FACTORS - elif base_model in [BaseModelType.QwenImage, BaseModelType.Krea2]: - # Krea-2 decodes with the Qwen-Image VAE (16 latent channels), so it shares the preview factors. - latent_rgb_factors = QWEN_IMAGE_LATENT_RGB_FACTORS - latent_rgb_bias = QWEN_IMAGE_LATENT_RGB_BIAS - elif base_model == BaseModelType.Flux: - latent_rgb_factors = FLUX_LATENT_RGB_FACTORS - elif base_model == BaseModelType.Flux2: - latent_rgb_factors = FLUX2_LATENT_RGB_FACTORS - latent_rgb_bias = FLUX2_LATENT_RGB_BIAS - elif base_model == BaseModelType.ZImage: - # Z-Image uses FLUX-compatible VAE with 16 latent channels - latent_rgb_factors = FLUX_LATENT_RGB_FACTORS - elif base_model == BaseModelType.Anima: - # Anima uses Wan 2.1 VAE with 16 latent channels - latent_rgb_factors = ANIMA_LATENT_RGB_FACTORS - latent_rgb_bias = ANIMA_LATENT_RGB_BIAS - elif base_model == BaseModelType.ErnieImage: - # ERNIE-Image uses AutoencoderKLFlux2 (same as FLUX.2) with 32 latent channels, and the - # denoise loop unpatches before previewing, so the shapes line up. The values do not: - # ERNIE denoises in BN-normalized latent space (denormalized only at VAE decode) and the - # BN stats live on the VAE, which isn't loaded here. Previews are therefore approximate - # in color/contrast. - latent_rgb_factors = FLUX2_LATENT_RGB_FACTORS - latent_rgb_bias = FLUX2_LATENT_RGB_BIAS - elif base_model == BaseModelType.Wan: - # A14B (16-ch standard Wan VAE, 8x spatial) vs TI2V-5B (48-ch Wan2.2-VAE, - # 16x spatial). The latent channel count uniquely identifies the variant. - if sample.shape[-3] == 48: - latent_rgb_factors = WAN22_LATENT_RGB_FACTORS - latent_rgb_bias = WAN22_LATENT_RGB_BIAS - else: - latent_rgb_factors = WAN_LATENT_RGB_FACTORS - latent_rgb_bias = WAN_LATENT_RGB_BIAS - elif base_model == BaseModelType.MiniMaxH3: - # 24-ch H3 video VAE; factors fitted against the released encoder (see constants above). - latent_rgb_factors = MINIMAX_H3_LATENT_RGB_FACTORS - latent_rgb_bias = MINIMAX_H3_LATENT_RGB_BIAS - else: - raise ValueError(f"Unsupported base model: {base_model}") - - latent_rgb_factors_torch = torch.tensor(latent_rgb_factors, dtype=sample.dtype, device=sample.device) - smooth_matrix_torch = ( - torch.tensor(smooth_matrix, dtype=sample.dtype, device=sample.device) if smooth_matrix else None - ) - latent_rgb_bias_torch = ( - torch.tensor(latent_rgb_bias, dtype=sample.dtype, device=sample.device) if latent_rgb_bias else None + # Which projection, which bias, whether to smooth, and how much the VAE downscales are all facts + # about the architecture, and they live in invokeai/backend/architectures/defs/. This used to be + # a fifteen-branch if/elif ending in `raise ValueError(f"Unsupported base model: {base_model}")`, + # which fired on the first preview step — after the model had loaded and generation had started. + # An architecture that declares no latent space now fails at boot instead. + latent_space = resolve_latent_space(base_model, sample) + image = latent_space.preview(sample) + + signal_progress( + "Denoising", + calc_percentage(intermediate_state), + image, + (image.width * latent_space.spatial_compression, image.height * latent_space.spatial_compression), ) - image = sample_to_lowres_estimated_image( - samples=sample, - latent_rgb_factors=latent_rgb_factors_torch, - smooth_matrix=smooth_matrix_torch, - latent_rgb_bias=latent_rgb_bias_torch, - ) - - # Spatial downscale ratio: 8x is the SD/SDXL/FLUX/Wan-A14B default; - # Wan TI2V-5B's Wan2.2-VAE uses 16x. - spatial_scale = 8 - if base_model == BaseModelType.Wan and sample.shape[-3] == 48: - spatial_scale = 16 - elif base_model == BaseModelType.MiniMaxH3: - spatial_scale = 16 - width = image.width * spatial_scale - height = image.height * spatial_scale - percentage = calc_percentage(intermediate_state) - - signal_progress("Denoising", percentage, image, (width, height)) diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py index 173996707d0..11548d5147a 100644 --- a/invokeai/backend/architectures/__init__.py +++ b/invokeai/backend/architectures/__init__.py @@ -7,6 +7,11 @@ from invokeai.backend.architectures import defs as defs # noqa: F401 (imported for side effects) from invokeai.backend.architectures import facets as facets # noqa: F401 (imported for side effects) from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.facets.latent_space import ( + LatentSpace, + LatentSpaceFacet, + resolve_latent_space, +) from invokeai.backend.architectures.registry import ( ArchitectureError, defs_module_path, @@ -21,6 +26,9 @@ __all__ = [ "ArchitectureError", "Facet", + "LatentSpace", + "LatentSpaceFacet", + "resolve_latent_space", "defs_module_path", "facets_of", "generative_bases", diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py index 73d4ead201a..c336232cda5 100644 --- a/invokeai/backend/architectures/defs/anima.py +++ b/invokeai/backend/architectures/defs/anima.py @@ -1,6 +1,11 @@ """What the anima architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.Anima) +# Anima uses the Wan 2.1 VAE. +register( + BaseModelType.Anima, + LatentSpaceFacet(WAN21_16), +) diff --git a/invokeai/backend/architectures/defs/cogview4.py b/invokeai/backend/architectures/defs/cogview4.py index fd4cc68d192..cb4cb7d4ccc 100644 --- a/invokeai/backend/architectures/defs/cogview4.py +++ b/invokeai/backend/architectures/defs/cogview4.py @@ -1,6 +1,10 @@ """What the cogview4 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import COGVIEW4_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.CogView4) +register( + BaseModelType.CogView4, + LatentSpaceFacet(COGVIEW4_16), +) diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py index 76a023dffa5..00e660a95cb 100644 --- a/invokeai/backend/architectures/defs/ernie_image.py +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -1,6 +1,13 @@ """What the ernie-image architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.ErnieImage) +# ERNIE-Image uses AutoencoderKLFlux2. The shapes line up because the denoise loop unpatches +# before previewing; the values are approximate, because ERNIE denoises in BN-normalized +# latent space and the BN stats live on the VAE, which is not loaded to draw a preview. +register( + BaseModelType.ErnieImage, + LatentSpaceFacet(FLUX2_32), +) diff --git a/invokeai/backend/architectures/defs/flux.py b/invokeai/backend/architectures/defs/flux.py index 048aeb0f84f..4e639647369 100644 --- a/invokeai/backend/architectures/defs/flux.py +++ b/invokeai/backend/architectures/defs/flux.py @@ -1,6 +1,10 @@ """What the flux architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.Flux) +register( + BaseModelType.Flux, + LatentSpaceFacet(FLUX_16), +) diff --git a/invokeai/backend/architectures/defs/flux2.py b/invokeai/backend/architectures/defs/flux2.py index bbc1daa2afa..341bda4c06e 100644 --- a/invokeai/backend/architectures/defs/flux2.py +++ b/invokeai/backend/architectures/defs/flux2.py @@ -1,6 +1,10 @@ """What the flux2 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.Flux2) +register( + BaseModelType.Flux2, + LatentSpaceFacet(FLUX2_32), +) diff --git a/invokeai/backend/architectures/defs/ideogram_4.py b/invokeai/backend/architectures/defs/ideogram_4.py index 8d48d9fb5b9..9f16e452166 100644 --- a/invokeai/backend/architectures/defs/ideogram_4.py +++ b/invokeai/backend/architectures/defs/ideogram_4.py @@ -1,6 +1,12 @@ """What the ideogram-4 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.Ideogram4) +# Ideogram 4 also uses a FLUX.2-style 32-channel VAE. It was the one architecture missing +# from the old preview dispatch entirely — its node carried a second copy of the logic. +register( + BaseModelType.Ideogram4, + LatentSpaceFacet(FLUX2_32), +) diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py index 7eeaca2c046..603ee260125 100644 --- a/invokeai/backend/architectures/defs/krea_2.py +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -1,6 +1,11 @@ """What the krea-2 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.Krea2) +# Krea-2 decodes with the Qwen-Image VAE, which is the Wan 2.1 VAE. +register( + BaseModelType.Krea2, + LatentSpaceFacet(WAN21_16), +) diff --git a/invokeai/backend/architectures/defs/minimax_h3.py b/invokeai/backend/architectures/defs/minimax_h3.py index 3c3cb0fd194..2c5659a15c4 100644 --- a/invokeai/backend/architectures/defs/minimax_h3.py +++ b/invokeai/backend/architectures/defs/minimax_h3.py @@ -1,6 +1,10 @@ """What the minimax-h3 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import MINIMAX_H3_24, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.MiniMaxH3) +register( + BaseModelType.MiniMaxH3, + LatentSpaceFacet(MINIMAX_H3_24), +) diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py index cf4714df776..f95dd3eca72 100644 --- a/invokeai/backend/architectures/defs/qwen_image.py +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -1,6 +1,11 @@ """What the qwen-image architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.QwenImage) +# Qwen-Image uses the Wan 2.1 VAE. +register( + BaseModelType.QwenImage, + LatentSpaceFacet(WAN21_16), +) diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index 022d0385f7b..0ee2029e000 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -1,6 +1,10 @@ """What the sd-1 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.StableDiffusion1) +register( + BaseModelType.StableDiffusion1, + LatentSpaceFacet(SD15_4), +) diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py index 8e5f1a1cb28..7dcd457e467 100644 --- a/invokeai/backend/architectures/defs/sd_2.py +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -1,6 +1,11 @@ """What the sd-2 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.StableDiffusion2) +# SD 2.x previews with the SD 1.x factors; same four-channel VAE. +register( + BaseModelType.StableDiffusion2, + LatentSpaceFacet(SD15_4), +) diff --git a/invokeai/backend/architectures/defs/sd_3.py b/invokeai/backend/architectures/defs/sd_3.py index 21046883039..87b29dcfac1 100644 --- a/invokeai/backend/architectures/defs/sd_3.py +++ b/invokeai/backend/architectures/defs/sd_3.py @@ -1,6 +1,10 @@ """What the sd-3 architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import SD3_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.StableDiffusion3) +register( + BaseModelType.StableDiffusion3, + LatentSpaceFacet(SD3_16), +) diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index f6ad6234229..5abf759ab0d 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -1,6 +1,10 @@ """What the sdxl architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.StableDiffusionXL) +register( + BaseModelType.StableDiffusionXL, + LatentSpaceFacet(SDXL_4), +) diff --git a/invokeai/backend/architectures/defs/sdxl_refiner.py b/invokeai/backend/architectures/defs/sdxl_refiner.py index 0b37e5eda4c..c0b42b0f435 100644 --- a/invokeai/backend/architectures/defs/sdxl_refiner.py +++ b/invokeai/backend/architectures/defs/sdxl_refiner.py @@ -1,6 +1,11 @@ """What the sdxl-refiner architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.StableDiffusionXLRefiner) +# The refiner shares SDXL's VAE. +register( + BaseModelType.StableDiffusionXLRefiner, + LatentSpaceFacet(SDXL_4), +) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index 5b90c87db9e..ce9bba287e5 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -1,6 +1,13 @@ """What the wan architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import WAN21_16, WAN22_48, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.Wan) +# Two variants that model identity cannot tell apart: A14B denoises in the 16-channel Wan 2.1 +# space at 8x, TI2V-5B in the 48-channel Wan2.2-VAE space at 16x. The loaded checkpoint +# decides, so the sample's channel count is what resolves it. +register( + BaseModelType.Wan, + LatentSpaceFacet(WAN21_16, alternates=(WAN22_48,)), +) diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py index 8bd63d08149..6309e76f53c 100644 --- a/invokeai/backend/architectures/defs/z_image.py +++ b/invokeai/backend/architectures/defs/z_image.py @@ -1,6 +1,11 @@ """What the z-image architecture declares.""" +from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType -register(BaseModelType.ZImage) +# Z-Image decodes with a FLUX-compatible 16-channel VAE. +register( + BaseModelType.ZImage, + LatentSpaceFacet(FLUX_16), +) diff --git a/invokeai/backend/architectures/facets/latent_space.py b/invokeai/backend/architectures/facets/latent_space.py new file mode 100644 index 00000000000..76fe3aa81ae --- /dev/null +++ b/invokeai/backend/architectures/facets/latent_space.py @@ -0,0 +1,380 @@ +"""What an architecture's latent space looks like, and how to preview a sample of it. + +Denoising previews are produced by projecting latents onto RGB with a per-VAE matrix. Which matrix, +whether a bias or a smoothing kernel applies, and how much the VAE downscales are all facts about +the *VAE*, not about the sampler — but they were spread across a fifteen-branch `if/elif` chain in +`step_callback.py` plus a second, divergent copy in the Ideogram 4 node. Adding an architecture and +forgetting the branch produced "Unsupported base model" on the first preview step, i.e. after the +model had loaded and generation had begun. + +Nine latent spaces serve sixteen architectures, which is the point: the sharing is real and was +previously expressed by duplicating matrices under different names. +""" + +from dataclasses import dataclass +from typing import ClassVar + +import torch +from PIL import Image + +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.registry import require +from invokeai.backend.model_manager.taxonomy import BaseModelType + +# origingally adapted from code by @erucipe and @keturn here: +# https://discuss.huggingface.co/t/decoding-latents-to-rgb-without-upscaling/23204/7 +# these updated numbers for v1.5 are from @torridgristle +SD1_5_LATENT_RGB_FACTORS = [ + # R G B + [0.3444, 0.1385, 0.0670], # L1 + [0.1247, 0.4027, 0.1494], # L2 + [-0.3192, 0.2513, 0.2103], # L3 + [-0.1307, -0.1874, -0.7445], # L4 +] +# fast latents preview matrix for sdxl +# generated by @StAlKeR7779 +SDXL_LATENT_RGB_FACTORS = [ + # R G B + [0.3816, 0.4930, 0.5320], + [-0.3753, 0.1631, 0.1739], + [0.1770, 0.3588, -0.2048], + [-0.4350, -0.2644, -0.4289], +] +SDXL_SMOOTH_MATRIX = [ + [0.0358, 0.0964, 0.0358], + [0.0964, 0.4711, 0.0964], + [0.0358, 0.0964, 0.0358], +] +SD3_5_LATENT_RGB_FACTORS = [ + [-0.05240681, 0.03251581, 0.0749016], + [-0.0580572, 0.00759826, 0.05729818], + [0.16144888, 0.01270368, -0.03768577], + [0.14418615, 0.08460266, 0.15941818], + [0.04894035, 0.0056485, -0.06686988], + [0.05187166, 0.19222395, 0.06261094], + [0.1539433, 0.04818359, 0.07103094], + [-0.08601796, 0.09013458, 0.10893912], + [-0.12398469, -0.06766567, 0.0033688], + [-0.0439737, 0.07825329, 0.02258823], + [0.03101129, 0.06382551, 0.07753657], + [-0.01315361, 0.08554491, -0.08772475], + [0.06464487, 0.05914605, 0.13262741], + [-0.07863674, -0.02261737, -0.12761454], + [-0.09923835, -0.08010759, -0.06264447], + [-0.03392309, -0.0804029, -0.06078822], +] +COGVIEW4_LATENT_RGB_FACTORS = [ + [0.00408832, -0.00082485, -0.00214816], + [0.00084172, 0.00132241, 0.00842067], + [-0.00466737, -0.00983181, -0.00699561], + [0.03698397, -0.04797235, 0.03585809], + [0.00234701, -0.00124326, 0.00080869], + [-0.00723903, -0.00388422, -0.00656606], + [-0.00970917, -0.00467356, -0.00971113], + [0.17292486, -0.03452463, -0.1457515], + [0.02330308, 0.02942557, 0.02704329], + [-0.00903131, -0.01499841, -0.01432564], + [0.01250298, 0.0019407, -0.02168986], + [0.01371188, 0.00498283, -0.01302135], + [0.42396525, 0.4280575, 0.42148206], + [0.00983825, 0.00613302, 0.00610316], + [0.00473307, -0.00889551, -0.00915924], + [-0.00955853, -0.00980067, -0.00977842], +] +FLUX_LATENT_RGB_FACTORS = [ + [-0.0412, 0.0149, 0.0521], + [0.0056, 0.0291, 0.0768], + [0.0342, -0.0681, -0.0427], + [-0.0258, 0.0092, 0.0463], + [0.0863, 0.0784, 0.0547], + [-0.0017, 0.0402, 0.0158], + [0.0501, 0.1058, 0.1152], + [-0.0209, -0.0218, -0.0329], + [-0.0314, 0.0083, 0.0896], + [0.0851, 0.0665, -0.0472], + [-0.0534, 0.0238, -0.0024], + [0.0452, -0.0026, 0.0048], + [0.0892, 0.0831, 0.0881], + [-0.1117, -0.0304, -0.0789], + [0.0027, -0.0479, -0.0043], + [-0.1146, -0.0827, -0.0598], +] +# The 16-channel Wan 2.1 VAE. Shared by Wan 2.2 A14B, Qwen-Image, Krea-2 and Anima — this was +# three byte-identical copies under three names before the registry gave them one home. +# Factors from ComfyUI's Wan21 latent_format: +# https://github.com/comfyanonymous/ComfyUI/blob/master/comfy/latent_formats.py +WAN21_LATENT_RGB_FACTORS = [ + [-0.1299, -0.1692, 0.2932], + [0.0671, 0.0406, 0.0442], + [0.3568, 0.2548, 0.1747], + [0.0372, 0.2344, 0.1420], + [0.0313, 0.0189, -0.0328], + [0.0296, -0.0956, -0.0665], + [-0.3477, -0.4059, -0.2925], + [0.0166, 0.1902, 0.1975], + [-0.0412, 0.0267, -0.1364], + [-0.1293, 0.0740, 0.1636], + [0.0680, 0.3019, 0.1128], + [0.0032, 0.0581, 0.0639], + [-0.1251, 0.0927, 0.1699], + [0.0060, -0.0633, 0.0005], + [0.3477, 0.2275, 0.2950], + [0.1984, 0.0913, 0.1861], +] +WAN21_LATENT_RGB_BIAS = [-0.1835, -0.0868, -0.3360] +# FLUX.2 uses 32 latent channels. +# Factors from ComfyUI: https://github.com/Comfy-Org/ComfyUI/blob/main/comfy/latent_formats.py +FLUX2_LATENT_RGB_FACTORS = [ + # R G B + [0.0058, 0.0113, 0.0073], + [0.0495, 0.0443, 0.0836], + [-0.0099, 0.0096, 0.0644], + [0.2144, 0.3009, 0.3652], + [0.0166, -0.0039, -0.0054], + [0.0157, 0.0103, -0.0160], + [-0.0398, 0.0902, -0.0235], + [-0.0052, 0.0095, 0.0109], + [-0.3527, -0.2712, -0.1666], + [-0.0301, -0.0356, -0.0180], + [-0.0107, 0.0078, 0.0013], + [0.0746, 0.0090, -0.0941], + [0.0156, 0.0169, 0.0070], + [-0.0034, -0.0040, -0.0114], + [0.0032, 0.0181, 0.0080], + [-0.0939, -0.0008, 0.0186], + [0.0018, 0.0043, 0.0104], + [0.0284, 0.0056, -0.0127], + [-0.0024, -0.0022, -0.0030], + [0.1207, -0.0026, 0.0065], + [0.0128, 0.0101, 0.0142], + [0.0137, -0.0072, -0.0007], + [0.0095, 0.0092, -0.0059], + [0.0000, -0.0077, -0.0049], + [-0.0465, -0.0204, -0.0312], + [0.0095, 0.0012, -0.0066], + [0.0290, -0.0034, 0.0025], + [0.0220, 0.0169, -0.0048], + [-0.0332, -0.0457, -0.0468], + [-0.0085, 0.0389, 0.0609], + [-0.0076, 0.0003, -0.0043], + [-0.0111, -0.0460, -0.0614], +] +FLUX2_LATENT_RGB_BIAS = [-0.0329, -0.0718, -0.0851] +# MiniMax H3's video VAE: 24 latent channels, 16x spatial downscale. Least-squares fit of +# NORMALIZED posterior-mean latents against 16x-downscaled RGB in [-1, 1], over real photos +# plus synthetic gradients/patches, using the released H3 video VAE encoder (fit rms ~0.09). +# This is the fallback path only — when the taeh3 preview decoder is available, the denoise +# node decodes previews with it instead. +MINIMAX_H3_LATENT_RGB_FACTORS = [ + [-0.0127, -0.0944, -0.1146], + [-0.0083, 0.0638, -0.0942], + [0.3635, 0.4082, 0.1479], + [0.2079, 0.1357, -0.5101], + [0.0178, 0.3250, -0.3183], + [0.0567, 0.2060, -0.2453], + [0.0343, -0.0136, -0.0482], + [0.0079, 0.0299, -0.0814], + [0.0220, 0.0043, 0.0158], + [0.2984, 0.0988, 0.1576], + [-0.0066, 0.0184, 0.1134], + [-0.0794, -0.0416, 0.0628], + [0.0419, -0.0184, 0.0618], + [-0.0274, 0.0420, -0.0235], + [-0.0231, -0.0312, 0.0310], + [0.0089, 0.0368, -0.0387], + [0.0126, 0.0085, -0.0299], + [-0.0187, 0.0028, 0.0194], + [0.0264, -0.0304, 0.0089], + [0.0512, 0.0168, 0.0110], + [0.0168, -0.0357, -0.0001], + [0.0063, -0.0116, -0.0509], + [-0.0237, -0.0347, 0.0324], + [-0.0099, 0.0042, -0.0358], +] +MINIMAX_H3_LATENT_RGB_BIAS = [0.1189, 0.1415, -0.0034] +# Wan 2.2 TI2V-5B uses Wan2.2-VAE with 48 latent channels and 16x spatial downscale. +# Factors come from ComfyUI's Wan22 latent_format. +WAN22_LATENT_RGB_FACTORS = [ + [0.0119, 0.0103, 0.0046], + [-0.1062, -0.0504, 0.0165], + [0.0140, 0.0409, 0.0491], + [-0.0813, -0.0677, 0.0607], + [0.0656, 0.0851, 0.0808], + [0.0264, 0.0463, 0.0912], + [0.0295, 0.0326, 0.0590], + [-0.0244, -0.0270, 0.0025], + [0.0443, -0.0102, 0.0288], + [-0.0465, -0.0090, -0.0205], + [0.0359, 0.0236, 0.0082], + [-0.0776, 0.0854, 0.1048], + [0.0564, 0.0264, 0.0561], + [0.0006, 0.0594, 0.0418], + [-0.0319, -0.0542, -0.0637], + [-0.0268, 0.0024, 0.0260], + [0.0539, 0.0265, 0.0358], + [-0.0359, -0.0312, -0.0287], + [-0.0285, -0.1032, -0.1237], + [0.1041, 0.0537, 0.0622], + [-0.0086, -0.0374, -0.0051], + [0.0390, 0.0670, 0.2863], + [0.0069, 0.0144, 0.0082], + [0.0006, -0.0167, 0.0079], + [0.0313, -0.0574, -0.0232], + [-0.1454, -0.0902, -0.0481], + [0.0714, 0.0827, 0.0447], + [-0.0304, -0.0574, -0.0196], + [0.0401, 0.0384, 0.0204], + [-0.0758, -0.0297, -0.0014], + [0.0568, 0.1307, 0.1372], + [-0.0055, -0.0310, -0.0380], + [0.0239, -0.0305, 0.0325], + [-0.0663, -0.0673, -0.0140], + [-0.0416, -0.0047, -0.0023], + [0.0166, 0.0112, -0.0093], + [-0.0211, 0.0011, 0.0331], + [0.1833, 0.1466, 0.2250], + [-0.0368, 0.0370, 0.0295], + [-0.3441, -0.3543, -0.2008], + [-0.0479, -0.0489, -0.0420], + [-0.0660, -0.0153, 0.0800], + [-0.0101, 0.0068, 0.0156], + [-0.0690, -0.0452, -0.0927], + [-0.0145, 0.0041, 0.0015], + [0.0421, 0.0451, 0.0373], + [0.0504, -0.0483, -0.0356], + [-0.0837, 0.0168, 0.0055], +] +WAN22_LATENT_RGB_BIAS = [0.0317, -0.0878, -0.1388] + + +@dataclass(frozen=True) +class LatentSpace: + """One VAE's latent space, and how to render a preview from it.""" + + channels: int + """Latent channels. Also what identifies the space when an architecture has more than one.""" + + spatial_compression: int + """How much smaller a latent is than the image. The preview is reported at this multiple.""" + + rgb_factors: list[list[float]] + """`channels x 3` projection onto RGB.""" + + rgb_bias: list[float] | None = None + smooth_matrix: list[list[float]] | None = None + """A 3x3 kernel convolved over the projection. Only SDXL's four-channel space uses one.""" + + def preview(self, sample: torch.Tensor) -> Image.Image: + """Project a latent sample to a low-resolution RGB image. + + The output is one `spatial_compression`-th of the generated image in each dimension; the + caller reports the full size alongside it so the UI can scale the preview. + """ + if sample.dim() == 4: + sample = sample[0] + + factors = torch.tensor(self.rgb_factors, dtype=sample.dtype, device=sample.device) + latent_image = sample.permute(1, 2, 0) @ factors + + if self.rgb_bias is not None: + latent_image = latent_image + torch.tensor(self.rgb_bias, dtype=sample.dtype, device=sample.device) + + if self.smooth_matrix is not None: + kernel = torch.tensor(self.smooth_matrix, dtype=sample.dtype, device=sample.device) + latent_image = latent_image.unsqueeze(0).permute(3, 0, 1, 2) + latent_image = torch.nn.functional.conv2d(latent_image, kernel.reshape((1, 1, 3, 3)), padding=1) + latent_image = latent_image.permute(1, 2, 3, 0).squeeze(0) + + # -1..1 -> 0..1 -> 0..255 + latents_ubyte = (((latent_image + 1) / 2).clamp(0, 1).mul(0xFF).byte()).cpu() + return Image.fromarray(latents_ubyte.numpy()) + + +SD15_4 = LatentSpace(channels=4, spatial_compression=8, rgb_factors=SD1_5_LATENT_RGB_FACTORS) +"""Stable Diffusion 1.x and 2.x.""" + +SDXL_4 = LatentSpace( + channels=4, + spatial_compression=8, + rgb_factors=SDXL_LATENT_RGB_FACTORS, + smooth_matrix=SDXL_SMOOTH_MATRIX, +) +"""SDXL and its refiner. The only space with a smoothing kernel.""" + +SD3_16 = LatentSpace(channels=16, spatial_compression=8, rgb_factors=SD3_5_LATENT_RGB_FACTORS) + +COGVIEW4_16 = LatentSpace(channels=16, spatial_compression=8, rgb_factors=COGVIEW4_LATENT_RGB_FACTORS) + +FLUX_16 = LatentSpace(channels=16, spatial_compression=8, rgb_factors=FLUX_LATENT_RGB_FACTORS) +"""FLUX.1, and Z-Image, which decodes with a FLUX-compatible VAE.""" + +WAN21_16 = LatentSpace( + channels=16, + spatial_compression=8, + rgb_factors=WAN21_LATENT_RGB_FACTORS, + rgb_bias=WAN21_LATENT_RGB_BIAS, +) +"""The Wan 2.1 VAE: Wan 2.2 A14B, Qwen-Image, Krea-2 and Anima.""" + +FLUX2_32 = LatentSpace( + channels=32, + spatial_compression=8, + rgb_factors=FLUX2_LATENT_RGB_FACTORS, + rgb_bias=FLUX2_LATENT_RGB_BIAS, +) +"""AutoencoderKLFlux2: FLUX.2, ERNIE-Image and Ideogram 4.""" + +MINIMAX_H3_24 = LatentSpace( + channels=24, + spatial_compression=16, + rgb_factors=MINIMAX_H3_LATENT_RGB_FACTORS, + rgb_bias=MINIMAX_H3_LATENT_RGB_BIAS, +) + +WAN22_48 = LatentSpace( + channels=48, + spatial_compression=16, + rgb_factors=WAN22_LATENT_RGB_FACTORS, + rgb_bias=WAN22_LATENT_RGB_BIAS, +) +"""Wan 2.2 TI2V-5B's Wan2.2-VAE. Reached through `alternates`, never registered on its own.""" + + +@dataclass(frozen=True) +class LatentSpaceFacet(Facet): + """The latent space or spaces an architecture denoises in.""" + + REQUIRED: ClassVar[bool] = True + + primary: LatentSpace + alternates: tuple[LatentSpace, ...] = () + """Spaces the same architecture may also use, told apart at runtime by latent channel count. + + Only Wan has any: A14B denoises in 16 channels at 8x, TI2V-5B in 48 at 16x, and nothing in the + model identity distinguishes them — the loaded checkpoint does. Empty for every other + architecture, which is what keeps them from depending on the shape of a tensor. + """ + + def resolve(self, sample: torch.Tensor) -> LatentSpace: + """Which space this sample is in. + + Returns `primary` without looking at `sample` when there are no alternates. That short + circuit is deliberate: an architecture with one latent space must not acquire a dependency + on tensor shape, or a change in how latents are packed becomes a preview bug. + """ + if not self.alternates: + return self.primary + channels = sample.shape[-3] + for space in self.alternates: + if space.channels == channels: + return space + return self.primary + + +def resolve_latent_space(base: BaseModelType, sample: torch.Tensor) -> LatentSpace: + """The latent space `base` is denoising `sample` in. + + Raises `ArchitectureError` naming the file to edit if the architecture declares none — at the + same moment the old `if/elif` chain raised "Unsupported base model", but saying what to do. + """ + return require(base, LatentSpaceFacet).resolve(sample) diff --git a/tests/app/util/test_step_callback.py b/tests/app/util/test_step_callback.py deleted file mode 100644 index 3235d4d4699..00000000000 --- a/tests/app/util/test_step_callback.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tests for diffusion step callback preview image generation.""" - -import torch -from PIL import Image - -from invokeai.app.util.step_callback import ( - QWEN_IMAGE_LATENT_RGB_BIAS, - QWEN_IMAGE_LATENT_RGB_FACTORS, - sample_to_lowres_estimated_image, -) - - -class TestSampleToLowresEstimatedImage: - """Test the latent-to-preview-image conversion used during denoising.""" - - def test_qwen_image_preview_produces_valid_image(self): - """A synthetic Qwen latent tensor produces a valid RGB preview image.""" - # Create a small 1x16x4x4 latent tensor (batch=1, channels=16, 4x4 spatial) - torch.manual_seed(42) - sample = torch.randn(1, 16, 4, 4) - - factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype) - bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype) - - image = sample_to_lowres_estimated_image( - samples=sample, - latent_rgb_factors=factors, - latent_rgb_bias=bias, - ) - - assert isinstance(image, Image.Image) - assert image.size == (4, 4) - assert image.mode == "RGB" - - def test_qwen_image_preview_deterministic(self): - """The same input tensor always produces the same preview image.""" - sample = torch.ones(1, 16, 2, 2) - - factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype) - bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype) - - image1 = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias) - image2 = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias) - - assert list(image1.getdata()) == list(image2.getdata()) - - def test_qwen_image_preview_known_value(self): - """Verify the preview computation against a hand-calculated expected value. - - With a 1x16x1x1 tensor of all ones: - - latent_image = [1,1,...,1] @ factors = sum of each column of factors - - R = sum(col 0) = 0.3677, G = sum(col 1) = 0.4577, B = sum(col 2) = 0.9101 - - After bias: R = 0.1842, G = 0.3709, B = 0.5741 - - After scale ((x+1)/2): R = 0.5921, G = 0.6855, B = 0.7871 - - After quantize (*255): R = 151, G = 175, B = 201 - """ - sample = torch.ones(1, 16, 1, 1) - - factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype) - bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype) - - image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias) - - assert image.size == (1, 1) - pixel = image.getpixel((0, 0)) - - # Compute expected values - col_sums = [sum(row[c] for row in QWEN_IMAGE_LATENT_RGB_FACTORS) for c in range(3)] - expected = [] - for c in range(3): - val = col_sums[c] + QWEN_IMAGE_LATENT_RGB_BIAS[c] - val = (val + 1) / 2 # scale from [-1,1] to [0,1] - val = max(0.0, min(1.0, val)) # clamp - expected.append(int(val * 255)) - - assert pixel == tuple(expected), f"Expected {tuple(expected)}, got {pixel}" - - def test_qwen_image_preview_zeros_tensor(self): - """A zero tensor with bias produces a valid image reflecting just the bias.""" - sample = torch.zeros(1, 16, 2, 2) - - factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype) - bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype) - - image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias) - - assert isinstance(image, Image.Image) - assert image.size == (2, 2) - - # All pixels should be identical (uniform zero input) - pixels = [image.getpixel((x, y)) for y in range(image.height) for x in range(image.width)] - assert all(p == pixels[0] for p in pixels) - - # With zero input, result = bias, scaled: ((bias + 1) / 2) * 255 - expected = [] - for c in range(3): - val = (QWEN_IMAGE_LATENT_RGB_BIAS[c] + 1) / 2 - val = max(0.0, min(1.0, val)) - expected.append(int(val * 255)) - assert pixels[0] == tuple(expected) - - def test_qwen_image_factors_have_correct_shape(self): - """Qwen Image uses 16 latent channels, so factors should be 16x3.""" - assert len(QWEN_IMAGE_LATENT_RGB_FACTORS) == 16 - for row in QWEN_IMAGE_LATENT_RGB_FACTORS: - assert len(row) == 3 - assert len(QWEN_IMAGE_LATENT_RGB_BIAS) == 3 - - def test_3d_input_accepted(self): - """sample_to_lowres_estimated_image accepts 3D input (no batch dim).""" - sample = torch.randn(16, 4, 4) # no batch dimension - - factors = torch.tensor(QWEN_IMAGE_LATENT_RGB_FACTORS, dtype=sample.dtype) - bias = torch.tensor(QWEN_IMAGE_LATENT_RGB_BIAS, dtype=sample.dtype) - - image = sample_to_lowres_estimated_image(samples=sample, latent_rgb_factors=factors, latent_rgb_bias=bias) - - assert isinstance(image, Image.Image) - assert image.size == (4, 4) diff --git a/tests/backend/architectures/test_latent_space.py b/tests/backend/architectures/test_latent_space.py new file mode 100644 index 00000000000..afbeac75e4a --- /dev/null +++ b/tests/backend/architectures/test_latent_space.py @@ -0,0 +1,116 @@ +"""The latent-space facet: the projection maths, and what each architecture declares.""" + +import torch + +from invokeai.backend.architectures import generative_bases, resolve_latent_space +from invokeai.backend.architectures.facets.latent_space import ( + SDXL_4, + WAN21_16, + WAN22_48, + LatentSpace, + LatentSpaceFacet, +) +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.taxonomy import BaseModelType + + +class TestProjection: + def test_a_known_pixel(self) -> None: + """One reference value, written down rather than recomputed. + + The previous version of this test derived its expectation by summing the columns of the very + matrix under test, which made it a tautology: any change to the matrix changed both sides + and the test stayed green. Its docstring claimed 0.3677/0.4577/0.9101 for these column sums; + the real values are 0.3887/0.8771/1.3152, and nothing noticed for as long as it existed. + + A 1x16x1x1 tensor of ones projects to the column sums of WAN21, plus the bias, mapped from + -1..1 to 0..255. + """ + assert WAN21_16.preview(torch.ones(1, 16, 1, 1)).getpixel((0, 0)) == (153, 228, 252) + + def test_a_zero_sample_shows_the_bias(self) -> None: + assert WAN21_16.preview(torch.zeros(1, 16, 1, 1)).getpixel((0, 0)) == (104, 116, 84) + + def test_the_preview_is_one_pixel_per_latent(self) -> None: + assert WAN21_16.preview(torch.zeros(1, 16, 5, 7)).size == (7, 5) + + def test_a_sample_without_a_batch_dimension_is_accepted(self) -> None: + assert WAN21_16.preview(torch.randn(16, 4, 4)).size == (4, 4) + + def test_a_uniform_sample_gives_a_uniform_preview(self) -> None: + image = WAN21_16.preview(torch.zeros(1, 16, 3, 3)) + pixels = [image.getpixel((x, y)) for y in range(3) for x in range(3)] + assert all(p == pixels[0] for p in pixels) + + def test_the_smoothing_kernel_changes_the_result(self) -> None: + """SDXL is the only space with one; without this, dropping it would go unnoticed.""" + unsmoothed = LatentSpace(channels=4, spatial_compression=8, rgb_factors=SDXL_4.rgb_factors) + sample = torch.randn(1, 4, 8, 8) + torch.manual_seed(0) + assert SDXL_4.preview(sample).tobytes() != unsmoothed.preview(sample).tobytes() + + +class TestResolution: + def test_wan_picks_its_space_by_channel_count(self) -> None: + """A14B and TI2V-5B are one `BaseModelType`; only the loaded checkpoint tells them apart.""" + assert resolve_latent_space(BaseModelType.Wan, torch.zeros(1, 16, 4, 4)) is WAN21_16 + assert resolve_latent_space(BaseModelType.Wan, torch.zeros(1, 48, 4, 4)) is WAN22_48 + + def test_an_unknown_channel_count_falls_back_to_the_primary(self) -> None: + assert resolve_latent_space(BaseModelType.Wan, torch.zeros(1, 7, 4, 4)) is WAN21_16 + + def test_a_single_space_never_looks_at_the_sample(self) -> None: + """The short circuit, pinned: an architecture with one space must not depend on tensor shape. + + A zero-dimensional tensor has no `shape[-3]`, so reading it would raise here. + """ + facet = LatentSpaceFacet(WAN21_16) + assert facet.resolve(torch.empty(0)) is WAN21_16 + + +class TestWhatArchitecturesDeclare: + def test_every_architecture_declares_a_latent_space(self) -> None: + """`REQUIRED = True` makes `validate()` enforce this at boot; this says what it means.""" + undeclared = sorted(b.value for b in generative_bases() if get(b, LatentSpaceFacet) is None) + assert undeclared == [] + + def test_each_matrix_has_one_row_per_channel(self) -> None: + """A projection that disagrees with its own channel count fails only at generation time.""" + wrong = [ + (b.value, space.channels, len(space.rgb_factors)) + for b in generative_bases() + for facet in [get(b, LatentSpaceFacet)] + if facet is not None + for space in (facet.primary, *facet.alternates) + if len(space.rgb_factors) != space.channels or any(len(row) != 3 for row in space.rgb_factors) + ] + assert wrong == [] + + def test_no_two_declared_spaces_hold_the_same_matrix(self) -> None: + """Three byte-identical Wan 2.1 matrices lived under three names before the merge. + + Duplicates are how a projection gets fixed in one place and stays wrong in two others, so + this fails the next time one is pasted rather than shared. + """ + spaces: list[LatentSpace] = [] + for base in generative_bases(): + facet = get(base, LatentSpaceFacet) + assert facet is not None + for space in (facet.primary, *facet.alternates): + if space not in spaces: + spaces.append(space) + matrices = [tuple(tuple(row) for row in s.rgb_factors) for s in spaces] + assert len(set(matrices)) == len(matrices) + + def test_the_shared_spaces_are_actually_shared(self) -> None: + """Sharing is by identity, not by an equal copy — the point of naming the spaces.""" + for base in (BaseModelType.QwenImage, BaseModelType.Krea2, BaseModelType.Anima, BaseModelType.Wan): + facet = get(base, LatentSpaceFacet) + assert facet is not None and facet.primary is WAN21_16, base.value + + def test_only_wan_has_alternates(self) -> None: + """Every other architecture stays independent of what shape its latents happen to be.""" + with_alternates = sorted( + b.value for b in generative_bases() if (f := get(b, LatentSpaceFacet)) is not None and f.alternates + ) + assert with_alternates == ["wan"] From c6ba48483049c458fb184c408f2af95412418229 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 16:11:44 +0200 Subject: [PATCH 09/26] feat(architectures): declare conditioning types, and build safe_globals from them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text encoders write a `ConditioningFieldData` to disk; denoise nodes read it back under `torch.load(weights_only=True)`, so every conditioning class has to be passed to `add_safe_globals` first. That was a hand-maintained list of thirteen imports and thirteen entries in `ApiDependencies.initialize`. Forgetting one produces no error at boot and none at encode. It fails at load, inside the denoise node, after the text encoder has already run and written its output: an `UnpicklingError` naming a class the user has never heard of, halfway through a graph. This is the second failure mode the registry exists for, and the later of the two. It is also what the module-scope import in `dependencies.py` was put there for. `add_safe_globals` mutates process-global torch state at a fixed point during startup, so the registry has to be full before `initialize()` runs — a constraint asserted since the registry landed, and only now actually load-bearing. Which class belongs to which architecture was derived rather than assumed: an AST sweep over the invocation packages for what each one actually *constructs*, not merely imports for a type check. That is how FLUX.2 turns out to encode to `FLUXConditioningInfo` — there is no `Flux2ConditioningInfo` — and it is the only one of the three sharings that was not obvious from the names. Thirteen classes for sixteen architectures. The resulting list is compared against the old one as a set: identical, in both directions. `IPAdapterConditioningInfo` stays out, and now visibly so: it is built in memory and handed to the pipeline, never written through `context.conditioning.save`, so it is never unpickled. `conditioning_infos()` sorts by class name. Registry order is insertion order, which is the order `defs/` happened to be walked in — reproducible in practice but incidental, and this list is worth being able to diff. Widens the facets and defs import allowlists by one module, `conditioning_data`. That is the first widening since the layering policy landed, and it is a line in the change that needs it rather than a blanket permission granted up front. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/dependencies.py | 39 ++--------- invokeai/backend/architectures/__init__.py | 3 + invokeai/backend/architectures/defs/anima.py | 3 + .../backend/architectures/defs/cogview4.py | 3 + .../backend/architectures/defs/ernie_image.py | 3 + invokeai/backend/architectures/defs/flux.py | 3 + invokeai/backend/architectures/defs/flux2.py | 4 ++ .../backend/architectures/defs/ideogram_4.py | 3 + invokeai/backend/architectures/defs/krea_2.py | 3 + .../backend/architectures/defs/minimax_h3.py | 3 + .../backend/architectures/defs/qwen_image.py | 3 + invokeai/backend/architectures/defs/sd_1.py | 3 + invokeai/backend/architectures/defs/sd_2.py | 3 + invokeai/backend/architectures/defs/sd_3.py | 3 + invokeai/backend/architectures/defs/sdxl.py | 3 + .../architectures/defs/sdxl_refiner.py | 3 + invokeai/backend/architectures/defs/wan.py | 3 + .../backend/architectures/defs/z_image.py | 3 + .../architectures/facets/conditioning.py | 49 ++++++++++++++ .../architectures/test_conditioning.py | 64 +++++++++++++++++++ tests/backend/architectures/test_layering.py | 12 +++- 21 files changed, 182 insertions(+), 34 deletions(-) create mode 100644 invokeai/backend/architectures/facets/conditioning.py create mode 100644 tests/backend/architectures/test_conditioning.py diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index c6a2f4b1581..4dc6ff39501 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -64,23 +64,9 @@ from invokeai.app.services.wildcard_records.wildcard_records_sqlite import SqliteWildcardRecordsStorage from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk +from invokeai.backend.architectures import conditioning_infos from invokeai.backend.architectures import validate as validate_architectures -from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( - AnimaConditioningInfo, - BasicConditioningInfo, - CogView4ConditioningInfo, - ConditioningFieldData, - ErnieImageConditioningInfo, - FLUXConditioningInfo, - Ideogram4ConditioningInfo, - Krea2ConditioningInfo, - MiniMaxH3ConditioningInfo, - QwenImageConditioningInfo, - SD3ConditioningInfo, - SDXLConditioningInfo, - WanConditioningInfo, - ZImageConditioningInfo, -) +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData from invokeai.backend.util.logging import InvokeAILogger from invokeai.version.invokeai_version import __version__ @@ -171,22 +157,11 @@ def initialize( conditioning = ObjectSerializerForwardCache( ObjectSerializerDisk[ConditioningFieldData]( output_folder / "conditioning", - safe_globals=[ - ConditioningFieldData, - BasicConditioningInfo, - SDXLConditioningInfo, - FLUXConditioningInfo, - SD3ConditioningInfo, - CogView4ConditioningInfo, - ZImageConditioningInfo, - ErnieImageConditioningInfo, - Ideogram4ConditioningInfo, - QwenImageConditioningInfo, - Krea2ConditioningInfo, - AnimaConditioningInfo, - WanConditioningInfo, - MiniMaxH3ConditioningInfo, - ], + # Every architecture's conditioning class, from what each declares under + # invokeai/backend/architectures/defs/. Missing one here fails nowhere near + # here: the encoder runs, writes its output, and the denoise node then dies + # unpickling it. + safe_globals=[ConditioningFieldData, *conditioning_infos()], ephemeral=True, ), ) diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py index 11548d5147a..845c1484650 100644 --- a/invokeai/backend/architectures/__init__.py +++ b/invokeai/backend/architectures/__init__.py @@ -7,6 +7,7 @@ from invokeai.backend.architectures import defs as defs # noqa: F401 (imported for side effects) from invokeai.backend.architectures import facets as facets # noqa: F401 (imported for side effects) from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet, conditioning_infos from invokeai.backend.architectures.facets.latent_space import ( LatentSpace, LatentSpaceFacet, @@ -25,9 +26,11 @@ __all__ = [ "ArchitectureError", + "ConditioningFacet", "Facet", "LatentSpace", "LatentSpaceFacet", + "conditioning_infos", "resolve_latent_space", "defs_module_path", "facets_of", diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py index c336232cda5..c21d480d154 100644 --- a/invokeai/backend/architectures/defs/anima.py +++ b/invokeai/backend/architectures/defs/anima.py @@ -1,11 +1,14 @@ """What the anima architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import AnimaConditioningInfo # Anima uses the Wan 2.1 VAE. register( BaseModelType.Anima, LatentSpaceFacet(WAN21_16), + ConditioningFacet(AnimaConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/cogview4.py b/invokeai/backend/architectures/defs/cogview4.py index cb4cb7d4ccc..c58d745f105 100644 --- a/invokeai/backend/architectures/defs/cogview4.py +++ b/invokeai/backend/architectures/defs/cogview4.py @@ -1,10 +1,13 @@ """What the cogview4 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import COGVIEW4_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import CogView4ConditioningInfo register( BaseModelType.CogView4, LatentSpaceFacet(COGVIEW4_16), + ConditioningFacet(CogView4ConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py index 00e660a95cb..9b592d1cb19 100644 --- a/invokeai/backend/architectures/defs/ernie_image.py +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -1,8 +1,10 @@ """What the ernie-image architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ErnieImageConditioningInfo # ERNIE-Image uses AutoencoderKLFlux2. The shapes line up because the denoise loop unpatches # before previewing; the values are approximate, because ERNIE denoises in BN-normalized @@ -10,4 +12,5 @@ register( BaseModelType.ErnieImage, LatentSpaceFacet(FLUX2_32), + ConditioningFacet(ErnieImageConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/flux.py b/invokeai/backend/architectures/defs/flux.py index 4e639647369..e2ed7160b3c 100644 --- a/invokeai/backend/architectures/defs/flux.py +++ b/invokeai/backend/architectures/defs/flux.py @@ -1,10 +1,13 @@ """What the flux architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import FLUXConditioningInfo register( BaseModelType.Flux, LatentSpaceFacet(FLUX_16), + ConditioningFacet(FLUXConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/flux2.py b/invokeai/backend/architectures/defs/flux2.py index 341bda4c06e..6b82a2ea5bc 100644 --- a/invokeai/backend/architectures/defs/flux2.py +++ b/invokeai/backend/architectures/defs/flux2.py @@ -1,10 +1,14 @@ """What the flux2 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import FLUXConditioningInfo register( BaseModelType.Flux2, LatentSpaceFacet(FLUX2_32), + # FLUX.2 encodes to the same conditioning shape as FLUX.1, both [dev] and Klein. + ConditioningFacet(FLUXConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/ideogram_4.py b/invokeai/backend/architectures/defs/ideogram_4.py index 9f16e452166..e5150b27d59 100644 --- a/invokeai/backend/architectures/defs/ideogram_4.py +++ b/invokeai/backend/architectures/defs/ideogram_4.py @@ -1,12 +1,15 @@ """What the ideogram-4 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import Ideogram4ConditioningInfo # Ideogram 4 also uses a FLUX.2-style 32-channel VAE. It was the one architecture missing # from the old preview dispatch entirely — its node carried a second copy of the logic. register( BaseModelType.Ideogram4, LatentSpaceFacet(FLUX2_32), + ConditioningFacet(Ideogram4ConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py index 603ee260125..9266507dbc2 100644 --- a/invokeai/backend/architectures/defs/krea_2.py +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -1,11 +1,14 @@ """What the krea-2 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import Krea2ConditioningInfo # Krea-2 decodes with the Qwen-Image VAE, which is the Wan 2.1 VAE. register( BaseModelType.Krea2, LatentSpaceFacet(WAN21_16), + ConditioningFacet(Krea2ConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/minimax_h3.py b/invokeai/backend/architectures/defs/minimax_h3.py index 2c5659a15c4..0fa086ab1ac 100644 --- a/invokeai/backend/architectures/defs/minimax_h3.py +++ b/invokeai/backend/architectures/defs/minimax_h3.py @@ -1,10 +1,13 @@ """What the minimax-h3 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import MINIMAX_H3_24, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import MiniMaxH3ConditioningInfo register( BaseModelType.MiniMaxH3, LatentSpaceFacet(MINIMAX_H3_24), + ConditioningFacet(MiniMaxH3ConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py index f95dd3eca72..e980221c394 100644 --- a/invokeai/backend/architectures/defs/qwen_image.py +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -1,11 +1,14 @@ """What the qwen-image architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import QwenImageConditioningInfo # Qwen-Image uses the Wan 2.1 VAE. register( BaseModelType.QwenImage, LatentSpaceFacet(WAN21_16), + ConditioningFacet(QwenImageConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index 0ee2029e000..5527ec1829b 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -1,10 +1,13 @@ """What the sd-1 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import BasicConditioningInfo register( BaseModelType.StableDiffusion1, LatentSpaceFacet(SD15_4), + ConditioningFacet(BasicConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py index 7dcd457e467..dfd4ecf6990 100644 --- a/invokeai/backend/architectures/defs/sd_2.py +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -1,11 +1,14 @@ """What the sd-2 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import BasicConditioningInfo # SD 2.x previews with the SD 1.x factors; same four-channel VAE. register( BaseModelType.StableDiffusion2, LatentSpaceFacet(SD15_4), + ConditioningFacet(BasicConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/sd_3.py b/invokeai/backend/architectures/defs/sd_3.py index 87b29dcfac1..f38aadf337f 100644 --- a/invokeai/backend/architectures/defs/sd_3.py +++ b/invokeai/backend/architectures/defs/sd_3.py @@ -1,10 +1,13 @@ """What the sd-3 architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import SD3_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import SD3ConditioningInfo register( BaseModelType.StableDiffusion3, LatentSpaceFacet(SD3_16), + ConditioningFacet(SD3ConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index 5abf759ab0d..aaea8c5a0ed 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -1,10 +1,13 @@ """What the sdxl architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import SDXLConditioningInfo register( BaseModelType.StableDiffusionXL, LatentSpaceFacet(SDXL_4), + ConditioningFacet(SDXLConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/sdxl_refiner.py b/invokeai/backend/architectures/defs/sdxl_refiner.py index c0b42b0f435..2ef26f22bd7 100644 --- a/invokeai/backend/architectures/defs/sdxl_refiner.py +++ b/invokeai/backend/architectures/defs/sdxl_refiner.py @@ -1,11 +1,14 @@ """What the sdxl-refiner architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import SDXLConditioningInfo # The refiner shares SDXL's VAE. register( BaseModelType.StableDiffusionXLRefiner, LatentSpaceFacet(SDXL_4), + ConditioningFacet(SDXLConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index ce9bba287e5..c7a880ec5e0 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -1,8 +1,10 @@ """What the wan architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, WAN22_48, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import WanConditioningInfo # Two variants that model identity cannot tell apart: A14B denoises in the 16-channel Wan 2.1 # space at 8x, TI2V-5B in the 48-channel Wan2.2-VAE space at 16x. The loaded checkpoint @@ -10,4 +12,5 @@ register( BaseModelType.Wan, LatentSpaceFacet(WAN21_16, alternates=(WAN22_48,)), + ConditioningFacet(WanConditioningInfo), ) diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py index 6309e76f53c..3ee0fc02304 100644 --- a/invokeai/backend/architectures/defs/z_image.py +++ b/invokeai/backend/architectures/defs/z_image.py @@ -1,11 +1,14 @@ """What the z-image architecture declares.""" +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ZImageConditioningInfo # Z-Image decodes with a FLUX-compatible 16-channel VAE. register( BaseModelType.ZImage, LatentSpaceFacet(FLUX_16), + ConditioningFacet(ZImageConditioningInfo), ) diff --git a/invokeai/backend/architectures/facets/conditioning.py b/invokeai/backend/architectures/facets/conditioning.py new file mode 100644 index 00000000000..58ed7be2479 --- /dev/null +++ b/invokeai/backend/architectures/facets/conditioning.py @@ -0,0 +1,49 @@ +"""Which conditioning type an architecture serializes. + +Text encoders write a `ConditioningFieldData` to disk and denoise nodes read it back. The reader +unpickles under `torch.load(weights_only=True)`, so every conditioning class has to be passed to +`torch.serialization.add_safe_globals` first — a process-global list built once, in +`ApiDependencies.initialize`. + +Forgetting to add a new architecture's class there produces no error at boot and no error at encode. +It fails at *load*, inside the denoise node, after the text encoder has run and its output has been +written: an `UnpicklingError` naming a class the user has never heard of, halfway through a graph. + +`add_safe_globals` mutates process-global state at a fixed point during startup, which is why the +registry has to be filled by the time `dependencies` is imported rather than on first use. That is +the constraint the module-scope import in `dependencies.py` was put there for; this is what redeems +it. +""" + +from dataclasses import dataclass +from typing import Any, ClassVar + +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.registry import generative_bases, get + + +@dataclass(frozen=True) +class ConditioningFacet(Facet): + """The `*ConditioningInfo` an architecture's text encoder produces.""" + + REQUIRED: ClassVar[bool] = True + + info: type[Any] + """The class itself, not its name. A name would have to be resolved back to a class to be + registered as a safe global, and a typo would then fail at exactly the moment this facet exists + to protect — during deserialization, mid-graph.""" + + +def conditioning_infos() -> tuple[type[Any], ...]: + """Every distinct conditioning class any architecture declares. + + Sorted by name so the resulting `safe_globals` list is stable across runs. Registry order is + insertion order, which is the order the `defs/` modules were discovered in — reproducible in + practice but incidental, and this list is worth being able to diff. + + Thirteen classes serve sixteen architectures: SD 1.x and 2.x share `BasicConditioningInfo`, + SDXL and its refiner share `SDXLConditioningInfo`, and FLUX.2 encodes to `FLUXConditioningInfo` + just as FLUX.1 does. + """ + infos = {facet.info for base in generative_bases() if (facet := get(base, ConditioningFacet)) is not None} + return tuple(sorted(infos, key=lambda cls: cls.__name__)) diff --git a/tests/backend/architectures/test_conditioning.py b/tests/backend/architectures/test_conditioning.py new file mode 100644 index 00000000000..57f3d9df669 --- /dev/null +++ b/tests/backend/architectures/test_conditioning.py @@ -0,0 +1,64 @@ +"""The conditioning facet, and the `safe_globals` list built from it.""" + +from invokeai.backend.architectures import conditioning_infos, generative_bases +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( + BasicConditioningInfo, + FLUXConditioningInfo, + IPAdapterConditioningInfo, + SDXLConditioningInfo, +) + + +def test_every_architecture_declares_a_conditioning_type() -> None: + undeclared = sorted(b.value for b in generative_bases() if get(b, ConditioningFacet) is None) + assert undeclared == [] + + +def test_the_shared_types_are_shared_by_identity() -> None: + """Three pairs share a class. Asserted by identity, so an equal-looking copy would fail.""" + for bases, info in ( + ((BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2), BasicConditioningInfo), + ((BaseModelType.StableDiffusionXL, BaseModelType.StableDiffusionXLRefiner), SDXLConditioningInfo), + ((BaseModelType.Flux, BaseModelType.Flux2), FLUXConditioningInfo), + ): + for base in bases: + facet = get(base, ConditioningFacet) + assert facet is not None and facet.info is info, base.value + + +def test_thirteen_types_serve_sixteen_architectures() -> None: + """Pins the sharing itself. A fourteenth type means a new architecture stopped sharing.""" + assert len(conditioning_infos()) == 13 + assert len(generative_bases()) == 16 + + +def test_the_list_is_deterministic() -> None: + """`safe_globals` should be diffable, and registry order is only incidentally stable.""" + names = [cls.__name__ for cls in conditioning_infos()] + assert names == sorted(names) + + +def test_the_declared_classes_are_the_ones_that_get_serialized() -> None: + """Each facet holds a class, not a name — so this can check the object, not a string. + + `ConditioningFieldData` itself is added separately by the caller, and + `IPAdapterConditioningInfo` is deliberately absent: it is built in memory and handed to the + pipeline, never written through `context.conditioning.save`, so it is not unpickled and does not + need to be a safe global. + """ + infos = set(conditioning_infos()) + assert IPAdapterConditioningInfo not in infos + assert all(isinstance(cls, type) for cls in infos) + + +def test_it_matches_what_dependencies_installs() -> None: + """The list the app actually builds, assembled the same way `dependencies` assembles it.""" + from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData + + safe_globals = [ConditioningFieldData, *conditioning_infos()] + assert len(safe_globals) == 14 + assert safe_globals[0] is ConditioningFieldData + assert len(set(safe_globals)) == len(safe_globals), "a class appears twice" diff --git a/tests/backend/architectures/test_layering.py b/tests/backend/architectures/test_layering.py index 41fd2debe55..f6113354979 100644 --- a/tests/backend/architectures/test_layering.py +++ b/tests/backend/architectures/test_layering.py @@ -23,6 +23,10 @@ ARCH_DIR = "invokeai/backend/architectures" TAXONOMY = "invokeai.backend.model_manager.taxonomy" DISCOVERY = "invokeai.backend.util.module_discovery" +# The conditioning facet names the `*ConditioningInfo` classes themselves, and so do the defs +# that declare them. Added deliberately rather than pre-emptively: each widening of these lists +# is a reviewable line in the change that needs it. +CONDITIONING = "invokeai.backend.stable_diffusion.diffusion.conditioning_data" # Vendored third-party trees, mirroring [tool.ruff] exclude, plus the frontend. EXCLUDED = ( @@ -48,11 +52,15 @@ def _allowed_for(path: str) -> tuple[str, frozenset[str], tuple[str, ...]] | Non if path == f"{ARCH_DIR}/__init__.py": return "aggregate-is-a-facade", frozenset({ARCH}), (f"{ARCH}.",) if path.startswith(f"{ARCH_DIR}/facets/"): - return "facets-allowlist", frozenset({f"{ARCH}.facet", f"{ARCH}.registry", TAXONOMY, DISCOVERY}), () + return ( + "facets-allowlist", + frozenset({f"{ARCH}.facet", f"{ARCH}.registry", TAXONOMY, DISCOVERY, CONDITIONING}), + (), + ) if path.startswith(f"{ARCH_DIR}/defs/"): return ( "defs-allowlist", - frozenset({f"{ARCH}.facet", f"{ARCH}.facets", f"{ARCH}.registry", TAXONOMY, DISCOVERY}), + frozenset({f"{ARCH}.facet", f"{ARCH}.facets", f"{ARCH}.registry", TAXONOMY, DISCOVERY, CONDITIONING}), (f"{ARCH}.facets.",), ) return None From b6f841c08e36d6ebaa2dfbfa51d0c4f46cf7cf79 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 17:55:42 +0200 Subject: [PATCH 10/26] feat(architectures): declare the generation defaults each architecture recommends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MainModelDefaultSettings.from_base` was a `match base:` block of twelve cases, four of which sub-dispatched on variant, ending in `case _: return None`. It decides what the generation sliders say when a model is selected — 30 steps for Wan TI2V-5B, CFG off for Krea-2 Turbo — and it is read once, at identification, then stored on the config. These are product decisions rather than derivable facts, so they move to the architecture that owns them and stay data: a mapping from variant to settings, with `None` as the fallback. The facet is deliberately *not* `REQUIRED`. Four architectures (SD 3.5, the SDXL refiner, CogView 4, FLUX.1) reach the old fallback and have no defaults, with a standing `TODO(psyche)` asking whether they should. So a forgotten architecture fails softly here — no crash, just sliders the user sets himself — which is milder than the other facets, and the boot check cannot enforce it. A test pins exactly which four declare nothing, so the set stays a decision rather than an accident. ERNIE-Image's Turbo detection stays name-based, as it must: Turbo and the base model share an architecture and a config, so there is nothing on disk to probe. It becomes a `by_name_hint` mapping rather than a code branch, which keeps ERNIE's two settings objects in `defs/ernie_image.py` with the rest of the product data instead of stranding them in the resolver. The existing behavioural tests — leaf directory counts, ancestor directory must not — now run through it unchanged. `MainModelDefaultSettings` moves to `configs/default_settings.py`, and this is structural rather than tidying. A facet holding instances of that class cannot import `configs/main.py` once `configs/main.py` is the module doing the looking up: main imports the registry, the registry imports the defs, the defs import the facet, and the cycle closes on a half-initialized module. Splitting the data from the behaviour breaks it without a lazy import. `configs/main.py` re-exports the name, so every existing caller is unaffected. Verified by sweeping the old `from_base` against the new resolver over every (base, variant, name, path) combination — 16 bases x 32 variants x 5 names x 4 paths: 10560 for 10560 identical. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/backend/architectures/__init__.py | 6 + invokeai/backend/architectures/defs/anima.py | 3 + .../backend/architectures/defs/ernie_image.py | 8 ++ invokeai/backend/architectures/defs/flux2.py | 17 ++- .../backend/architectures/defs/ideogram_4.py | 5 + invokeai/backend/architectures/defs/krea_2.py | 13 +- .../backend/architectures/defs/minimax_h3.py | 5 + .../backend/architectures/defs/qwen_image.py | 3 + invokeai/backend/architectures/defs/sd_1.py | 3 + invokeai/backend/architectures/defs/sd_2.py | 3 + invokeai/backend/architectures/defs/sdxl.py | 3 + invokeai/backend/architectures/defs/wan.py | 11 +- .../backend/architectures/defs/z_image.py | 12 +- .../architectures/facets/default_settings.py | 78 +++++++++++ .../model_manager/configs/default_settings.py | 40 ++++++ .../backend/model_manager/configs/factory.py | 12 +- .../backend/model_manager/configs/main.py | 122 ++---------------- .../architectures/test_default_settings.py | 91 +++++++++++++ tests/backend/architectures/test_layering.py | 18 ++- .../configs/test_krea2_main_config.py | 6 +- .../test_ernie_image_default_settings.py | 18 +-- .../test_wan_default_settings.py | 8 +- .../test_identification.py | 4 +- 23 files changed, 346 insertions(+), 143 deletions(-) create mode 100644 invokeai/backend/architectures/facets/default_settings.py create mode 100644 invokeai/backend/model_manager/configs/default_settings.py create mode 100644 tests/backend/architectures/test_default_settings.py diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py index 845c1484650..6f462142e24 100644 --- a/invokeai/backend/architectures/__init__.py +++ b/invokeai/backend/architectures/__init__.py @@ -8,6 +8,10 @@ from invokeai.backend.architectures import facets as facets # noqa: F401 (imported for side effects) from invokeai.backend.architectures.facet import Facet from invokeai.backend.architectures.facets.conditioning import ConditioningFacet, conditioning_infos +from invokeai.backend.architectures.facets.default_settings import ( + DefaultSettingsFacet, + resolve_default_settings, +) from invokeai.backend.architectures.facets.latent_space import ( LatentSpace, LatentSpaceFacet, @@ -27,10 +31,12 @@ __all__ = [ "ArchitectureError", "ConditioningFacet", + "DefaultSettingsFacet", "Facet", "LatentSpace", "LatentSpaceFacet", "conditioning_infos", + "resolve_default_settings", "resolve_latent_space", "defs_module_path", "facets_of", diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py index c21d480d154..5472374e965 100644 --- a/invokeai/backend/architectures/defs/anima.py +++ b/invokeai/backend/architectures/defs/anima.py @@ -1,8 +1,10 @@ """What the anima architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import AnimaConditioningInfo @@ -11,4 +13,5 @@ BaseModelType.Anima, LatentSpaceFacet(WAN21_16), ConditioningFacet(AnimaConditioningInfo), + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=35, cfg_scale=4.5, width=1024, height=1024)}), ) diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py index 9b592d1cb19..5349303d60c 100644 --- a/invokeai/backend/architectures/defs/ernie_image.py +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -1,8 +1,10 @@ """What the ernie-image architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ErnieImageConditioningInfo @@ -13,4 +15,10 @@ BaseModelType.ErnieImage, LatentSpaceFacet(FLUX2_32), ConditioningFacet(ErnieImageConditioningInfo), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(steps=50, cfg_scale=4.0, width=1024, height=1024)}, + # Turbo and the base model share an architecture and a config, so there is nothing on + # disk to discriminate on and no variant is modeled. The name is the only signal. + by_name_hint={"turbo": MainModelDefaultSettings(steps=8, cfg_scale=1.0, width=1024, height=1024)}, + ), ) diff --git a/invokeai/backend/architectures/defs/flux2.py b/invokeai/backend/architectures/defs/flux2.py index 6b82a2ea5bc..5cf0fd44917 100644 --- a/invokeai/backend/architectures/defs/flux2.py +++ b/invokeai/backend/architectures/defs/flux2.py @@ -1,9 +1,11 @@ """What the flux2 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register -from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import BaseModelType, Flux2VariantType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import FLUXConditioningInfo register( @@ -11,4 +13,17 @@ LatentSpaceFacet(FLUX2_32), # FLUX.2 encodes to the same conditioning shape as FLUX.1, both [dev] and Klein. ConditioningFacet(FLUXConditioningInfo), + DefaultSettingsFacet( + { + # [dev] is guidance-distilled: guidance 3.5, 28 steps, CFG off. + Flux2VariantType.Dev: MainModelDefaultSettings( + steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024 + ), + # The undistilled Klein bases need the steps but not the guidance. + Flux2VariantType.Klein4BBase: MainModelDefaultSettings(steps=28, cfg_scale=1.0, width=1024, height=1024), + Flux2VariantType.Klein9BBase: MainModelDefaultSettings(steps=28, cfg_scale=1.0, width=1024, height=1024), + # Distilled Klein 4B / 9B. + None: MainModelDefaultSettings(steps=4, cfg_scale=1.0, width=1024, height=1024), + } + ), ) diff --git a/invokeai/backend/architectures/defs/ideogram_4.py b/invokeai/backend/architectures/defs/ideogram_4.py index e5150b27d59..e238a4ee686 100644 --- a/invokeai/backend/architectures/defs/ideogram_4.py +++ b/invokeai/backend/architectures/defs/ideogram_4.py @@ -1,8 +1,10 @@ """What the ideogram-4 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import Ideogram4ConditioningInfo @@ -12,4 +14,7 @@ BaseModelType.Ideogram4, LatentSpaceFacet(FLUX2_32), ConditioningFacet(Ideogram4ConditioningInfo), + # Ideogram 4 samples from presets (V4_QUALITY_48 by default) with a dual-branch guidance + # schedule; these are sensible UI defaults rather than the sampler's own numbers. + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=48, cfg_scale=7.0, width=1024, height=1024)}), ) diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py index 9266507dbc2..3853e0c3d40 100644 --- a/invokeai/backend/architectures/defs/krea_2.py +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -1,9 +1,11 @@ """What the krea-2 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register -from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import BaseModelType, Krea2VariantType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import Krea2ConditioningInfo # Krea-2 decodes with the Qwen-Image VAE, which is the Wan 2.1 VAE. @@ -11,4 +13,13 @@ BaseModelType.Krea2, LatentSpaceFacet(WAN21_16), ConditioningFacet(Krea2ConditioningInfo), + DefaultSettingsFacet( + { + # Diffusers' Krea-2 guidance 4.5 uses cond + 4.5 * (cond - uncond), equivalent to + # InvokeAI's CFG convention at 5.5. + Krea2VariantType.Base: MainModelDefaultSettings(steps=28, cfg_scale=5.5, width=1024, height=1024), + # Turbo (distilled). cfg_scale has a floor of 1; 1.0 means no guidance. + None: MainModelDefaultSettings(steps=8, cfg_scale=1.0, width=1024, height=1024), + } + ), ) diff --git a/invokeai/backend/architectures/defs/minimax_h3.py b/invokeai/backend/architectures/defs/minimax_h3.py index 0fa086ab1ac..580ff55f81c 100644 --- a/invokeai/backend/architectures/defs/minimax_h3.py +++ b/invokeai/backend/architectures/defs/minimax_h3.py @@ -1,8 +1,10 @@ """What the minimax-h3 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import MINIMAX_H3_24, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import MiniMaxH3ConditioningInfo @@ -10,4 +12,7 @@ BaseModelType.MiniMaxH3, LatentSpaceFacet(MINIMAX_H3_24), ConditioningFacet(MiniMaxH3ConditioningInfo), + # H3 is guidance-distilled (cfg_scale 1.0 means no guidance) and was released for a fixed + # 768px short edge; 1344x768 is its native 16:9 canvas. Dimensions must be multiples of 32. + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=50, cfg_scale=1.0, width=1344, height=768)}), ) diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py index e980221c394..6e72c2ec132 100644 --- a/invokeai/backend/architectures/defs/qwen_image.py +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -1,8 +1,10 @@ """What the qwen-image architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import QwenImageConditioningInfo @@ -11,4 +13,5 @@ BaseModelType.QwenImage, LatentSpaceFacet(WAN21_16), ConditioningFacet(QwenImageConditioningInfo), + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024)}), ) diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index 5527ec1829b..978198f6a93 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -1,8 +1,10 @@ """What the sd-1 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import BasicConditioningInfo @@ -10,4 +12,5 @@ BaseModelType.StableDiffusion1, LatentSpaceFacet(SD15_4), ConditioningFacet(BasicConditioningInfo), + DefaultSettingsFacet({None: MainModelDefaultSettings(width=512, height=512)}), ) diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py index dfd4ecf6990..0b95575bea9 100644 --- a/invokeai/backend/architectures/defs/sd_2.py +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -1,8 +1,10 @@ """What the sd-2 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import BasicConditioningInfo @@ -11,4 +13,5 @@ BaseModelType.StableDiffusion2, LatentSpaceFacet(SD15_4), ConditioningFacet(BasicConditioningInfo), + DefaultSettingsFacet({None: MainModelDefaultSettings(width=768, height=768)}), ) diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index aaea8c5a0ed..31ddcf2fd2f 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -1,8 +1,10 @@ """What the sdxl architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import SDXLConditioningInfo @@ -10,4 +12,5 @@ BaseModelType.StableDiffusionXL, LatentSpaceFacet(SDXL_4), ConditioningFacet(SDXLConditioningInfo), + DefaultSettingsFacet({None: MainModelDefaultSettings(width=1024, height=1024)}), ) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index c7a880ec5e0..ef3b15d87f9 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -1,9 +1,11 @@ """What the wan architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, WAN22_48, LatentSpaceFacet from invokeai.backend.architectures.registry import register -from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import BaseModelType, WanVariantType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import WanConditioningInfo # Two variants that model identity cannot tell apart: A14B denoises in the 16-channel Wan 2.1 @@ -13,4 +15,11 @@ BaseModelType.Wan, LatentSpaceFacet(WAN21_16, alternates=(WAN22_48,)), ConditioningFacet(WanConditioningInfo), + DefaultSettingsFacet( + { + WanVariantType.TI2V_5B: MainModelDefaultSettings(steps=30, cfg_scale=5.0, width=1024, height=1024), + # A14B, and whatever an unknown variant turns out to be. + None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024), + } + ), ) diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py index 3ee0fc02304..b064de6f3b7 100644 --- a/invokeai/backend/architectures/defs/z_image.py +++ b/invokeai/backend/architectures/defs/z_image.py @@ -1,9 +1,11 @@ """What the z-image architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register -from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import BaseModelType, ZImageVariantType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ZImageConditioningInfo # Z-Image decodes with a FLUX-compatible 16-channel VAE. @@ -11,4 +13,12 @@ BaseModelType.ZImage, LatentSpaceFacet(FLUX_16), ConditioningFacet(ZImageConditioningInfo), + DefaultSettingsFacet( + { + # The undistilled base needs more steps and supports CFG. + ZImageVariantType.ZBase: MainModelDefaultSettings(steps=50, cfg_scale=4.0, width=1024, height=1024), + # Turbo (distilled): fewer steps, no CFG. + None: MainModelDefaultSettings(steps=9, cfg_scale=1.0, width=1024, height=1024), + } + ), ) diff --git a/invokeai/backend/architectures/facets/default_settings.py b/invokeai/backend/architectures/facets/default_settings.py new file mode 100644 index 00000000000..eefbfeab146 --- /dev/null +++ b/invokeai/backend/architectures/facets/default_settings.py @@ -0,0 +1,78 @@ +"""The generation parameters an architecture recommends. + +These are product decisions, not derivable facts: someone decided Wan TI2V-5B wants 30 steps and +Krea-2 Turbo wants CFG disabled. They are read once, when a model is identified, stored on its +config, and used by the UI to prefill the generation sliders. + +They were a `match base:` block of twelve cases in `configs/main.py`, four of which sub-dispatched on +variant, ending in `case _: return None`. That fallback is why this facet is *not* `REQUIRED`: four +architectures deliberately have no defaults, with a standing `TODO(psyche)` asking whether they +should. Forgetting a new architecture here therefore fails softly — no crash, just sliders the user +has to set themselves — which is milder than the other facets and worth being honest about. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import AnyVariant, BaseModelType + + +@dataclass(frozen=True) +class DefaultSettingsFacet(Facet): + """What the sliders should say when a model of this architecture is selected.""" + + by_variant: Mapping[Any, MainModelDefaultSettings] + """Keyed by variant, with `None` as the fallback for every variant not named. + + Keys are variant enum members. Those are `str`-mixin enums, so they hash by value — a lookup of + `FluxVariantType.Dev` would find a `Flux2VariantType.Dev` key. Harmless, because a mapping is + only ever consulted for the architecture that declared it, but it does mean two variants from + different enums sharing a value would shadow each other within one mapping. + """ + + by_name_hint: Mapping[str, MainModelDefaultSettings] = field(default_factory=dict) + """Settings selected by a substring of the model's name, checked before `by_variant`. + + For architectures whose sub-models are indistinguishable on disk. ERNIE-Image is the only one: + Turbo and the base model share an architecture and a config, so there is nothing to probe and no + variant is modeled — the name is the only signal there is. + """ + + def resolve( + self, + variant: AnyVariant | None = None, + name: str | None = None, + path: str | None = None, + ) -> MainModelDefaultSettings | None: + """The settings for one concrete model. + + The install directory's own name is searched as well as the model name, so that renaming a + model in the install dialog does not lose its defaults. Only the leaf directory: an in-place + install records an absolute path, and an unrelated ancestor (`/mnt/turbo-nvme/models/`) must + not hand the base model Turbo's settings. + """ + if self.by_name_hint: + haystack = " ".join(part for part in (name, Path(path).name if path else None) if part).lower() + for hint, settings in self.by_name_hint.items(): + if hint in haystack: + return settings + + if variant is not None and variant in self.by_variant: + return self.by_variant[variant] + return self.by_variant.get(None) + + +def resolve_default_settings( + base: BaseModelType, + variant: AnyVariant | None = None, + name: str | None = None, + path: str | None = None, +) -> MainModelDefaultSettings | None: + """The generation defaults for a model, or None if its architecture declares none.""" + facet = get(base, DefaultSettingsFacet) + return facet.resolve(variant, name, path) if facet is not None else None diff --git a/invokeai/backend/model_manager/configs/default_settings.py b/invokeai/backend/model_manager/configs/default_settings.py new file mode 100644 index 00000000000..f5800919d1e --- /dev/null +++ b/invokeai/backend/model_manager/configs/default_settings.py @@ -0,0 +1,40 @@ +"""`MainModelDefaultSettings` — the recommended generation parameters for a model. + +Set once, when a model is identified, and stored on its config. The UI reads them to prefill the +generation sliders: pick a Wan TI2V-5B and the steps go to 30, pick Krea-2 Turbo and CFG drops to 1. + +Split out of `configs/main.py` so it can stay a leaf. The values themselves are declared per +architecture under `invokeai/backend/architectures/defs/`, and a facet that holds instances of this +class cannot import the module that also does the *looking up* — `configs/main.py` would then import +the registry which imports the defs which import the facet, and the cycle closes on a +half-initialized module. +""" + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES + +DEFAULTS_PRECISION = Literal["fp16", "fp32"] + + +class MainModelDefaultSettings(BaseModel): + vae: str | None = Field(default=None, description="Default VAE for this model (model key)") + vae_precision: DEFAULTS_PRECISION | None = Field(default=None, description="Default VAE precision for this model") + scheduler: SCHEDULER_NAME_VALUES | None = Field(default=None, description="Default scheduler for this model") + steps: int | None = Field(default=None, gt=0, description="Default number of steps for this model") + cfg_scale: float | None = Field(default=None, ge=1, description="Default CFG Scale for this model") + cfg_rescale_multiplier: float | None = Field( + default=None, ge=0, lt=1, description="Default CFG Rescale Multiplier for this model" + ) + width: int | None = Field(default=None, multiple_of=8, ge=64, description="Default width for this model") + height: int | None = Field(default=None, multiple_of=8, ge=64, description="Default height for this model") + guidance: float | None = Field(default=None, ge=1, description="Default Guidance for this model") + cpu_only: bool | None = Field(default=None, description="Whether this model should run on CPU only") + fp8_storage: bool | None = Field( + default=None, + description="Store weights in FP8 to reduce VRAM usage (~50% savings). Weights are cast to compute dtype during inference.", + ) + + model_config = ConfigDict(extra="forbid") diff --git a/invokeai/backend/model_manager/configs/factory.py b/invokeai/backend/model_manager/configs/factory.py index c695cf41bfc..ab52e3fb558 100644 --- a/invokeai/backend/model_manager/configs/factory.py +++ b/invokeai/backend/model_manager/configs/factory.py @@ -11,6 +11,7 @@ from invokeai.app.services.config.config_default import get_config from invokeai.app.util.misc import uuid_string +from invokeai.backend.architectures import resolve_default_settings from invokeai.backend.model_hash.model_hash import HASHING_ALGORITHMS from invokeai.backend.model_manager.configs.base import Config_Base from invokeai.backend.model_manager.configs.clip_embed import CLIPEmbed_Diffusers_G_Config, CLIPEmbed_Diffusers_L_Config @@ -109,7 +110,6 @@ Main_SDNQ_Flux2_Config, Main_SDNQ_FLUX_Config, Main_SDNQ_ZImage_Config, - MainModelDefaultSettings, ) from invokeai.backend.model_manager.configs.mistral_encoder import ( MistralEncoder_Checkpoint_Config, @@ -761,12 +761,12 @@ def from_model_on_disk( # Now do any post-processing needed for specific model types/bases/etc. match config.type: case ModelType.Main: - # Pass variant if available (e.g., for Flux2 models). Name and path are used to - # detect ERNIE-Image-Turbo, which has no distinct variant on the config. + # Variant, name and path all narrow the result: four architectures have + # per-variant defaults, and ERNIE-Image-Turbo has no variant on the config at all, + # so its name is the only signal. What each architecture recommends lives in + # invokeai/backend/architectures/defs/. variant = getattr(config, "variant", None) - config.default_settings = MainModelDefaultSettings.from_base( - config.base, variant, config.name, config.path - ) + config.default_settings = resolve_default_settings(config.base, variant, config.name, config.path) case ModelType.ControlNet | ModelType.T2IAdapter | ModelType.ControlLoRa: config.default_settings = ControlAdapterDefaultSettings.from_model_name(config.name) case ModelType.LoRA: diff --git a/invokeai/backend/model_manager/configs/main.py b/invokeai/backend/model_manager/configs/main.py index 7281beee7c7..34e0bc8cb29 100644 --- a/invokeai/backend/model_manager/configs/main.py +++ b/invokeai/backend/model_manager/configs/main.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Literal, Self -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, Field from invokeai.backend.model_manager.configs.base import ( Checkpoint_Config_Base, @@ -12,6 +12,16 @@ SubmodelDefinition, ) from invokeai.backend.model_manager.configs.clip_embed import get_clip_variant_type_from_config + +# Re-exported: `MainModelDefaultSettings` moved to its own module so the architecture registry +# can hold instances of it without this module — which now looks the values *up* — becoming part +# of an import cycle. Kept importable from here, where every caller already expects it. +from invokeai.backend.model_manager.configs.default_settings import ( # noqa: E402 + DEFAULTS_PRECISION as DEFAULTS_PRECISION, +) +from invokeai.backend.model_manager.configs.default_settings import ( + MainModelDefaultSettings as MainModelDefaultSettings, +) from invokeai.backend.model_manager.configs.flux2_variant import ( flux2_variant_from_context_dim, flux2_variant_from_hidden_size, @@ -47,116 +57,6 @@ from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor from invokeai.backend.quantization.sdnq.detection import is_sdnq_folder from invokeai.backend.quantization.sdnq.sdnq_tensor import SDNQTensor -from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES - -DEFAULTS_PRECISION = Literal["fp16", "fp32"] - - -class MainModelDefaultSettings(BaseModel): - vae: str | None = Field(default=None, description="Default VAE for this model (model key)") - vae_precision: DEFAULTS_PRECISION | None = Field(default=None, description="Default VAE precision for this model") - scheduler: SCHEDULER_NAME_VALUES | None = Field(default=None, description="Default scheduler for this model") - steps: int | None = Field(default=None, gt=0, description="Default number of steps for this model") - cfg_scale: float | None = Field(default=None, ge=1, description="Default CFG Scale for this model") - cfg_rescale_multiplier: float | None = Field( - default=None, ge=0, lt=1, description="Default CFG Rescale Multiplier for this model" - ) - width: int | None = Field(default=None, multiple_of=8, ge=64, description="Default width for this model") - height: int | None = Field(default=None, multiple_of=8, ge=64, description="Default height for this model") - guidance: float | None = Field(default=None, ge=1, description="Default Guidance for this model") - cpu_only: bool | None = Field(default=None, description="Whether this model should run on CPU only") - fp8_storage: bool | None = Field( - default=None, - description="Store weights in FP8 to reduce VRAM usage (~50% savings). Weights are cast to compute dtype during inference.", - ) - - model_config = ConfigDict(extra="forbid") - - @classmethod - def from_base( - cls, - base: BaseModelType, - variant: Flux2VariantType - | FluxVariantType - | ModelVariantType - | WanVariantType - | ZImageVariantType - | Krea2VariantType - | MiniMaxH3VariantType - | None = None, - name: str | None = None, - path: str | None = None, - ) -> Self | None: - match base: - case BaseModelType.StableDiffusion1: - return cls(width=512, height=512) - case BaseModelType.StableDiffusion2: - return cls(width=768, height=768) - case BaseModelType.StableDiffusionXL: - return cls(width=1024, height=1024) - case BaseModelType.ZImage: - # Different defaults based on variant - if variant == ZImageVariantType.ZBase: - # Undistilled base model needs more steps and supports CFG - # Recommended: steps=28-50, cfg_scale=3.0-5.0 - return cls(steps=50, cfg_scale=4.0, width=1024, height=1024) - else: - # Turbo (distilled) uses fewer steps, no CFG - return cls(steps=9, cfg_scale=1.0, width=1024, height=1024) - case BaseModelType.ErnieImage: - # ERNIE-Image-Turbo (distilled) uses fewer steps and CFG=1.0. The two checkpoints - # share an architecture and config, so there is nothing on disk to discriminate on - # and no Turbo variant is modeled. Fall back to the name, and also the install - # directory's own name so that renaming the model in the install dialog doesn't lose - # the Turbo defaults. Only the leaf name is matched: an in-place install records an - # absolute path, and an unrelated ancestor directory (e.g. /mnt/turbo-nvme/models/) - # must not silently give the base model Turbo's 8 steps and CFG 1.0. - path_name = Path(path).name if path else None - haystack = " ".join(part for part in (name, path_name) if part).lower() - if "turbo" in haystack: - return cls(steps=8, cfg_scale=1.0, width=1024, height=1024) - return cls(steps=50, cfg_scale=4.0, width=1024, height=1024) - case BaseModelType.Anima: - return cls(steps=35, cfg_scale=4.5, width=1024, height=1024) - case BaseModelType.Ideogram4: - # Ideogram 4 uses sampler presets (default V4_QUALITY_48 = 48 steps) and a - # dual-branch guidance schedule; these are sensible UI defaults. - return cls(steps=48, cfg_scale=7.0, width=1024, height=1024) - case BaseModelType.Flux2: - # Different defaults based on variant - if variant == Flux2VariantType.Dev: - # FLUX.2 [dev] is guidance-distilled (recommended guidance=3.5, 28 steps, CFG disabled) - return cls(steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024) - elif variant in (Flux2VariantType.Klein4BBase, Flux2VariantType.Klein9BBase): - # Undistilled base models need more steps - return cls(steps=28, cfg_scale=1.0, width=1024, height=1024) - else: - # Distilled models (Klein 4B, Klein 9B) use fewer steps - return cls(steps=4, cfg_scale=1.0, width=1024, height=1024) - case BaseModelType.QwenImage: - return cls(steps=40, cfg_scale=4.0, width=1024, height=1024) - case BaseModelType.Krea2: - # Krea-2-Raw (Base, undistilled) needs more steps and CFG; Turbo (distilled) uses 8 - # steps with CFG disabled. cfg_scale has a floor of 1 (ge=1); 1.0 means "no guidance". - if variant == Krea2VariantType.Base: - # Diffusers' Krea-2 guidance 4.5 uses cond + 4.5 * (cond - uncond), which is - # equivalent to InvokeAI's standard CFG convention at scale 5.5. - return cls(steps=28, cfg_scale=5.5, width=1024, height=1024) - return cls(steps=8, cfg_scale=1.0, width=1024, height=1024) - case BaseModelType.Wan: - # Wan 2.2 recommended defaults differ by variant. - if variant == WanVariantType.TI2V_5B: - return cls(steps=30, cfg_scale=5.0, width=1024, height=1024) - # Default to A14B settings (also used when variant is unknown). - return cls(steps=40, cfg_scale=4.0, width=1024, height=1024) - case BaseModelType.MiniMaxH3: - # H3 is guidance-distilled (no CFG; cfg_scale 1.0 means "no guidance") and was - # released for a fixed 768px short edge; 1344x768 is its native 16:9 canvas. - # Dimensions must be multiples of 32. - return cls(steps=50, cfg_scale=1.0, width=1344, height=768) - case _: - # TODO(psyche): Do we want defaults for other base types? - return None class Main_Config_Base(ABC, BaseModel): diff --git a/tests/backend/architectures/test_default_settings.py b/tests/backend/architectures/test_default_settings.py new file mode 100644 index 00000000000..168a0fbce82 --- /dev/null +++ b/tests/backend/architectures/test_default_settings.py @@ -0,0 +1,91 @@ +"""The default-settings facet: what each architecture recommends, and what it deliberately does not. + +ERNIE-Image's name-based detection is covered by tests/backend/model_manager/test_ernie_image_default_settings.py, +which now runs through this resolver. +""" + +import pytest + +from invokeai.backend.architectures import generative_bases, resolve_default_settings +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + Flux2VariantType, + FluxVariantType, + Krea2VariantType, + ZImageVariantType, +) + +# The four that returned None from the old `case _:` fallback. A standing TODO in configs/main.py +# asks whether they should have defaults; until someone answers it, "none" is the declared answer. +WITHOUT_DEFAULTS = { + BaseModelType.StableDiffusion3, + BaseModelType.StableDiffusionXLRefiner, + BaseModelType.CogView4, + BaseModelType.Flux, +} + + +def test_the_facet_is_optional() -> None: + """Unlike latent space and conditioning, a missing declaration here is a legitimate state. + + So `validate()` cannot enforce it, and forgetting a new architecture fails softly: no crash, + just sliders the user sets themselves. + """ + assert DefaultSettingsFacet.REQUIRED is False + + +def test_exactly_the_expected_architectures_declare_nothing() -> None: + undeclared = {b for b in generative_bases() if get(b, DefaultSettingsFacet) is None} + assert undeclared == WITHOUT_DEFAULTS + + +def test_an_architecture_without_defaults_resolves_to_none() -> None: + for base in WITHOUT_DEFAULTS: + assert resolve_default_settings(base) is None, base.value + + +@pytest.mark.parametrize( + ("variant", "expected"), + [ + (ZImageVariantType.ZBase, (50, 4.0)), + (None, (9, 1.0)), + ], +) +def test_z_image_dispatches_on_variant(variant: ZImageVariantType | None, expected: tuple[int, float]) -> None: + settings = resolve_default_settings(BaseModelType.ZImage, variant) + assert settings is not None + assert (settings.steps, settings.cfg_scale) == expected + + +def test_flux2_has_three_distinct_answers() -> None: + """[dev] carries guidance, the undistilled Klein bases carry steps, distilled Klein carries neither.""" + dev = resolve_default_settings(BaseModelType.Flux2, Flux2VariantType.Dev) + klein_base = resolve_default_settings(BaseModelType.Flux2, Flux2VariantType.Klein4BBase) + klein = resolve_default_settings(BaseModelType.Flux2, None) + assert dev is not None and klein_base is not None and klein is not None + assert (dev.steps, dev.guidance) == (28, 3.5) + assert (klein_base.steps, klein_base.guidance) == (28, None) + assert (klein.steps, klein.guidance) == (4, None) + + +def test_an_unknown_variant_falls_back() -> None: + """`None` is the fallback key, and it is what an unrecognised variant lands on.""" + assert resolve_default_settings(BaseModelType.Krea2, Krea2VariantType.Turbo) == resolve_default_settings( + BaseModelType.Krea2, None + ) + + +def test_a_variant_from_another_architecture_does_not_leak_in() -> None: + """`FluxVariantType.Dev` and `Flux2VariantType.Dev` are equal and hash alike — both are "dev". + + A mapping is only ever consulted for the architecture that declared it, so this cannot happen in + practice; pinned because the equality is surprising and someone will eventually key a mapping by + a variant from the wrong enum. + """ + assert FluxVariantType.Dev == Flux2VariantType.Dev + flux2_dev = resolve_default_settings(BaseModelType.Flux2, Flux2VariantType.Dev) + assert flux2_dev is not None and flux2_dev.guidance == 3.5 + # FLUX.1 declares no defaults at all, so its own lookup is unaffected by the shared value. + assert resolve_default_settings(BaseModelType.Flux, FluxVariantType.Dev) is None diff --git a/tests/backend/architectures/test_layering.py b/tests/backend/architectures/test_layering.py index f6113354979..b9cfc95cad1 100644 --- a/tests/backend/architectures/test_layering.py +++ b/tests/backend/architectures/test_layering.py @@ -27,6 +27,10 @@ # that declare them. Added deliberately rather than pre-emptively: each widening of these lists # is a reviewable line in the change that needs it. CONDITIONING = "invokeai.backend.stable_diffusion.diffusion.conditioning_data" +# `MainModelDefaultSettings` lives in a leaf module of its own precisely so this edge is safe: +# `configs/main.py` looks the values up through the registry, so a facet importing *that* would +# close a cycle. +DEFAULT_SETTINGS = "invokeai.backend.model_manager.configs.default_settings" # Vendored third-party trees, mirroring [tool.ruff] exclude, plus the frontend. EXCLUDED = ( @@ -54,13 +58,23 @@ def _allowed_for(path: str) -> tuple[str, frozenset[str], tuple[str, ...]] | Non if path.startswith(f"{ARCH_DIR}/facets/"): return ( "facets-allowlist", - frozenset({f"{ARCH}.facet", f"{ARCH}.registry", TAXONOMY, DISCOVERY, CONDITIONING}), + frozenset({f"{ARCH}.facet", f"{ARCH}.registry", TAXONOMY, DISCOVERY, CONDITIONING, DEFAULT_SETTINGS}), (), ) if path.startswith(f"{ARCH_DIR}/defs/"): return ( "defs-allowlist", - frozenset({f"{ARCH}.facet", f"{ARCH}.facets", f"{ARCH}.registry", TAXONOMY, DISCOVERY, CONDITIONING}), + frozenset( + { + f"{ARCH}.facet", + f"{ARCH}.facets", + f"{ARCH}.registry", + TAXONOMY, + DISCOVERY, + CONDITIONING, + DEFAULT_SETTINGS, + } + ), (f"{ARCH}.facets.",), ) return None diff --git a/tests/backend/model_manager/configs/test_krea2_main_config.py b/tests/backend/model_manager/configs/test_krea2_main_config.py index f19ee8f0aaf..e8eee77b977 100644 --- a/tests/backend/model_manager/configs/test_krea2_main_config.py +++ b/tests/backend/model_manager/configs/test_krea2_main_config.py @@ -16,9 +16,9 @@ import pytest +from invokeai.backend.architectures import resolve_default_settings from invokeai.backend.model_manager.configs.identification_utils import NotAMatchError from invokeai.backend.model_manager.configs.main import ( - MainModelDefaultSettings, _get_krea2_variant_from_name, _has_krea2_keys, ) @@ -233,7 +233,7 @@ class TestKrea2DefaultSettings: """Per-variant default generation settings.""" def test_turbo_defaults(self) -> None: - ds = MainModelDefaultSettings.from_base(BaseModelType.Krea2, Krea2VariantType.Turbo) + ds = resolve_default_settings(BaseModelType.Krea2, Krea2VariantType.Turbo) assert ds is not None assert ds.steps == 8 assert ds.cfg_scale == 1.0 @@ -241,7 +241,7 @@ def test_turbo_defaults(self) -> None: assert ds.height == 1024 def test_base_defaults(self) -> None: - ds = MainModelDefaultSettings.from_base(BaseModelType.Krea2, Krea2VariantType.Base) + ds = resolve_default_settings(BaseModelType.Krea2, Krea2VariantType.Base) assert ds is not None assert ds.cfg_scale == 5.5 assert ds.steps == 28 diff --git a/tests/backend/model_manager/test_ernie_image_default_settings.py b/tests/backend/model_manager/test_ernie_image_default_settings.py index 8fcbf081978..2594e3f907f 100644 --- a/tests/backend/model_manager/test_ernie_image_default_settings.py +++ b/tests/backend/model_manager/test_ernie_image_default_settings.py @@ -1,15 +1,15 @@ -from invokeai.backend.model_manager.configs.main import MainModelDefaultSettings +from invokeai.backend.architectures import resolve_default_settings from invokeai.backend.model_manager.taxonomy import BaseModelType class TestErnieImageDefaultSettings: def test_base_defaults(self) -> None: - s = MainModelDefaultSettings.from_base(BaseModelType.ErnieImage, None, "ERNIE-Image") + s = resolve_default_settings(BaseModelType.ErnieImage, None, "ERNIE-Image") assert s is not None assert (s.steps, s.cfg_scale) == (50, 4.0) def test_turbo_detected_from_name(self) -> None: - s = MainModelDefaultSettings.from_base(BaseModelType.ErnieImage, None, "ERNIE-Image-Turbo") + s = resolve_default_settings(BaseModelType.ErnieImage, None, "ERNIE-Image-Turbo") assert s is not None assert (s.steps, s.cfg_scale) == (8, 1.0) @@ -17,14 +17,12 @@ def test_turbo_detected_from_path_when_renamed(self) -> None: # The two checkpoints are architecturally identical, so detection falls back to the name. # Renaming the model in the install dialog must not lose the Turbo defaults, hence the # install path is consulted too. - s = MainModelDefaultSettings.from_base( - BaseModelType.ErnieImage, None, "my ernie", "main/ernie-image/ERNIE-Image-Turbo" - ) + s = resolve_default_settings(BaseModelType.ErnieImage, None, "my ernie", "main/ernie-image/ERNIE-Image-Turbo") assert s is not None assert (s.steps, s.cfg_scale) == (8, 1.0) def test_no_turbo_hint_falls_back_to_base_defaults(self) -> None: - s = MainModelDefaultSettings.from_base(BaseModelType.ErnieImage, None, "my ernie", "main/ernie-image/whatever") + s = resolve_default_settings(BaseModelType.ErnieImage, None, "my ernie", "main/ernie-image/whatever") assert s is not None assert (s.steps, s.cfg_scale) == (50, 4.0) @@ -32,14 +30,12 @@ def test_turbo_in_ancestor_directory_is_ignored(self) -> None: # An in-place install records an absolute path, so an unrelated ancestor directory can # contain "turbo". Only the install directory's own name may drive detection — otherwise the # *base* model silently gets Turbo's 8 steps and CFG 1.0. - s = MainModelDefaultSettings.from_base( - BaseModelType.ErnieImage, None, "my ernie", "/mnt/turbo-nvme/models/ERNIE-Image" - ) + s = resolve_default_settings(BaseModelType.ErnieImage, None, "my ernie", "/mnt/turbo-nvme/models/ERNIE-Image") assert s is not None assert (s.steps, s.cfg_scale) == (50, 4.0) def test_turbo_in_leaf_directory_still_detected(self) -> None: - s = MainModelDefaultSettings.from_base( + s = resolve_default_settings( BaseModelType.ErnieImage, None, "my ernie", "/mnt/turbo-nvme/models/ERNIE-Image-Turbo" ) assert s is not None diff --git a/tests/backend/model_manager/test_wan_default_settings.py b/tests/backend/model_manager/test_wan_default_settings.py index ff66cf4f067..f3fe258393b 100644 --- a/tests/backend/model_manager/test_wan_default_settings.py +++ b/tests/backend/model_manager/test_wan_default_settings.py @@ -1,12 +1,12 @@ """Tests for Wan 2.2 default settings.""" -from invokeai.backend.model_manager.configs.main import MainModelDefaultSettings +from invokeai.backend.architectures import resolve_default_settings from invokeai.backend.model_manager.taxonomy import BaseModelType, WanVariantType class TestWanDefaultSettings: def test_a14b_defaults(self) -> None: - s = MainModelDefaultSettings.from_base(BaseModelType.Wan, WanVariantType.T2V_A14B) + s = resolve_default_settings(BaseModelType.Wan, WanVariantType.T2V_A14B) assert s is not None assert s.steps == 40 assert s.cfg_scale == 4.0 @@ -14,12 +14,12 @@ def test_a14b_defaults(self) -> None: assert s.height == 1024 def test_ti2v_5b_defaults(self) -> None: - s = MainModelDefaultSettings.from_base(BaseModelType.Wan, WanVariantType.TI2V_5B) + s = resolve_default_settings(BaseModelType.Wan, WanVariantType.TI2V_5B) assert s is not None assert s.steps == 30 assert s.cfg_scale == 5.0 def test_no_variant_falls_back_to_a14b_settings(self) -> None: - s = MainModelDefaultSettings.from_base(BaseModelType.Wan) + s = resolve_default_settings(BaseModelType.Wan) assert s is not None assert s.steps == 40 diff --git a/tests/model_identification/test_identification.py b/tests/model_identification/test_identification.py index dbd377f2c12..8a4d220f5c0 100644 --- a/tests/model_identification/test_identification.py +++ b/tests/model_identification/test_identification.py @@ -7,11 +7,11 @@ import pytest +from invokeai.backend.architectures import resolve_default_settings from invokeai.backend.model_manager.configs.controlnet import ControlAdapterDefaultSettings from invokeai.backend.model_manager.configs.factory import ( ModelConfigFactory, ) -from invokeai.backend.model_manager.configs.main import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import ( BaseModelType, ) @@ -45,7 +45,7 @@ def test_controlnet_t2i_default_settings(model_name: str, preprocessor: str | No ], ) def test_default_settings_main(base: BaseModelType, attrs: dict[str, Any] | None): - settings = MainModelDefaultSettings.from_base(base) + settings = resolve_default_settings(base) if attrs is None: assert settings is None else: From 38c07e76864465c52f44e51ea2036f7c55fc42b7 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 20:06:18 +0200 Subject: [PATCH 11/26] feat(architectures): give FLUX.1, CogView 4 and SD 3.5 the defaults their model cards recommend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three architectures reached the old `case _: return None` and had no generation defaults at all, so a user selecting one got whatever the sliders happened to be showing. Now that the values live next to the architecture, filling the gaps is a three-line change per architecture rather than three more cases in a match block. The numbers are cited, not invented: cogview4 50 steps, guidance 3.5, 1024x1024 THUDM/CogView4-6B's own example. True CFG — it takes a negative prompt — so it goes in cfg_scale, and the denoise node already defaults to 3.5. Nothing was propagating that to the sliders. sd-3 40 steps, guidance 4.5 stable-diffusion-3.5-medium. Medium rather than Large (28/3.5): there is one `sd-3` row and no variant to tell the two apart, and Medium is the smaller and more commonly run of them. flux per variant, see below black-forest-labs' cards for schnell, dev and Fill. FLUX.1 gets a variant-keyed mapping because its three variants genuinely disagree. `guidance` here is the distilled guidance embedding, not classifier-free guidance, so cfg_scale stays at its floor (1.0, meaning off) for all three: schnell 4 steps, no guidance timestep-distilled; the node already documents that it ignores guidance entirely dev 28 steps, guidance 3.5 the card's example says 50; 28 is the de-facto standard and what FLUX.2 [dev] already declares here, so the two agree dev_fill 50 steps, guidance 30.0 corroborated in-tree — flux_denoise.py already warns when guidance drops below 25.0 for a Fill model Only the SDXL refiner is left declaring nothing, which is right: it is not run on its own, so there is nothing for it to prefill. That resolves the standing `TODO(psyche)` for the other three. Not touched, because they need a ruling rather than a citation: SD 1.x/2.x/XL still declare size only and no steps or CFG, as they always have; and SD 2.x's 768x768 is right for the v-prediction checkpoints and wrong for the 512 base ones, with nothing distinguishing them here. Note this changes nothing in webv2 yet. webv2 reads `default_settings` only for LoRA weight and VAE key — steps, CFG and dimensions come from its own hardcoded BASE_GENERATION table, which disagrees with these values in four places (ideogram-4 at CFG 1 against the sampler's actual 7.0; z-image at 8 steps against the card's 9; anima at 30/4; sd-2 at 512). Making webv2 read the backend is what the capabilities endpoint is for. Co-Authored-By: Claude Opus 5 (1M context) --- .../backend/architectures/defs/cogview4.py | 6 ++ invokeai/backend/architectures/defs/flux.py | 19 +++++- invokeai/backend/architectures/defs/sd_3.py | 6 ++ .../architectures/test_default_settings.py | 68 +++++++++++++++---- 4 files changed, 83 insertions(+), 16 deletions(-) diff --git a/invokeai/backend/architectures/defs/cogview4.py b/invokeai/backend/architectures/defs/cogview4.py index c58d745f105..c2babde2954 100644 --- a/invokeai/backend/architectures/defs/cogview4.py +++ b/invokeai/backend/architectures/defs/cogview4.py @@ -1,8 +1,10 @@ """What the cogview4 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import COGVIEW4_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import CogView4ConditioningInfo @@ -10,4 +12,8 @@ BaseModelType.CogView4, LatentSpaceFacet(COGVIEW4_16), ConditioningFacet(CogView4ConditioningInfo), + # THUDM/CogView4-6B's own example: 50 steps at guidance 3.5, 1024x1024. This is true + # classifier-free guidance, so it belongs in cfg_scale — and the denoise node already + # defaults to 3.5, which nothing was propagating to the sliders. + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=50, cfg_scale=3.5, width=1024, height=1024)}), ) diff --git a/invokeai/backend/architectures/defs/flux.py b/invokeai/backend/architectures/defs/flux.py index e2ed7160b3c..980b53cbb36 100644 --- a/invokeai/backend/architectures/defs/flux.py +++ b/invokeai/backend/architectures/defs/flux.py @@ -1,13 +1,30 @@ """What the flux architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register -from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import BaseModelType, FluxVariantType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import FLUXConditioningInfo register( BaseModelType.Flux, LatentSpaceFacet(FLUX_16), ConditioningFacet(FLUXConditioningInfo), + # Per variant, from the model cards. FLUX's `guidance` is the distilled guidance embedding, + # not classifier-free guidance — hence cfg_scale 1.0 (the field's floor, meaning "off") on all + # three. Fill's 30.0 is corroborated in-tree: flux_denoise.py warns below 25.0. + DefaultSettingsFacet( + { + # schnell is timestep-distilled: 4 steps, and it ignores guidance entirely. + FluxVariantType.Schnell: MainModelDefaultSettings(steps=4, cfg_scale=1.0, width=1024, height=1024), + FluxVariantType.DevFill: MainModelDefaultSettings( + steps=50, cfg_scale=1.0, guidance=30.0, width=1024, height=1024 + ), + # dev. The card's example uses 50 steps; 28 is the de-facto standard and what FLUX.2 + # [dev] already declares here, so the two stay consistent. + None: MainModelDefaultSettings(steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024), + } + ), ) diff --git a/invokeai/backend/architectures/defs/sd_3.py b/invokeai/backend/architectures/defs/sd_3.py index f38aadf337f..92380194594 100644 --- a/invokeai/backend/architectures/defs/sd_3.py +++ b/invokeai/backend/architectures/defs/sd_3.py @@ -1,8 +1,10 @@ """What the sd-3 architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SD3_16, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import SD3ConditioningInfo @@ -10,4 +12,8 @@ BaseModelType.StableDiffusion3, LatentSpaceFacet(SD3_16), ConditioningFacet(SD3ConditioningInfo), + # stable-diffusion-3.5-medium's example: 40 steps at guidance 4.5. Medium rather than Large + # (28/3.5) because there is one `sd-3` row and no variant to tell them apart, and Medium is the + # smaller, more commonly run model. + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.5, width=1024, height=1024)}), ) diff --git a/tests/backend/architectures/test_default_settings.py b/tests/backend/architectures/test_default_settings.py index 168a0fbce82..aa80093cc52 100644 --- a/tests/backend/architectures/test_default_settings.py +++ b/tests/backend/architectures/test_default_settings.py @@ -17,14 +17,10 @@ ZImageVariantType, ) -# The four that returned None from the old `case _:` fallback. A standing TODO in configs/main.py -# asks whether they should have defaults; until someone answers it, "none" is the declared answer. -WITHOUT_DEFAULTS = { - BaseModelType.StableDiffusion3, - BaseModelType.StableDiffusionXLRefiner, - BaseModelType.CogView4, - BaseModelType.Flux, -} +# SD 3.5, CogView 4 and FLUX.1 reached the old `case _:` fallback and had no defaults at all; they +# now declare what their model cards recommend. The refiner is the one left: it is not run on its +# own, so there is nothing for it to prefill. +WITHOUT_DEFAULTS = {BaseModelType.StableDiffusionXLRefiner} def test_the_facet_is_optional() -> None: @@ -77,15 +73,57 @@ def test_an_unknown_variant_falls_back() -> None: ) -def test_a_variant_from_another_architecture_does_not_leak_in() -> None: +def test_a_variant_from_another_architecture_falls_back_rather_than_matching() -> None: """`FluxVariantType.Dev` and `Flux2VariantType.Dev` are equal and hash alike — both are "dev". A mapping is only ever consulted for the architecture that declared it, so this cannot happen in - practice; pinned because the equality is surprising and someone will eventually key a mapping by - a variant from the wrong enum. + practice. Pinned because the equality is surprising: a lookup keyed by the wrong enum would find + an entry rather than miss it, and nothing but the fallback would reveal the mistake. + + Both architectures happen to declare 28 steps at guidance 3.5 for their `dev`, so the shared key + is not observable there — which is exactly why the check uses a value the two do not share. """ assert FluxVariantType.Dev == Flux2VariantType.Dev - flux2_dev = resolve_default_settings(BaseModelType.Flux2, Flux2VariantType.Dev) - assert flux2_dev is not None and flux2_dev.guidance == 3.5 - # FLUX.1 declares no defaults at all, so its own lookup is unaffected by the shared value. - assert resolve_default_settings(BaseModelType.Flux, FluxVariantType.Dev) is None + assert resolve_default_settings(BaseModelType.Flux2, FluxVariantType.Schnell) == resolve_default_settings( + BaseModelType.Flux2, None + ) + + +def test_flux1_dispatches_on_variant() -> None: + """Three genuinely different answers, and `guidance` is not CFG. + + FLUX's `guidance` is the distilled guidance embedding, so cfg_scale stays at its floor (1.0, + meaning "off") for every variant. Fill's 30.0 is corroborated in-tree: `flux_denoise.py` warns + when guidance drops below 25.0 for a Fill model. + """ + schnell = resolve_default_settings(BaseModelType.Flux, FluxVariantType.Schnell) + dev = resolve_default_settings(BaseModelType.Flux, FluxVariantType.Dev) + fill = resolve_default_settings(BaseModelType.Flux, FluxVariantType.DevFill) + assert schnell is not None and dev is not None and fill is not None + + assert (schnell.steps, schnell.guidance) == (4, None), "schnell is distilled and ignores guidance" + assert (dev.steps, dev.guidance) == (28, 3.5) + assert (fill.steps, fill.guidance) == (50, 30.0) + assert {schnell.cfg_scale, dev.cfg_scale, fill.cfg_scale} == {1.0}, "FLUX never uses CFG" + + +def test_the_researched_values_are_what_the_model_cards_say() -> None: + """Pinned against their sources, so a later edit has to argue with the citation. + + cogview4: THUDM/CogView4-6B, 50 steps at guidance 3.5 (true CFG — it takes a negative prompt). + sd-3: stable-diffusion-3.5-medium, 40 steps at guidance 4.5. Medium, not Large (28/3.5): + there is one `sd-3` row and no variant to tell them apart. + z-image: Tongyi-MAI/Z-Image-Turbo, `num_inference_steps=9`, guidance 0 -> cfg_scale 1.0. + ideogram: not from a card but from our own PRESETS — every preset runs main guidance 7.0. + """ + expected = { + BaseModelType.CogView4: (50, 3.5), + BaseModelType.StableDiffusion3: (40, 4.5), + BaseModelType.ZImage: (9, 1.0), + BaseModelType.Ideogram4: (48, 7.0), + BaseModelType.ErnieImage: (50, 4.0), + } + for base, (steps, cfg) in expected.items(): + settings = resolve_default_settings(base) + assert settings is not None, base.value + assert (settings.steps, settings.cfg_scale) == (steps, cfg), base.value From 3cc9a3c40a355da3b3390668566acdde41c41817 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 20:22:58 +0200 Subject: [PATCH 12/26] feat(architectures): declare the Stable Diffusion generation defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SD 1.x, 2.x and XL declared their canvas size and nothing else, so selecting one left the step and CFG sliders at whatever the previous model had set. They get 30 steps at CFG 7 — the classic Stable Diffusion defaults, and what webv2's own table has been using for them all along. Sizes stay native and differ: 512 for 1.x, 1024 for XL, and 768 for 2.x. That last one is a judgment call recorded in place — 768 is right for the v-prediction checkpoints and wrong for the 512 `-base` ones, SD 2.x has no variant modeled to tell them apart, and we ship no starter model for it either way. With this, every architecture but the SDXL refiner declares its defaults, and the refiner is right to declare none: it is not run on its own. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/backend/architectures/defs/sd_1.py | 2 +- invokeai/backend/architectures/defs/sd_2.py | 5 ++++- invokeai/backend/architectures/defs/sdxl.py | 2 +- .../architectures/test_default_settings.py | 21 +++++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index 978198f6a93..e9acdb085bd 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -12,5 +12,5 @@ BaseModelType.StableDiffusion1, LatentSpaceFacet(SD15_4), ConditioningFacet(BasicConditioningInfo), - DefaultSettingsFacet({None: MainModelDefaultSettings(width=512, height=512)}), + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=512, height=512)}), ) diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py index 0b95575bea9..7f5da2f4f4e 100644 --- a/invokeai/backend/architectures/defs/sd_2.py +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -13,5 +13,8 @@ BaseModelType.StableDiffusion2, LatentSpaceFacet(SD15_4), ConditioningFacet(BasicConditioningInfo), - DefaultSettingsFacet({None: MainModelDefaultSettings(width=768, height=768)}), + # 768 is right for the v-prediction checkpoints and wrong for the 512 `-base` ones, and + # nothing here distinguishes them — SD 2.x has no variant modeled and we ship no starter + # model for it. 768 is the deliberate choice of the two. + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=768, height=768)}), ) diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index 31ddcf2fd2f..b5b8a947f34 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -12,5 +12,5 @@ BaseModelType.StableDiffusionXL, LatentSpaceFacet(SDXL_4), ConditioningFacet(SDXLConditioningInfo), - DefaultSettingsFacet({None: MainModelDefaultSettings(width=1024, height=1024)}), + DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=1024, height=1024)}), ) diff --git a/tests/backend/architectures/test_default_settings.py b/tests/backend/architectures/test_default_settings.py index aa80093cc52..3e1f6d279af 100644 --- a/tests/backend/architectures/test_default_settings.py +++ b/tests/backend/architectures/test_default_settings.py @@ -122,8 +122,29 @@ def test_the_researched_values_are_what_the_model_cards_say() -> None: BaseModelType.ZImage: (9, 1.0), BaseModelType.Ideogram4: (48, 7.0), BaseModelType.ErnieImage: (50, 4.0), + # The classic Stable Diffusion defaults, which every SD generation is built around. + BaseModelType.StableDiffusion1: (30, 7.0), + BaseModelType.StableDiffusion2: (30, 7.0), + BaseModelType.StableDiffusionXL: (30, 7.0), } for base, (steps, cfg) in expected.items(): settings = resolve_default_settings(base) assert settings is not None, base.value assert (settings.steps, settings.cfg_scale) == (steps, cfg), base.value + + +def test_the_sd_family_keeps_its_native_sizes() -> None: + """Same steps and CFG, three different canvases — 2.x is the judgment call. + + 768 is right for the v-prediction checkpoints and wrong for the 512 `-base` ones. Nothing in the + config distinguishes them, so one of the two had to be picked. + """ + sizes = { + BaseModelType.StableDiffusion1: (512, 512), + BaseModelType.StableDiffusion2: (768, 768), + BaseModelType.StableDiffusionXL: (1024, 1024), + } + for base, (width, height) in sizes.items(): + settings = resolve_default_settings(base) + assert settings is not None, base.value + assert (settings.width, settings.height) == (width, height), base.value From 1c678365768b52495071bfe99767796ac4d12eea Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 20:44:41 +0200 Subject: [PATCH 13/26] feat(architectures): give the SDXL refiner a canvas, and require default settings at boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refiner declares SDXL's 1024x1024 — it refines an SDXL latent, so it shares its canvas. Steps and CFG stay absent on purpose: it is a second pass driven by the UI's own refiner parameters, so there is nothing here for them to prefill. That was the last architecture without a declaration, which removes the reason this facet was optional. It becomes `REQUIRED`, so a new architecture that forgets its defaults cannot start the app — the same gate that already covers latent spaces and conditioning types. The failure it guards is milder than theirs. A missing latent space raises mid-generation; a missing default just leaves the sliders wherever the previous model put them. But that is an argument for catching it at boot rather than for tolerating it: nothing about an absent prefill is loud enough to be noticed any other way. Verified by removing Anima's declaration — `validate()` refuses to start and names the file. Co-Authored-By: Claude Opus 5 (1M context) --- .../architectures/defs/sdxl_refiner.py | 6 ++++ .../architectures/facets/default_settings.py | 16 +++++++--- .../architectures/test_default_settings.py | 31 +++++++++---------- 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/invokeai/backend/architectures/defs/sdxl_refiner.py b/invokeai/backend/architectures/defs/sdxl_refiner.py index 2ef26f22bd7..f30c1a3d146 100644 --- a/invokeai/backend/architectures/defs/sdxl_refiner.py +++ b/invokeai/backend/architectures/defs/sdxl_refiner.py @@ -1,8 +1,10 @@ """What the sdxl-refiner architecture declares.""" from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType from invokeai.backend.stable_diffusion.diffusion.conditioning_data import SDXLConditioningInfo @@ -11,4 +13,8 @@ BaseModelType.StableDiffusionXLRefiner, LatentSpaceFacet(SDXL_4), ConditioningFacet(SDXLConditioningInfo), + # Same canvas as SDXL, which it refines. Steps and CFG are deliberately absent: the refiner + # is a second pass over an SDXL latent and the UI drives it with its own parameters, so + # there is nothing here for them to prefill. + DefaultSettingsFacet({None: MainModelDefaultSettings(width=1024, height=1024)}), ) diff --git a/invokeai/backend/architectures/facets/default_settings.py b/invokeai/backend/architectures/facets/default_settings.py index eefbfeab146..f4c4354ae62 100644 --- a/invokeai/backend/architectures/facets/default_settings.py +++ b/invokeai/backend/architectures/facets/default_settings.py @@ -5,16 +5,20 @@ config, and used by the UI to prefill the generation sliders. They were a `match base:` block of twelve cases in `configs/main.py`, four of which sub-dispatched on -variant, ending in `case _: return None`. That fallback is why this facet is *not* `REQUIRED`: four -architectures deliberately have no defaults, with a standing `TODO(psyche)` asking whether they -should. Forgetting a new architecture here therefore fails softly — no crash, just sliders the user -has to set themselves — which is milder than the other facets and worth being honest about. +variant, ending in `case _: return None`. Four architectures fell into that fallback and had no +defaults at all, under a standing `TODO(psyche)` asking whether they should; all four have since +been given the values their model cards recommend, which answers it. + +With no architecture left without defaults, this is `REQUIRED`. The failure it guards is milder than +the other facets' — a forgotten declaration would leave the sliders wherever the last model put them +rather than crash — but that is a reason to catch it at boot, not a reason to tolerate it: nothing +about a missing prefill is visible enough to be noticed any other way. """ from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import Any +from typing import Any, ClassVar from invokeai.backend.architectures.facet import Facet from invokeai.backend.architectures.registry import get @@ -26,6 +30,8 @@ class DefaultSettingsFacet(Facet): """What the sliders should say when a model of this architecture is selected.""" + REQUIRED: ClassVar[bool] = True + by_variant: Mapping[Any, MainModelDefaultSettings] """Keyed by variant, with `None` as the fallback for every variant not named. diff --git a/tests/backend/architectures/test_default_settings.py b/tests/backend/architectures/test_default_settings.py index 3e1f6d279af..bb3bd3fdc08 100644 --- a/tests/backend/architectures/test_default_settings.py +++ b/tests/backend/architectures/test_default_settings.py @@ -17,29 +17,28 @@ ZImageVariantType, ) -# SD 3.5, CogView 4 and FLUX.1 reached the old `case _:` fallback and had no defaults at all; they -# now declare what their model cards recommend. The refiner is the one left: it is not run on its -# own, so there is nothing for it to prefill. -WITHOUT_DEFAULTS = {BaseModelType.StableDiffusionXLRefiner} +def test_the_facet_is_required() -> None: + """Every architecture declares defaults, so `validate()` enforces it at boot. -def test_the_facet_is_optional() -> None: - """Unlike latent space and conditioning, a missing declaration here is a legitimate state. - - So `validate()` cannot enforce it, and forgetting a new architecture fails softly: no crash, - just sliders the user sets themselves. + It was optional while four architectures legitimately had none. They now declare what their + model cards recommend, and the SDXL refiner — the last holdout — declares SDXL's canvas. A + missing prefill is quiet rather than loud, which is exactly why it needs a boot check. """ - assert DefaultSettingsFacet.REQUIRED is False + assert DefaultSettingsFacet.REQUIRED is True -def test_exactly_the_expected_architectures_declare_nothing() -> None: - undeclared = {b for b in generative_bases() if get(b, DefaultSettingsFacet) is None} - assert undeclared == WITHOUT_DEFAULTS +def test_every_architecture_declares_defaults() -> None: + undeclared = sorted(b.value for b in generative_bases() if get(b, DefaultSettingsFacet) is None) + assert undeclared == [] -def test_an_architecture_without_defaults_resolves_to_none() -> None: - for base in WITHOUT_DEFAULTS: - assert resolve_default_settings(base) is None, base.value +def test_the_refiner_declares_a_canvas_but_no_sampler_settings() -> None: + """It is a second pass over an SDXL latent, driven by the UI's own refiner parameters.""" + settings = resolve_default_settings(BaseModelType.StableDiffusionXLRefiner) + assert settings is not None + assert (settings.width, settings.height) == (1024, 1024) + assert (settings.steps, settings.cfg_scale) == (None, None) @pytest.mark.parametrize( From 6760ffda24755da56c948d1f31843246afaee104 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 21:01:41 +0200 Subject: [PATCH 14/26] feat(architectures): declare what each architecture can generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two facts that travel together. What an architecture can produce — text-to-image, img2img, inpaint, outpaint, and for Wan and MiniMax H3 video as well — and what its modes are called in image metadata. The second is the load-bearing half. Every generated image records a string like `flux_inpaint`, and those strings sit in user galleries and workflow files. They cannot be changed, only declared: the slug is `z_image` where the enum says `z-image`, `krea2` where it says `krea-2`, `ideogram4`, `sd3`, `ernie_image`, `qwen_image`, `minimax_h3`. Seven of fourteen differ, and SD 1.x and 2.x carry no prefix at all. Deriving them by replacing `-` with `_` would produce `ideogram_4` and orphan every image already tagged `ideogram4`. `GENERATION_MODES` stays a `Literal` — it is a type, and it is what pydantic validates metadata against. What the declarations buy is a test that reconstructs all 50 strings from them and compares: a string missing from the literal is metadata that will not validate, one missing from the declarations is a mode nothing can produce. Both directions are checked. The split was derived from the literal rather than transcribed: fourteen slugs, no ambiguity, and it turned up two things worth stating. The SDXL refiner declares no modes — it refines an SDXL latent and writes no mode string of its own. And MiniMax H3 declares `txt2img`, `t2v` and `i2v` but none of img2img, inpaint or outpaint, which is a real capability boundary the UI can now read instead of guess. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/backend/architectures/__init__.py | 8 ++ invokeai/backend/architectures/defs/anima.py | 2 + .../backend/architectures/defs/cogview4.py | 2 + .../backend/architectures/defs/ernie_image.py | 3 + invokeai/backend/architectures/defs/flux.py | 2 + invokeai/backend/architectures/defs/flux2.py | 2 + .../backend/architectures/defs/ideogram_4.py | 3 + invokeai/backend/architectures/defs/krea_2.py | 2 + .../backend/architectures/defs/minimax_h3.py | 3 + .../backend/architectures/defs/qwen_image.py | 2 + invokeai/backend/architectures/defs/sd_1.py | 3 + invokeai/backend/architectures/defs/sd_2.py | 2 + invokeai/backend/architectures/defs/sd_3.py | 2 + invokeai/backend/architectures/defs/sdxl.py | 2 + .../architectures/defs/sdxl_refiner.py | 3 + invokeai/backend/architectures/defs/wan.py | 3 + .../backend/architectures/defs/z_image.py | 2 + .../backend/architectures/facets/modality.py | 59 +++++++++++++ tests/backend/architectures/test_modality.py | 83 +++++++++++++++++++ 19 files changed, 188 insertions(+) create mode 100644 invokeai/backend/architectures/facets/modality.py create mode 100644 tests/backend/architectures/test_modality.py diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py index 6f462142e24..9c2046e38f8 100644 --- a/invokeai/backend/architectures/__init__.py +++ b/invokeai/backend/architectures/__init__.py @@ -17,6 +17,11 @@ LatentSpaceFacet, resolve_latent_space, ) +from invokeai.backend.architectures.facets.modality import ( + GenerationModeKind, + ModalityFacet, + generation_modes, +) from invokeai.backend.architectures.registry import ( ArchitectureError, defs_module_path, @@ -33,9 +38,12 @@ "ConditioningFacet", "DefaultSettingsFacet", "Facet", + "GenerationModeKind", + "ModalityFacet", "LatentSpace", "LatentSpaceFacet", "conditioning_infos", + "generation_modes", "resolve_default_settings", "resolve_latent_space", "defs_module_path", diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py index 5472374e965..1335af582ce 100644 --- a/invokeai/backend/architectures/defs/anima.py +++ b/invokeai/backend/architectures/defs/anima.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -14,4 +15,5 @@ LatentSpaceFacet(WAN21_16), ConditioningFacet(AnimaConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=35, cfg_scale=4.5, width=1024, height=1024)}), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="anima"), ) diff --git a/invokeai/backend/architectures/defs/cogview4.py b/invokeai/backend/architectures/defs/cogview4.py index c2babde2954..55acff85657 100644 --- a/invokeai/backend/architectures/defs/cogview4.py +++ b/invokeai/backend/architectures/defs/cogview4.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import COGVIEW4_16, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -16,4 +17,5 @@ # classifier-free guidance, so it belongs in cfg_scale — and the denoise node already # defaults to 3.5, which nothing was propagating to the sliders. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=50, cfg_scale=3.5, width=1024, height=1024)}), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="cogview4"), ) diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py index 5349303d60c..e75c75861de 100644 --- a/invokeai/backend/architectures/defs/ernie_image.py +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -21,4 +22,6 @@ # disk to discriminate on and no variant is modeled. The name is the only signal. by_name_hint={"turbo": MainModelDefaultSettings(steps=8, cfg_scale=1.0, width=1024, height=1024)}, ), + # Text-to-image only. + ModalityFacet(frozenset({"txt2img"}), metadata_slug="ernie_image"), ) diff --git a/invokeai/backend/architectures/defs/flux.py b/invokeai/backend/architectures/defs/flux.py index 980b53cbb36..d57dcfe8bd9 100644 --- a/invokeai/backend/architectures/defs/flux.py +++ b/invokeai/backend/architectures/defs/flux.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, FluxVariantType @@ -27,4 +28,5 @@ None: MainModelDefaultSettings(steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024), } ), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="flux"), ) diff --git a/invokeai/backend/architectures/defs/flux2.py b/invokeai/backend/architectures/defs/flux2.py index 5cf0fd44917..53357df7840 100644 --- a/invokeai/backend/architectures/defs/flux2.py +++ b/invokeai/backend/architectures/defs/flux2.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, Flux2VariantType @@ -26,4 +27,5 @@ None: MainModelDefaultSettings(steps=4, cfg_scale=1.0, width=1024, height=1024), } ), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="flux2"), ) diff --git a/invokeai/backend/architectures/defs/ideogram_4.py b/invokeai/backend/architectures/defs/ideogram_4.py index e238a4ee686..5591b6b666a 100644 --- a/invokeai/backend/architectures/defs/ideogram_4.py +++ b/invokeai/backend/architectures/defs/ideogram_4.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -17,4 +18,6 @@ # Ideogram 4 samples from presets (V4_QUALITY_48 by default) with a dual-branch guidance # schedule; these are sensible UI defaults rather than the sampler's own numbers. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=48, cfg_scale=7.0, width=1024, height=1024)}), + # Text-to-image only. + ModalityFacet(frozenset({"txt2img"}), metadata_slug="ideogram4"), ) diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py index 3853e0c3d40..576ff80501b 100644 --- a/invokeai/backend/architectures/defs/krea_2.py +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, Krea2VariantType @@ -22,4 +23,5 @@ None: MainModelDefaultSettings(steps=8, cfg_scale=1.0, width=1024, height=1024), } ), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="krea2"), ) diff --git a/invokeai/backend/architectures/defs/minimax_h3.py b/invokeai/backend/architectures/defs/minimax_h3.py index 580ff55f81c..609c85569bf 100644 --- a/invokeai/backend/architectures/defs/minimax_h3.py +++ b/invokeai/backend/architectures/defs/minimax_h3.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import MINIMAX_H3_24, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -15,4 +16,6 @@ # H3 is guidance-distilled (cfg_scale 1.0 means no guidance) and was released for a fixed # 768px short edge; 1344x768 is its native 16:9 canvas. Dimensions must be multiples of 32. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=50, cfg_scale=1.0, width=1344, height=768)}), + # Video first, with a single-frame still-image path. No img2img, inpaint or outpaint. + ModalityFacet(frozenset({"txt2img", "t2v", "i2v"}), metadata_slug="minimax_h3"), ) diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py index 6e72c2ec132..bbf10c2486d 100644 --- a/invokeai/backend/architectures/defs/qwen_image.py +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -14,4 +15,5 @@ LatentSpaceFacet(WAN21_16), ConditioningFacet(QwenImageConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024)}), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="qwen_image"), ) diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index e9acdb085bd..eaa22bc10de 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -13,4 +14,6 @@ LatentSpaceFacet(SD15_4), ConditioningFacet(BasicConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=512, height=512)}), + # SD 1.x and 2.x share the unprefixed mode strings: a bare `txt2img`. + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"})), ) diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py index 7f5da2f4f4e..f4b63f298fa 100644 --- a/invokeai/backend/architectures/defs/sd_2.py +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -17,4 +18,5 @@ # nothing here distinguishes them — SD 2.x has no variant modeled and we ship no starter # model for it. 768 is the deliberate choice of the two. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=768, height=768)}), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"})), ) diff --git a/invokeai/backend/architectures/defs/sd_3.py b/invokeai/backend/architectures/defs/sd_3.py index 92380194594..14bcf0229b7 100644 --- a/invokeai/backend/architectures/defs/sd_3.py +++ b/invokeai/backend/architectures/defs/sd_3.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SD3_16, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -16,4 +17,5 @@ # (28/3.5) because there is one `sd-3` row and no variant to tell them apart, and Medium is the # smaller, more commonly run model. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.5, width=1024, height=1024)}), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="sd3"), ) diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index b5b8a947f34..fbb513acd5d 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -13,4 +14,5 @@ LatentSpaceFacet(SDXL_4), ConditioningFacet(SDXLConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=1024, height=1024)}), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="sdxl"), ) diff --git a/invokeai/backend/architectures/defs/sdxl_refiner.py b/invokeai/backend/architectures/defs/sdxl_refiner.py index f30c1a3d146..7c7e8d3bad8 100644 --- a/invokeai/backend/architectures/defs/sdxl_refiner.py +++ b/invokeai/backend/architectures/defs/sdxl_refiner.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -17,4 +18,6 @@ # is a second pass over an SDXL latent and the UI drives it with its own parameters, so # there is nothing here for them to prefill. DefaultSettingsFacet({None: MainModelDefaultSettings(width=1024, height=1024)}), + # Generates nothing on its own; it refines an SDXL latent, so it writes no mode string. + ModalityFacet(frozenset()), ) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index ef3b15d87f9..bd1d70506d3 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import WAN21_16, WAN22_48, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, WanVariantType @@ -22,4 +23,6 @@ None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024), } ), + # Plus image-to-video. Wan generates images at num_frames=1 and video above that. + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint", "i2v"}), metadata_slug="wan"), ) diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py index b064de6f3b7..b1535502dc2 100644 --- a/invokeai/backend/architectures/defs/z_image.py +++ b/invokeai/backend/architectures/defs/z_image.py @@ -3,6 +3,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, ZImageVariantType @@ -21,4 +22,5 @@ None: MainModelDefaultSettings(steps=9, cfg_scale=1.0, width=1024, height=1024), } ), + ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="z_image"), ) diff --git a/invokeai/backend/architectures/facets/modality.py b/invokeai/backend/architectures/facets/modality.py new file mode 100644 index 00000000000..ae76dd0d01b --- /dev/null +++ b/invokeai/backend/architectures/facets/modality.py @@ -0,0 +1,59 @@ +"""What an architecture can generate, and what its modes are called in image metadata. + +Two separate facts that happen to travel together. The first is a capability the UI needs — an +architecture that cannot inpaint must not offer the tool. The second is a naming convention: every +generated image records a mode string like `flux_inpaint` in its metadata, and those strings are +persisted in user galleries and workflow files. They cannot be changed, only declared. + +The slug is not the base value. It is `z_image` where the enum says `z-image`, `krea2` where the enum +says `krea-2`, `ideogram4`, `sd3`, `ernie_image` — and SD 1.x and 2.x use no prefix at all, emitting +a bare `txt2img`. Deriving it from `BaseModelType` would be wrong in seven of fourteen cases, so it +is declared, and a test reconstructs `GENERATION_MODES` from the declarations to prove the set is +complete and unchanged. +""" + +from dataclasses import dataclass +from typing import ClassVar, Literal + +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.registry import generative_bases, get + +GenerationModeKind = Literal["txt2img", "img2img", "inpaint", "outpaint", "t2v", "i2v"] +"""The kinds of generation a mode string names. `t2v`/`i2v` produce video, the rest images.""" + + +@dataclass(frozen=True) +class ModalityFacet(Facet): + """What this architecture can produce.""" + + REQUIRED: ClassVar[bool] = True + + modes: frozenset[GenerationModeKind] + """Empty is meaningful: the SDXL refiner generates nothing on its own.""" + + metadata_slug: str | None = None + """The prefix its mode strings carry in image metadata. `None` means unprefixed. + + Persisted in every image a user has ever generated. Changing one does not migrate anything — it + orphans the old value. + """ + + def metadata_modes(self) -> frozenset[str]: + """The mode strings this architecture writes into image metadata.""" + prefix = f"{self.metadata_slug}_" if self.metadata_slug else "" + return frozenset(f"{prefix}{mode}" for mode in self.modes) + + +def generation_modes() -> frozenset[str]: + """Every mode string any architecture can write. + + Compared against `GENERATION_MODES` in a test rather than used to define it: that literal is a + type, and it is what pydantic validates metadata against. + """ + return frozenset( + mode + for base in generative_bases() + for facet in [get(base, ModalityFacet)] + if facet is not None + for mode in facet.metadata_modes() + ) diff --git a/tests/backend/architectures/test_modality.py b/tests/backend/architectures/test_modality.py new file mode 100644 index 00000000000..a0c17cabce1 --- /dev/null +++ b/tests/backend/architectures/test_modality.py @@ -0,0 +1,83 @@ +"""What each architecture can generate, and the metadata strings it writes.""" + +from typing import get_args + +from invokeai.app.invocations.metadata import GENERATION_MODES +from invokeai.backend.architectures import generative_bases +from invokeai.backend.architectures.facets.modality import ModalityFacet, generation_modes +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.taxonomy import BaseModelType + + +def test_the_declarations_reconstruct_generation_modes() -> None: + """The gate. `GENERATION_MODES` is a type pydantic validates metadata against; this proves the + architecture declarations and that literal describe the same 50 strings. + + Compared rather than generated from the declarations, deliberately: the literal has to stay a + literal to be a type, and a mismatch in either direction is a bug worth naming. A string missing + from the literal means metadata that will not validate; one missing from the declarations means + a mode nothing can produce. + """ + assert generation_modes() == frozenset(get_args(GENERATION_MODES)) + + +def test_every_architecture_declares_its_modality() -> None: + undeclared = sorted(b.value for b in generative_bases() if get(b, ModalityFacet) is None) + assert undeclared == [] + + +def test_the_slug_is_not_the_base_value() -> None: + """Seven of fourteen slugs differ from the enum value, which is why they are declared. + + These strings sit in the metadata of every image a user has generated. Deriving them — by + replacing `-` with `_`, say — would produce `ideogram_4` where the truth is `ideogram4`, and + silently orphan every image already tagged with the old one. + """ + divergent = { + BaseModelType.StableDiffusion3: "sd3", + BaseModelType.ZImage: "z_image", + BaseModelType.ErnieImage: "ernie_image", + BaseModelType.Ideogram4: "ideogram4", + BaseModelType.QwenImage: "qwen_image", + BaseModelType.Krea2: "krea2", + BaseModelType.MiniMaxH3: "minimax_h3", + } + for base, slug in divergent.items(): + facet = get(base, ModalityFacet) + assert facet is not None and facet.metadata_slug == slug, base.value + assert slug != base.value, f"{base.value} would not need declaring" + + +def test_stable_diffusion_writes_unprefixed_modes() -> None: + """SD 1.x and 2.x share the bare strings — the only two architectures with no prefix.""" + unprefixed = sorted( + b.value for b in generative_bases() if (f := get(b, ModalityFacet)) is not None and f.metadata_slug is None + ) + assert unprefixed == ["sd-1", "sd-2", "sdxl-refiner"] + + sd1 = get(BaseModelType.StableDiffusion1, ModalityFacet) + assert sd1 is not None and "txt2img" in sd1.metadata_modes() + + +def test_the_refiner_generates_nothing() -> None: + """Empty modes, so it contributes no metadata string despite having no prefix either.""" + facet = get(BaseModelType.StableDiffusionXLRefiner, ModalityFacet) + assert facet is not None + assert facet.modes == frozenset() + assert facet.metadata_modes() == frozenset() + + +def test_the_text_to_image_only_architectures() -> None: + for base in (BaseModelType.ErnieImage, BaseModelType.Ideogram4): + facet = get(base, ModalityFacet) + assert facet is not None and facet.modes == frozenset({"txt2img"}), base.value + + +def test_the_video_architectures() -> None: + """Wan generates images at one frame and video above that; H3 is video-first.""" + wan = get(BaseModelType.Wan, ModalityFacet) + h3 = get(BaseModelType.MiniMaxH3, ModalityFacet) + assert wan is not None and h3 is not None + + assert "i2v" in wan.modes and "inpaint" in wan.modes + assert h3.modes == frozenset({"txt2img", "t2v", "i2v"}), "H3 has no img2img, inpaint or outpaint" From 6a1853cbbdeca02bd15761fd30f0eb639ba1ef45 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Wed, 19 Aug 2026 21:40:11 +0200 Subject: [PATCH 15/26] feat(architectures): declare which generation features each architecture supports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether to show a negative prompt box, whether a ControlNet layer can attach, how many reference images to accept, whether clip-skip means anything — none of it derivable from a model file, all of it living in the frontend. webv2 holds the working version for thirteen bases in `baseGenerationPolicies.ts`, plus three predicates elsewhere: `isControlKindSupportedForBase`, `isReferenceImageSupported` and `isRegionalGuidanceSupportedForBase`. Those values are what is declared here — the point is not to change them but to put them where a new architecture cannot be added without them. Which matters, because three bases have no row there at all and got one by derivation from their own nodes rather than by guesswork: ernie-image `ernie_image_denoise` takes a negative_conditioning "required when guidance_scale != 1.0" — cfg-gated, like the other distilled models. Its scheduler set is ERNIE_IMAGE_SCHEDULER_MAP, a flow family. minimax-h3 its module docstring is explicit: "guidance-distilled: no negative prompt, no CFG, one forward per step". It steps video and audio down two hardcoded flow schedules, so there is no scheduler to choose — the only base declaring `scheduler_set=None`. sdxl-refiner declares no modes, so it is not offered as a generation model; it answers as the SDXL pass it is, so a UI that does surface it needs no special case. `dimension_grid` was not taken from webv2 but from the node's `multiple_of` on width — the constraint the graph actually enforces. The two agree for all thirteen, which is the useful part: two independent sources, no divergence, and a test now pins the declaration to the node so a UI can never offer dimensions the graph will reject. It also fills in the two missing values, 16 for ERNIE and 32 for H3. Three invariants are asserted rather than assumed, and each caught something while being written: regional negative prompts are a strict subset of regional guidance; a negative prompt box is visible exactly when its usage is not `never`; and clip-skip belongs to SD 1.x and 2.x alone — legacy's `CLIP_SKIP_MAP` carries 24 for SDXL, which webv2 never reads and nothing has offered for some time. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/backend/architectures/__init__.py | 8 + invokeai/backend/architectures/defs/anima.py | 8 + .../backend/architectures/defs/cogview4.py | 7 + .../backend/architectures/defs/ernie_image.py | 10 ++ invokeai/backend/architectures/defs/flux.py | 11 ++ invokeai/backend/architectures/defs/flux2.py | 10 ++ .../backend/architectures/defs/ideogram_4.py | 7 + invokeai/backend/architectures/defs/krea_2.py | 8 + .../backend/architectures/defs/minimax_h3.py | 9 ++ .../backend/architectures/defs/qwen_image.py | 9 ++ invokeai/backend/architectures/defs/sd_1.py | 17 ++ invokeai/backend/architectures/defs/sd_2.py | 13 ++ invokeai/backend/architectures/defs/sd_3.py | 7 + invokeai/backend/architectures/defs/sdxl.py | 16 ++ .../architectures/defs/sdxl_refiner.py | 13 ++ invokeai/backend/architectures/defs/wan.py | 7 + .../backend/architectures/defs/z_image.py | 9 ++ .../backend/architectures/facets/features.py | 77 ++++++++++ tests/backend/architectures/test_features.py | 145 ++++++++++++++++++ 19 files changed, 391 insertions(+) create mode 100644 invokeai/backend/architectures/facets/features.py create mode 100644 tests/backend/architectures/test_features.py diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py index 9c2046e38f8..409f921d0e7 100644 --- a/invokeai/backend/architectures/__init__.py +++ b/invokeai/backend/architectures/__init__.py @@ -12,6 +12,11 @@ DefaultSettingsFacet, resolve_default_settings, ) +from invokeai.backend.architectures.facets.features import ( + ControlKind, + FeaturesFacet, + NegativePrompt, +) from invokeai.backend.architectures.facets.latent_space import ( LatentSpace, LatentSpaceFacet, @@ -37,7 +42,10 @@ "ArchitectureError", "ConditioningFacet", "DefaultSettingsFacet", + "ControlKind", "Facet", + "FeaturesFacet", + "NegativePrompt", "GenerationModeKind", "ModalityFacet", "LatentSpace", diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py index 1335af582ce..d6ca61575d6 100644 --- a/invokeai/backend/architectures/defs/anima.py +++ b/invokeai/backend/architectures/defs/anima.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -16,4 +17,11 @@ ConditioningFacet(AnimaConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=35, cfg_scale=4.5, width=1024, height=1024)}), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="anima"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="cfg-gated"), + dimension_grid=8, + guidance_label="CFG", + scheduler_set="anima", + scheduler_applies_to_graph=True, + ), ) diff --git a/invokeai/backend/architectures/defs/cogview4.py b/invokeai/backend/architectures/defs/cogview4.py index 55acff85657..a6e35ad62c3 100644 --- a/invokeai/backend/architectures/defs/cogview4.py +++ b/invokeai/backend/architectures/defs/cogview4.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import COGVIEW4_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -18,4 +19,10 @@ # defaults to 3.5, which nothing was propagating to the sliders. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=50, cfg_scale=3.5, width=1024, height=1024)}), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="cogview4"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=32, + guidance_label="CFG", + scheduler_set="standard", + ), ) diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py index e75c75861de..6431e7226a3 100644 --- a/invokeai/backend/architectures/defs/ernie_image.py +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -24,4 +25,13 @@ ), # Text-to-image only. ModalityFacet(frozenset({"txt2img"}), metadata_slug="ernie_image"), + # ernie_image_denoise takes a negative_conditioning that is 'required when + # guidance_scale != 1.0' — cfg-gated, exactly like the other distilled models. Its + # scheduler set is ERNIE_IMAGE_SCHEDULER_MAP, a flow family. + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="cfg-gated"), + dimension_grid=16, + guidance_label="CFG", + scheduler_set="flow", + ), ) diff --git a/invokeai/backend/architectures/defs/flux.py b/invokeai/backend/architectures/defs/flux.py index d57dcfe8bd9..614456ff740 100644 --- a/invokeai/backend/architectures/defs/flux.py +++ b/invokeai/backend/architectures/defs/flux.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -29,4 +30,14 @@ } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="flux"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=False, usage="never"), + dimension_grid=16, + guidance_label="Guidance", + scheduler_set="flow", + scheduler_applies_to_graph=True, + control_kinds=frozenset({"controlnet", "control_lora"}), + max_reference_images=5, + supports_regional_guidance=True, + ), ) diff --git a/invokeai/backend/architectures/defs/flux2.py b/invokeai/backend/architectures/defs/flux2.py index 53357df7840..75c7ac53588 100644 --- a/invokeai/backend/architectures/defs/flux2.py +++ b/invokeai/backend/architectures/defs/flux2.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -28,4 +29,13 @@ } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="flux2"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=False, usage="never"), + dimension_grid=16, + guidance_label="Guidance", + scheduler_set="flow", + scheduler_applies_to_graph=True, + max_reference_images=5, + supports_regional_guidance=True, + ), ) diff --git a/invokeai/backend/architectures/defs/ideogram_4.py b/invokeai/backend/architectures/defs/ideogram_4.py index 5591b6b666a..ad05b3ecc4c 100644 --- a/invokeai/backend/architectures/defs/ideogram_4.py +++ b/invokeai/backend/architectures/defs/ideogram_4.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import FLUX2_32, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -20,4 +21,10 @@ DefaultSettingsFacet({None: MainModelDefaultSettings(steps=48, cfg_scale=7.0, width=1024, height=1024)}), # Text-to-image only. ModalityFacet(frozenset({"txt2img"}), metadata_slug="ideogram4"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=False, usage="never"), + dimension_grid=16, + guidance_label="Guidance", + scheduler_set="flow", + ), ) diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py index 576ff80501b..a69f643bf5d 100644 --- a/invokeai/backend/architectures/defs/krea_2.py +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -24,4 +25,11 @@ } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="krea2"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="cfg-gated"), + dimension_grid=16, + guidance_label="CFG", + scheduler_set="flow", + supports_regional_guidance=True, + ), ) diff --git a/invokeai/backend/architectures/defs/minimax_h3.py b/invokeai/backend/architectures/defs/minimax_h3.py index 609c85569bf..5cc5fa36ba5 100644 --- a/invokeai/backend/architectures/defs/minimax_h3.py +++ b/invokeai/backend/architectures/defs/minimax_h3.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import MINIMAX_H3_24, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -18,4 +19,12 @@ DefaultSettingsFacet({None: MainModelDefaultSettings(steps=50, cfg_scale=1.0, width=1344, height=768)}), # Video first, with a single-frame still-image path. No img2img, inpaint or outpaint. ModalityFacet(frozenset({"txt2img", "t2v", "i2v"}), metadata_slug="minimax_h3"), + # minimax_h3_denoise is explicit: 'guidance-distilled: no negative prompt, no CFG, one + # forward per step'. It steps video and audio down two hardcoded flow schedules, so + # there is no scheduler to choose either. + FeaturesFacet( + negative_prompt=NegativePrompt(visible=False, usage="never"), + dimension_grid=32, + guidance_label="Guidance", + ), ) diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py index bbf10c2486d..2527adc40c7 100644 --- a/invokeai/backend/architectures/defs/qwen_image.py +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -16,4 +17,12 @@ ConditioningFacet(QwenImageConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024)}), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="qwen_image"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="cfg-gated"), + dimension_grid=16, + guidance_label="CFG", + scheduler_set="standard", + max_reference_images=5, + reference_images_require_variant="edit", + ), ) diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index eaa22bc10de..083195a1e4e 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -16,4 +17,20 @@ DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=512, height=512)}), # SD 1.x and 2.x share the unprefixed mode strings: a bare `txt2img`. ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"})), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=8, + guidance_label="CFG", + scheduler_set="standard", + scheduler_applies_to_graph=True, + control_kinds=frozenset({"controlnet", "t2i_adapter"}), + max_reference_images=5, + supports_regional_guidance=True, + regional_negative=True, + clip_skip_max=12, + supports_seamless=True, + supports_cfg_rescale=True, + sd_vae_override=True, + vae_precision=True, + ), ) diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py index f4b63f298fa..fd3f545fc8d 100644 --- a/invokeai/backend/architectures/defs/sd_2.py +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -19,4 +20,16 @@ # model for it. 768 is the deliberate choice of the two. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=768, height=768)}), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"})), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=8, + guidance_label="CFG", + scheduler_set="standard", + scheduler_applies_to_graph=True, + clip_skip_max=24, + supports_seamless=True, + supports_cfg_rescale=True, + sd_vae_override=True, + vae_precision=True, + ), ) diff --git a/invokeai/backend/architectures/defs/sd_3.py b/invokeai/backend/architectures/defs/sd_3.py index 14bcf0229b7..bcdb4ccb819 100644 --- a/invokeai/backend/architectures/defs/sd_3.py +++ b/invokeai/backend/architectures/defs/sd_3.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import SD3_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -18,4 +19,10 @@ # smaller, more commonly run model. DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.5, width=1024, height=1024)}), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="sd3"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=16, + guidance_label="CFG", + scheduler_set="standard", + ), ) diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index fbb513acd5d..9615fecdeba 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -15,4 +16,19 @@ ConditioningFacet(SDXLConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=1024, height=1024)}), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="sdxl"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=8, + guidance_label="CFG", + scheduler_set="standard", + scheduler_applies_to_graph=True, + control_kinds=frozenset({"controlnet", "t2i_adapter"}), + max_reference_images=5, + supports_regional_guidance=True, + regional_negative=True, + supports_seamless=True, + sd_vae_override=True, + color_compensation=True, + vae_precision=True, + ), ) diff --git a/invokeai/backend/architectures/defs/sdxl_refiner.py b/invokeai/backend/architectures/defs/sdxl_refiner.py index 7c7e8d3bad8..dd8d92e2b29 100644 --- a/invokeai/backend/architectures/defs/sdxl_refiner.py +++ b/invokeai/backend/architectures/defs/sdxl_refiner.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -20,4 +21,16 @@ DefaultSettingsFacet({None: MainModelDefaultSettings(width=1024, height=1024)}), # Generates nothing on its own; it refines an SDXL latent, so it writes no mode string. ModalityFacet(frozenset()), + # Not selected as a generation model — it declares no modes — but it is an SDXL pass and + # answers as one, so a UI that does surface it does not have to special-case it. + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=8, + guidance_label="CFG", + scheduler_set="standard", + scheduler_applies_to_graph=True, + sd_vae_override=True, + color_compensation=True, + vae_precision=True, + ), ) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index bd1d70506d3..1cea381e21c 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, WAN22_48, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -25,4 +26,10 @@ ), # Plus image-to-video. Wan generates images at num_frames=1 and video above that. ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint", "i2v"}), metadata_slug="wan"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=16, + guidance_label="Guidance", + scheduler_set="flow", + ), ) diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py index b1535502dc2..e917feed5fc 100644 --- a/invokeai/backend/architectures/defs/z_image.py +++ b/invokeai/backend/architectures/defs/z_image.py @@ -2,6 +2,7 @@ from invokeai.backend.architectures.facets.conditioning import ConditioningFacet from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet from invokeai.backend.architectures.registry import register @@ -23,4 +24,12 @@ } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="z_image"), + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="cfg-gated"), + dimension_grid=16, + guidance_label="CFG", + scheduler_set="flow", + scheduler_applies_to_graph=True, + control_kinds=frozenset({"z_image_control"}), + ), ) diff --git a/invokeai/backend/architectures/facets/features.py b/invokeai/backend/architectures/facets/features.py new file mode 100644 index 00000000000..5ebe7566314 --- /dev/null +++ b/invokeai/backend/architectures/facets/features.py @@ -0,0 +1,77 @@ +"""Which generation features an architecture supports. + +The UI has to know whether to show a negative prompt box, whether a ControlNet layer can attach, +how many reference images to accept, whether clip-skip means anything. None of it is derivable from +a model file — it follows from what the architecture is — and all of it was living in the frontend. + +webv2 holds the working version of this table for thirteen bases at +`features/generation/core/baseGenerationPolicies.ts`, plus three more predicates scattered across +`controlValidation.ts` and `addRegionalGuidance.ts`. Those values are the source for what is +declared here; the point is not to change them but to move them somewhere a new architecture cannot +be added without them, and where the three bases webv2 has never heard of — ERNIE-Image, MiniMax H3 +and the SDXL refiner — get an answer too. +""" + +from dataclasses import dataclass +from typing import ClassVar, Literal + +from invokeai.backend.architectures.facet import Facet + +NegativePromptUsage = Literal["always", "cfg-gated", "never"] +"""`cfg-gated` means the field exists but only does anything above CFG 1 — the distilled models.""" + +ControlKind = Literal["controlnet", "t2i_adapter", "control_lora", "z_image_control"] + +SchedulerSet = Literal["standard", "flow", "anima"] +"""Which family of schedulers to offer. `None` means the architecture drives its own and offers no +choice — MiniMax H3 steps video and audio down two hardcoded flow schedules.""" + + +@dataclass(frozen=True) +class NegativePrompt: + """Whether to show the box, and whether what is typed in it is used.""" + + visible: bool + usage: NegativePromptUsage + + +@dataclass(frozen=True) +class FeaturesFacet(Facet): + """What the UI may offer for this architecture.""" + + REQUIRED: ClassVar[bool] = True + + negative_prompt: NegativePrompt + dimension_grid: int + """Width and height must be a multiple of this. Already declared on the denoise node as + `multiple_of`; a test asserts the two agree, so this cannot drift into a UI that offers + dimensions the node will reject.""" + + guidance_label: Literal["CFG", "Guidance"] = "CFG" + """What to call the slider. FLUX-family models expose a distilled guidance embedding rather than + classifier-free guidance, and calling it CFG has confused users into expecting CFG behaviour.""" + + scheduler_set: SchedulerSet | None = None + scheduler_applies_to_graph: bool = False + """Whether the chosen scheduler reaches the graph, or is only a UI affordance.""" + + control_kinds: frozenset[ControlKind] = frozenset() + max_reference_images: int = 0 + reference_images_require_variant: str | None = None + """Qwen-Image accepts reference images only as the `edit` variant — the one feature in this + table that a base alone cannot answer.""" + + supports_regional_guidance: bool = False + regional_negative: bool = False + """Regional *negative* prompts, which only the SD family has.""" + + clip_skip_max: int | None = None + supports_seamless: bool = False + supports_cfg_rescale: bool = False + sd_vae_override: bool = False + color_compensation: bool = False + vae_precision: bool = False + + @property + def supports_reference_images(self) -> bool: + return self.max_reference_images > 0 diff --git a/tests/backend/architectures/test_features.py b/tests/backend/architectures/test_features.py new file mode 100644 index 00000000000..38dbf2ad414 --- /dev/null +++ b/tests/backend/architectures/test_features.py @@ -0,0 +1,145 @@ +"""Which generation features each architecture declares.""" + +import pytest + +from invokeai.app.invocations.baseinvocation import InvocationRegistry +from invokeai.backend.architectures import generative_bases +from invokeai.backend.architectures.facets.features import ControlKind, FeaturesFacet +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.taxonomy import BaseModelType + +# The node that owns width/height for each architecture. Bases absent from this map drive their +# dimensions from the latent tensor rather than from fields on a node. +DENOISE_NODE = { + BaseModelType.StableDiffusion3: "sd3_denoise", + BaseModelType.CogView4: "cogview4_denoise", + BaseModelType.Flux: "flux_denoise", + BaseModelType.Flux2: "flux2_denoise", + BaseModelType.ZImage: "z_image_denoise", + BaseModelType.ErnieImage: "ernie_image_denoise", + BaseModelType.Ideogram4: "ideogram4_denoise", + BaseModelType.QwenImage: "qwen_image_denoise", + BaseModelType.Anima: "anima_denoise", + BaseModelType.Krea2: "krea2_denoise", + BaseModelType.Wan: "wan_denoise", + BaseModelType.MiniMaxH3: "minimax_h3_denoise", +} + + +def test_every_architecture_declares_its_features() -> None: + undeclared = sorted(b.value for b in generative_bases() if get(b, FeaturesFacet) is None) + assert undeclared == [] + + +def test_the_dimension_grid_matches_the_node_that_enforces_it() -> None: + """The declared grid and the node's `multiple_of` are the same number, or the UI offers + dimensions the graph will reject. + + Two independent sources today: the node's field constraint, and webv2's own `dimensions.grid`. + They agree for all thirteen bases webv2 knows; this pins the declaration to the node, which is + the one that actually enforces it. + """ + widths = { + cls.get_type(): cls.model_json_schema()["properties"]["width"] + for cls in InvocationRegistry.get_invocation_classes() + if "width" in cls.model_json_schema().get("properties", {}) + } + mismatched = [] + for base, node_type in DENOISE_NODE.items(): + facet = get(base, FeaturesFacet) + assert facet is not None, base.value + node_grid = widths[node_type].get("multipleOf") + if node_grid != facet.dimension_grid: + mismatched.append(f"{base.value}: declared {facet.dimension_grid}, {node_type} says {node_grid}") + assert mismatched == [] + + +def test_the_sd_family_grid_is_the_vae_compression() -> None: + """SD has no width field to constrain — its grid follows from the VAE's 8x downscale.""" + for base in (BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2, BaseModelType.StableDiffusionXL): + facet = get(base, FeaturesFacet) + assert facet is not None and facet.dimension_grid == 8, base.value + + +@pytest.mark.parametrize( + ("kind", "expected"), + [ + ("controlnet", {"sd-1", "sdxl", "flux"}), + ("t2i_adapter", {"sd-1", "sdxl"}), + ("control_lora", {"flux"}), + ("z_image_control", {"z-image"}), + ], +) +def test_control_kinds_match_the_frontend_policy(kind: ControlKind, expected: set[str]) -> None: + """Lifted from webv2's `isControlKindSupportedForBase`, which is four nested conditionals.""" + declared = { + b.value for b in generative_bases() if (f := get(b, FeaturesFacet)) is not None and kind in f.control_kinds + } + assert declared == expected + + +def test_reference_images_and_the_one_variant_condition() -> None: + """Qwen-Image is the only base whose answer depends on the variant, so it is the only one with + `reference_images_require_variant` set.""" + supported = { + b.value for b in generative_bases() if (f := get(b, FeaturesFacet)) is not None and f.supports_reference_images + } + assert supported == {"flux", "flux2", "sd-1", "sdxl", "qwen-image"} + + conditional = { + b.value + for b in generative_bases() + if (f := get(b, FeaturesFacet)) is not None and f.reference_images_require_variant is not None + } + assert conditional == {"qwen-image"} + qwen = get(BaseModelType.QwenImage, FeaturesFacet) + assert qwen is not None and qwen.reference_images_require_variant == "edit" + + +def test_regional_guidance_and_its_negative_subset() -> None: + """Regional negative prompts are a strict subset — only the SD family has them.""" + regional = { + b.value for b in generative_bases() if (f := get(b, FeaturesFacet)) is not None and f.supports_regional_guidance + } + negative = {b.value for b in generative_bases() if (f := get(b, FeaturesFacet)) is not None and f.regional_negative} + assert regional == {"sd-1", "sdxl", "flux", "flux2", "krea-2"} + assert negative == {"sd-1", "sdxl"} + assert negative < regional, "a regional negative prompt without regional guidance is meaningless" + + +def test_the_negative_prompt_policy_follows_the_guidance_model() -> None: + """`cfg-gated` is for models that take a negative prompt only above CFG 1; `never` for the + guidance-distilled ones, which have no CFG at all.""" + by_usage: dict[str, set[str]] = {} + for base in generative_bases(): + facet = get(base, FeaturesFacet) + assert facet is not None + by_usage.setdefault(facet.negative_prompt.usage, set()).add(base.value) + + assert by_usage["never"] == {"flux", "flux2", "ideogram-4", "minimax-h3"} + assert by_usage["cfg-gated"] == {"anima", "krea-2", "qwen-image", "z-image", "ernie-image"} + # Nothing declares a visible box it never uses, or an invisible one it does. + for base in generative_bases(): + facet = get(base, FeaturesFacet) + assert facet is not None + assert facet.negative_prompt.visible == (facet.negative_prompt.usage != "never"), base.value + + +def test_the_flux_family_labels_its_slider_guidance() -> None: + """Calling a distilled guidance embedding "CFG" has misled users into expecting CFG behaviour.""" + labelled = { + b.value + for b in generative_bases() + if (f := get(b, FeaturesFacet)) is not None and f.guidance_label == "Guidance" + } + assert labelled == {"flux", "flux2", "ideogram-4", "wan", "minimax-h3"} + + +def test_clip_skip_is_an_sd_1_and_2_feature_only() -> None: + """SDXL has no `clipSkipMax` in webv2 at all, and legacy's 24 for it was never reachable.""" + with_clip_skip = { + b.value: (f := get(b, FeaturesFacet)) and f.clip_skip_max + for b in generative_bases() + if (f := get(b, FeaturesFacet)) is not None and f.clip_skip_max is not None + } + assert with_clip_skip == {"sd-1": 12, "sd-2": 24} From f357dec8a9337d6d37198b8d2cc7a98fa6401526 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 05:40:17 +0200 Subject: [PATCH 16/26] feat(api): serve the architecture capability table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/v2/models/capabilities` returns what every architecture can generate and which generation features it supports — 24 rows, one per architecture plus one per variant that answers differently. A client fetches it once and joins it against model records locally: look up `(base, variant)`, fall back to `(base, null)`. This is what the facets were for. webv2 currently hardcodes the same table for thirteen bases in `baseGenerationPolicies.ts`, has no row at all for ERNIE-Image, MiniMax H3 or the SDXL refiner, and disagrees with the backend in four places (ideogram-4 at CFG 1 against the sampler's actual 7.0; z-image at 8 steps against the card's 9; anima at 30/4; sd-2 at 512). None of that is fixed by this commit — it is what the commit makes fixable, by giving webv2 something to read instead of a table to maintain. Three shape decisions, each against an alternative the plan proposed: `ArchitectureCapabilities` is not a subclass of `ExternalModelCapabilities` and does not share its name. That model describes one external provider's model — aspect ratios, resolution presets, mask format, per-request limits — and is stored on each such record. This describes an architecture, is identical for every model of it, and is stored nowhere. Subclassing would have put fifteen irrelevant fields on a schema webv2 already consumes, which is the opposite of additive. It is not a computed field on `AnyModelConfig` either: that would add these fields to all 115 config schemas and risk them being persisted into records. And a variant gets its own row only where something differs — the five architectures whose recommended parameters depend on the variant. Qwen-Image's variant-conditional reference-image support stays a field on the base row (`reference_images_require_variant`), because materializing a row for it would mean inventing rules about which fields a variant row may omit. The route takes no auth dependency and touches no service: there is nothing user- or install-specific in the response. A test pins that, so a later version reaching for the invoker fails there rather than in production. Purely additive, as intended and as measured: one new path, four new schemas, zero existing schemas changed, zero removed, `ExternalModelCapabilities` byte-identical. `openapi.json` gains 231 lines and `schema.ts` 178, with no deletions in either. The endpoint is `/api/v2/models/capabilities`, not the `/api/v1/...` the plan named — the model manager router has been on `/v2/models` for some time. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/model_manager.py | 20 ++ invokeai/backend/architectures/__init__.py | 12 + .../backend/architectures/capabilities.py | 169 +++++++++++++ invokeai/frontend/web/openapi.json | 231 ++++++++++++++++++ .../frontend/web/src/services/api/schema.ts | 178 ++++++++++++++ .../routers/test_architecture_capabilities.py | 61 +++++ tests/backend/architectures/test_layering.py | 8 + 7 files changed, 679 insertions(+) create mode 100644 invokeai/backend/architectures/capabilities.py create mode 100644 tests/app/routers/test_architecture_capabilities.py diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 3fe863c2d30..7870595d5eb 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -33,6 +33,7 @@ from invokeai.app.services.orphaned_models import OrphanedModelInfo from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.util.suppress_output import SuppressOutput +from invokeai.backend.architectures import ArchitectureCapabilities, architecture_capabilities from invokeai.backend.model_manager.configs.external_api import ExternalApiModelConfig from invokeai.backend.model_manager.configs.factory import AnyModelConfig, ModelConfigFactory from invokeai.backend.model_manager.configs.main import ( @@ -157,6 +158,25 @@ def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type ############################################################################## +@model_manager_router.get( + "/capabilities", + operation_id="list_architecture_capabilities", + responses={200: {"description": "What each model architecture supports"}}, +) +async def list_architecture_capabilities() -> list[ArchitectureCapabilities]: + """What each model architecture can generate, and which generation features it supports. + + A static table, the same for every install and every user, derived from what the architectures + declare under `invokeai/backend/architectures/defs/`. Fetch it once and join it against model + records locally: look up `(base, variant)`, fall back to `(base, null)`. + + Deliberately not a field on the model records themselves — it is the same for every model of an + architecture, and putting it there would add these fields to all 115 config schemas. No auth + dependency for the same reason: there is nothing user- or install-specific in it. + """ + return architecture_capabilities() + + @model_manager_router.get( "/", operation_id="list_model_records", diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py index 409f921d0e7..6e5e1f11d79 100644 --- a/invokeai/backend/architectures/__init__.py +++ b/invokeai/backend/architectures/__init__.py @@ -6,6 +6,13 @@ from invokeai.backend.architectures import defs as defs # noqa: F401 (imported for side effects) from invokeai.backend.architectures import facets as facets # noqa: F401 (imported for side effects) +from invokeai.backend.architectures.capabilities import ( + ArchitectureCapabilities, + ArchitectureFeatures, + ArchitectureModality, + NegativePromptPolicy, + architecture_capabilities, +) from invokeai.backend.architectures.facet import Facet from invokeai.backend.architectures.facets.conditioning import ConditioningFacet, conditioning_infos from invokeai.backend.architectures.facets.default_settings import ( @@ -39,6 +46,11 @@ ) __all__ = [ + "ArchitectureCapabilities", + "ArchitectureFeatures", + "ArchitectureModality", + "NegativePromptPolicy", + "architecture_capabilities", "ArchitectureError", "ConditioningFacet", "DefaultSettingsFacet", diff --git a/invokeai/backend/architectures/capabilities.py b/invokeai/backend/architectures/capabilities.py new file mode 100644 index 00000000000..a10b65c2af3 --- /dev/null +++ b/invokeai/backend/architectures/capabilities.py @@ -0,0 +1,169 @@ +"""The architecture table, in the shape a client fetches it. + +One row per architecture, plus a row per variant that answers differently. A client fetches this +once and joins it against model records locally: look up `(base, variant)`, fall back to +`(base, None)`. + +This is not `ExternalModelCapabilities`, and deliberately not a subclass of it. That describes one +external provider's model — aspect ratios, resolution presets, mask format, per-request image +limits — and is stored on each such record. This describes an architecture, is the same for every +model of that architecture, and is not stored anywhere. Merging them would put fifteen irrelevant +fields on a schema webv2 already consumes. + +Also deliberately not a computed field on `AnyModelConfig`: that would add these fields to all 115 +config schemas and risk them being persisted into model records. +""" + +from pydantic import BaseModel, ConfigDict, Field + +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import ( + ControlKind, + FeaturesFacet, + NegativePromptUsage, + SchedulerSet, +) +from invokeai.backend.architectures.facets.latent_space import LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import GenerationModeKind, ModalityFacet +from invokeai.backend.architectures.registry import generative_bases, get +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import BaseModelType + + +class NegativePromptPolicy(BaseModel): + visible: bool = Field(description="Whether to show a negative prompt field at all.") + usage: NegativePromptUsage = Field( + description="'always', 'cfg-gated' (only above CFG 1), or 'never'.", + ) + + model_config = ConfigDict(extra="forbid") + + +class ArchitectureModality(BaseModel): + """What this architecture can produce, and what it calls it in image metadata.""" + + modes: list[GenerationModeKind] = Field(description="Sorted. Empty means it generates nothing on its own.") + metadata_slug: str | None = Field( + default=None, + description="Prefix its mode strings carry in image metadata; null means unprefixed.", + ) + + model_config = ConfigDict(extra="forbid") + + +class ArchitectureFeatures(BaseModel): + """What a UI may offer for this architecture.""" + + negative_prompt: NegativePromptPolicy + dimension_grid: int = Field(description="Width and height must be a multiple of this.") + spatial_compression: int = Field(description="How much smaller a latent is than the image, per side.") + guidance_label: str = Field(description="What to call the guidance slider: 'CFG' or 'Guidance'.") + scheduler_set: SchedulerSet | None = Field( + default=None, description="Which scheduler family to offer; null means no choice." + ) + scheduler_applies_to_graph: bool = False + control_kinds: list[ControlKind] = Field(default_factory=list, description="Sorted.") + max_reference_images: int = 0 + reference_images_require_variant: str | None = Field( + default=None, + description="If set, reference images are only accepted for models of this variant.", + ) + supports_regional_guidance: bool = False + regional_negative: bool = False + clip_skip_max: int | None = None + supports_seamless: bool = False + supports_cfg_rescale: bool = False + sd_vae_override: bool = False + color_compensation: bool = False + vae_precision: bool = False + + model_config = ConfigDict(extra="forbid") + + +class ArchitectureCapabilities(BaseModel): + """One row of the table.""" + + base: BaseModelType + variant: str | None = Field( + default=None, + description="Null for the architecture's own row. A variant row overrides it.", + ) + modality: ArchitectureModality + features: ArchitectureFeatures + defaults: MainModelDefaultSettings | None = Field( + default=None, description="Recommended generation parameters, if the architecture has any." + ) + + model_config = ConfigDict(extra="forbid") + + +def _features_of(facet: FeaturesFacet, spatial_compression: int) -> ArchitectureFeatures: + return ArchitectureFeatures( + negative_prompt=NegativePromptPolicy( + visible=facet.negative_prompt.visible, + usage=facet.negative_prompt.usage, + ), + dimension_grid=facet.dimension_grid, + spatial_compression=spatial_compression, + guidance_label=facet.guidance_label, + scheduler_set=facet.scheduler_set, + scheduler_applies_to_graph=facet.scheduler_applies_to_graph, + control_kinds=sorted(facet.control_kinds), + max_reference_images=facet.max_reference_images, + reference_images_require_variant=facet.reference_images_require_variant, + supports_regional_guidance=facet.supports_regional_guidance, + regional_negative=facet.regional_negative, + clip_skip_max=facet.clip_skip_max, + supports_seamless=facet.supports_seamless, + supports_cfg_rescale=facet.supports_cfg_rescale, + sd_vae_override=facet.sd_vae_override, + color_compensation=facet.color_compensation, + vae_precision=facet.vae_precision, + ) + + +def architecture_capabilities() -> list[ArchitectureCapabilities]: + """Every row, base rows first, then the variant rows that override them. + + A variant gets its own row only where something actually differs — today that is the five + architectures whose recommended parameters depend on the variant. Feature differences that hang + on a variant are expressed on the base row instead, by + `features.reference_images_require_variant`; Qwen-Image is the only one, and inventing a row for + it would mean inventing which fields a variant row is allowed to omit. + + Sorted by base value, then variant, so the response is stable and diffable. + """ + rows: list[ArchitectureCapabilities] = [] + for base in sorted(generative_bases(), key=lambda b: b.value): + modality = get(base, ModalityFacet) + features = get(base, FeaturesFacet) + latent_space = get(base, LatentSpaceFacet) + defaults = get(base, DefaultSettingsFacet) + # All four are REQUIRED, so `validate()` has already refused to start without them. + assert modality is not None and features is not None and latent_space is not None + assert defaults is not None + + rendered = ArchitectureModality(modes=sorted(modality.modes), metadata_slug=modality.metadata_slug) + rendered_features = _features_of(features, latent_space.primary.spatial_compression) + + rows.append( + ArchitectureCapabilities( + base=base, + modality=rendered, + features=rendered_features, + defaults=defaults.resolve(), + ) + ) + for variant in sorted(v for v in defaults.by_variant if v is not None): + rows.append( + ArchitectureCapabilities( + base=base, + # `.value`, not `str()`: these are `str`-mixin enums, and `str()` on one yields + # "FluxVariantType.DevFill" rather than the "dev_fill" a client stores and sends. + variant=variant.value, + modality=rendered, + features=rendered_features, + defaults=defaults.by_variant[variant], + ) + ) + return rows diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index e5f418d205b..a111b8f1d0e 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -604,6 +604,30 @@ ] } }, + "/api/v2/models/capabilities": { + "get": { + "tags": ["model_manager"], + "summary": "List Architecture Capabilities", + "description": "What each model architecture can generate, and which generation features it supports.\n\nA static table, the same for every install and every user, derived from what the architectures\ndeclare under `invokeai/backend/architectures/defs/`. Fetch it once and join it against model\nrecords locally: look up `(base, variant)`, fall back to `(base, null)`.\n\nDeliberately not a field on the model records themselves \u2014 it is the same for every model of an\narchitecture, and putting it there would add these fields to all 115 config schemas. No auth\ndependency for the same reason: there is nothing user- or install-specific in it.", + "operationId": "list_architecture_capabilities", + "responses": { + "200": { + "description": "What each model architecture supports", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ArchitectureCapabilities" + }, + "type": "array", + "title": "Response List Architecture Capabilities" + } + } + } + } + } + } + }, "/api/v2/models/": { "get": { "tags": ["model_manager"], @@ -16009,6 +16033,194 @@ "$ref": "#/components/schemas/ImageOutput" } }, + "ArchitectureCapabilities": { + "properties": { + "base": { + "$ref": "#/components/schemas/BaseModelType" + }, + "variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Variant", + "description": "Null for the architecture's own row. A variant row overrides it." + }, + "modality": { + "$ref": "#/components/schemas/ArchitectureModality" + }, + "features": { + "$ref": "#/components/schemas/ArchitectureFeatures" + }, + "defaults": { + "anyOf": [ + { + "$ref": "#/components/schemas/MainModelDefaultSettings" + }, + { + "type": "null" + } + ], + "description": "Recommended generation parameters, if the architecture has any." + } + }, + "additionalProperties": false, + "type": "object", + "required": ["base", "modality", "features"], + "title": "ArchitectureCapabilities", + "description": "One row of the table." + }, + "ArchitectureFeatures": { + "properties": { + "negative_prompt": { + "$ref": "#/components/schemas/NegativePromptPolicy" + }, + "dimension_grid": { + "type": "integer", + "title": "Dimension Grid", + "description": "Width and height must be a multiple of this." + }, + "spatial_compression": { + "type": "integer", + "title": "Spatial Compression", + "description": "How much smaller a latent is than the image, per side." + }, + "guidance_label": { + "type": "string", + "title": "Guidance Label", + "description": "What to call the guidance slider: 'CFG' or 'Guidance'." + }, + "scheduler_set": { + "anyOf": [ + { + "type": "string", + "enum": ["standard", "flow", "anima"] + }, + { + "type": "null" + } + ], + "title": "Scheduler Set", + "description": "Which scheduler family to offer; null means no choice." + }, + "scheduler_applies_to_graph": { + "type": "boolean", + "title": "Scheduler Applies To Graph", + "default": false + }, + "control_kinds": { + "items": { + "type": "string", + "enum": ["controlnet", "t2i_adapter", "control_lora", "z_image_control"] + }, + "type": "array", + "title": "Control Kinds", + "description": "Sorted." + }, + "max_reference_images": { + "type": "integer", + "title": "Max Reference Images", + "default": 0 + }, + "reference_images_require_variant": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Reference Images Require Variant", + "description": "If set, reference images are only accepted for models of this variant." + }, + "supports_regional_guidance": { + "type": "boolean", + "title": "Supports Regional Guidance", + "default": false + }, + "regional_negative": { + "type": "boolean", + "title": "Regional Negative", + "default": false + }, + "clip_skip_max": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Clip Skip Max" + }, + "supports_seamless": { + "type": "boolean", + "title": "Supports Seamless", + "default": false + }, + "supports_cfg_rescale": { + "type": "boolean", + "title": "Supports Cfg Rescale", + "default": false + }, + "sd_vae_override": { + "type": "boolean", + "title": "Sd Vae Override", + "default": false + }, + "color_compensation": { + "type": "boolean", + "title": "Color Compensation", + "default": false + }, + "vae_precision": { + "type": "boolean", + "title": "Vae Precision", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": ["negative_prompt", "dimension_grid", "spatial_compression", "guidance_label"], + "title": "ArchitectureFeatures", + "description": "What a UI may offer for this architecture." + }, + "ArchitectureModality": { + "properties": { + "modes": { + "items": { + "type": "string", + "enum": ["txt2img", "img2img", "inpaint", "outpaint", "t2v", "i2v"] + }, + "type": "array", + "title": "Modes", + "description": "Sorted. Empty means it generates nothing on its own." + }, + "metadata_slug": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Metadata Slug", + "description": "Prefix its mode strings carry in image metadata; null means unprefixed." + } + }, + "additionalProperties": false, + "type": "object", + "required": ["modes"], + "title": "ArchitectureModality", + "description": "What this architecture can produce, and what it calls it in image metadata." + }, "BaseMetadata": { "properties": { "name": { @@ -73450,6 +73662,25 @@ "$ref": "#/components/schemas/IntegerOutput" } }, + "NegativePromptPolicy": { + "properties": { + "visible": { + "type": "boolean", + "title": "Visible", + "description": "Whether to show a negative prompt field at all." + }, + "usage": { + "type": "string", + "enum": ["always", "cfg-gated", "never"], + "title": "Usage", + "description": "'always', 'cfg-gated' (only above CFG 1), or 'never'." + } + }, + "additionalProperties": false, + "type": "object", + "required": ["visible", "usage"], + "title": "NegativePromptPolicy" + }, "NodeFieldValue": { "properties": { "node_path": { diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index a7b094c67dd..aeb89c6b668 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -384,6 +384,34 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v2/models/capabilities": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Architecture Capabilities + * @description What each model architecture can generate, and which generation features it supports. + * + * A static table, the same for every install and every user, derived from what the architectures + * declare under `invokeai/backend/architectures/defs/`. Fetch it once and join it against model + * records locally: look up `(base, variant)`, fall back to `(base, null)`. + * + * Deliberately not a field on the model records themselves — it is the same for every model of an + * architecture, and putting it there would add these fields to all 115 config schemas. No auth + * dependency for the same reason: there is nothing user- or install-specific in it. + */ + get: operations["list_architecture_capabilities"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v2/models/": { parameters: { query?: never; @@ -4610,6 +4638,122 @@ export type components = { */ type: "apply_mask_to_image"; }; + /** + * ArchitectureCapabilities + * @description One row of the table. + */ + ArchitectureCapabilities: { + base: components["schemas"]["BaseModelType"]; + /** + * Variant + * @description Null for the architecture's own row. A variant row overrides it. + */ + variant?: string | null; + modality: components["schemas"]["ArchitectureModality"]; + features: components["schemas"]["ArchitectureFeatures"]; + /** @description Recommended generation parameters, if the architecture has any. */ + defaults?: components["schemas"]["MainModelDefaultSettings"] | null; + }; + /** + * ArchitectureFeatures + * @description What a UI may offer for this architecture. + */ + ArchitectureFeatures: { + negative_prompt: components["schemas"]["NegativePromptPolicy"]; + /** + * Dimension Grid + * @description Width and height must be a multiple of this. + */ + dimension_grid: number; + /** + * Spatial Compression + * @description How much smaller a latent is than the image, per side. + */ + spatial_compression: number; + /** + * Guidance Label + * @description What to call the guidance slider: 'CFG' or 'Guidance'. + */ + guidance_label: string; + /** + * Scheduler Set + * @description Which scheduler family to offer; null means no choice. + */ + scheduler_set?: ("standard" | "flow" | "anima") | null; + /** + * Scheduler Applies To Graph + * @default false + */ + scheduler_applies_to_graph?: boolean; + /** + * Control Kinds + * @description Sorted. + */ + control_kinds?: ("controlnet" | "t2i_adapter" | "control_lora" | "z_image_control")[]; + /** + * Max Reference Images + * @default 0 + */ + max_reference_images?: number; + /** + * Reference Images Require Variant + * @description If set, reference images are only accepted for models of this variant. + */ + reference_images_require_variant?: string | null; + /** + * Supports Regional Guidance + * @default false + */ + supports_regional_guidance?: boolean; + /** + * Regional Negative + * @default false + */ + regional_negative?: boolean; + /** Clip Skip Max */ + clip_skip_max?: number | null; + /** + * Supports Seamless + * @default false + */ + supports_seamless?: boolean; + /** + * Supports Cfg Rescale + * @default false + */ + supports_cfg_rescale?: boolean; + /** + * Sd Vae Override + * @default false + */ + sd_vae_override?: boolean; + /** + * Color Compensation + * @default false + */ + color_compensation?: boolean; + /** + * Vae Precision + * @default false + */ + vae_precision?: boolean; + }; + /** + * ArchitectureModality + * @description What this architecture can produce, and what it calls it in image metadata. + */ + ArchitectureModality: { + /** + * Modes + * @description Sorted. Empty means it generates nothing on its own. + */ + modes: ("txt2img" | "img2img" | "inpaint" | "outpaint" | "t2v" | "i2v")[]; + /** + * Metadata Slug + * @description Prefix its mode strings carry in image metadata; null means unprefixed. + */ + metadata_slug?: string | null; + }; /** * BaseMetadata * @description Adds typing data for discriminated union. @@ -30682,6 +30826,20 @@ export type components = { */ type: "mul"; }; + /** NegativePromptPolicy */ + NegativePromptPolicy: { + /** + * Visible + * @description Whether to show a negative prompt field at all. + */ + visible: boolean; + /** + * Usage + * @description 'always', 'cfg-gated' (only above CFG 1), or 'never'. + * @enum {string} + */ + usage: "always" | "cfg-gated" | "never"; + }; /** NodeFieldValue */ NodeFieldValue: { /** @@ -44509,6 +44667,26 @@ export interface operations { }; }; }; + list_architecture_capabilities: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description What each model architecture supports */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ArchitectureCapabilities"][]; + }; + }; + }; + }; list_model_records: { parameters: { query?: { diff --git a/tests/app/routers/test_architecture_capabilities.py b/tests/app/routers/test_architecture_capabilities.py new file mode 100644 index 00000000000..372f14f177f --- /dev/null +++ b/tests/app/routers/test_architecture_capabilities.py @@ -0,0 +1,61 @@ +"""GET /api/v2/models/capabilities — the static architecture table. + +It touches no service and no database: the rows come from what the architectures declare at import +time. That is what lets this test use a bare client, and it is also the property worth pinning — +a later version that reaches for `ApiDependencies.invoker` would fail here rather than in production. +""" + +from fastapi.testclient import TestClient + +from invokeai.app.api_app import app +from invokeai.backend.architectures import architecture_capabilities, generative_bases + +client = TestClient(app) +URL = "/api/v2/models/capabilities" + + +def test_it_serves_a_row_for_every_architecture() -> None: + response = client.get(URL) + assert response.status_code == 200 + + rows = response.json() + base_rows = [r for r in rows if r["variant"] is None] + assert {r["base"] for r in base_rows} == {b.value for b in generative_bases()} + assert len(base_rows) == len(generative_bases()), "one row per architecture, no duplicates" + + +def test_variant_rows_override_their_base_row() -> None: + """FLUX is the clearest case: three variants, three genuinely different answers.""" + rows = client.get(URL).json() + flux = {r["variant"]: r for r in rows if r["base"] == "flux"} + + assert set(flux) == {None, "schnell", "dev_fill"} + assert flux["schnell"]["defaults"]["steps"] == 4 + assert flux["dev_fill"]["defaults"]["guidance"] == 30.0 + assert flux[None]["defaults"]["steps"] == 28, "the base row is dev" + + +def test_the_variant_is_the_value_a_client_holds() -> None: + """Not `str(enum)`, which would serialize as `FluxVariantType.DevFill`.""" + variants = {r["variant"] for r in client.get(URL).json() if r["variant"] is not None} + assert all("." not in v for v in variants), variants + assert "dev_fill" in variants + + +def test_it_needs_no_services() -> None: + """No auth, no invoker, no database — the same table for every install and every user.""" + assert client.get(URL).status_code == 200 + + +def test_the_response_matches_what_the_registry_renders() -> None: + """The route is a pass-through; anything it added would be a second source of truth.""" + served = client.get(URL).json() + rendered = [row.model_dump(mode="json") for row in architecture_capabilities()] + assert served == rendered + + +def test_the_rows_are_ordered_stably() -> None: + """Sorted by base, so a client diffing two responses sees only real changes.""" + rows = client.get(URL).json() + bases = [r["base"] for r in rows] + assert bases == sorted(bases) diff --git a/tests/backend/architectures/test_layering.py b/tests/backend/architectures/test_layering.py index b9cfc95cad1..91efce8540e 100644 --- a/tests/backend/architectures/test_layering.py +++ b/tests/backend/architectures/test_layering.py @@ -53,6 +53,14 @@ def _allowed_for(path: str) -> tuple[str, frozenset[str], tuple[str, ...]] | Non return "facet-is-a-leaf", frozenset(), () if path == f"{ARCH_DIR}/registry.py": return "registry-is-a-leaf", frozenset({f"{ARCH}.facet", TAXONOMY}), () + if path == f"{ARCH_DIR}/capabilities.py": + # The one module that reads across facets: it renders the table the API serves. It may + # name every facet, but still not the aggregate — it is imported by it. + return ( + "capabilities-allowlist", + frozenset({f"{ARCH}.facet", f"{ARCH}.facets", f"{ARCH}.registry", TAXONOMY, DEFAULT_SETTINGS}), + (f"{ARCH}.facets.",), + ) if path == f"{ARCH_DIR}/__init__.py": return "aggregate-is-a-facade", frozenset({ARCH}), (f"{ARCH}.",) if path.startswith(f"{ARCH_DIR}/facets/"): From 9d78d28e72e14ec5ae40f225eb4673a433202f62 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 05:51:32 +0200 Subject: [PATCH 17/26] refactor(model_manager): split the starter catalogue into a package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `starter_models.py` had reached 2707 lines and 240 top-level definitions: 201 starter models across seventeen architectures, thirteen bundles, and a block of provider presets for external models. It becomes a package of nineteen modules, none over 500 lines, named the same way as `architectures/defs/` — the base value with `-` replaced by `_`. Every block moved verbatim, by AST line range including the comments above it, so nothing was retyped and nothing could be mistyped. Verified against a snapshot taken beforehand: all 201 models identical field for field *and in the same order*, all thirteen bundles identical. That order is the point. `STARTER_MODELS` is the sequence the install dialog shows, decided by someone, reconstructible from nothing — so it stays written out in `__init__.py` rather than assembled from the per-architecture modules. Assembling it would be tidier and would silently destroy product data. A test asserts the list is *not* sorted, which is the only way to notice a later tidy-up that replaces the curation with something derivable. The dependency graph was derived before the split rather than assumed, and it is a clean DAG: `types` under everything, `common` under most, and exactly three edges between architecture modules — `krea_2 -> qwen_image`, `z_image -> flux`, `sdxl_refiner -> sdxl`. Those are the same three sharings the architecture registry already records: Krea-2 decodes with the Qwen-Image VAE, Z-Image with a FLUX-compatible one, and the refiner is an SDXL pass. Two independent derivations agreeing is worth more than either alone. Every name is re-exported from `__init__.py`, so the nine names other modules import — including `clip_vit_l_image_encoder` and `siglip`, imported by individual nodes — keep resolving unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../backend/model_manager/starter_models.py | 2707 ----------------- .../model_manager/starter_models/__init__.py | 1051 +++++++ .../model_manager/starter_models/anima.py | 82 + .../model_manager/starter_models/cogview4.py | 16 + .../model_manager/starter_models/common.py | 335 ++ .../starter_models/ernie_image.py | 30 + .../model_manager/starter_models/external.py | 498 +++ .../model_manager/starter_models/flux.py | 186 ++ .../model_manager/starter_models/flux2.py | 223 ++ .../starter_models/ideogram_4.py | 30 + .../model_manager/starter_models/krea_2.py | 58 + .../starter_models/minimax_h3.py | 56 + .../starter_models/qwen_image.py | 176 ++ .../model_manager/starter_models/sd_1.py | 263 ++ .../model_manager/starter_models/sd_3.py | 53 + .../model_manager/starter_models/sdxl.py | 193 ++ .../starter_models/sdxl_refiner.py | 17 + .../model_manager/starter_models/types.py | 47 + .../model_manager/starter_models/wan.py | 218 ++ .../model_manager/starter_models/z_image.py | 64 + .../test_starter_models_package.py | 87 + 21 files changed, 3683 insertions(+), 2707 deletions(-) delete mode 100644 invokeai/backend/model_manager/starter_models.py create mode 100644 invokeai/backend/model_manager/starter_models/__init__.py create mode 100644 invokeai/backend/model_manager/starter_models/anima.py create mode 100644 invokeai/backend/model_manager/starter_models/cogview4.py create mode 100644 invokeai/backend/model_manager/starter_models/common.py create mode 100644 invokeai/backend/model_manager/starter_models/ernie_image.py create mode 100644 invokeai/backend/model_manager/starter_models/external.py create mode 100644 invokeai/backend/model_manager/starter_models/flux.py create mode 100644 invokeai/backend/model_manager/starter_models/flux2.py create mode 100644 invokeai/backend/model_manager/starter_models/ideogram_4.py create mode 100644 invokeai/backend/model_manager/starter_models/krea_2.py create mode 100644 invokeai/backend/model_manager/starter_models/minimax_h3.py create mode 100644 invokeai/backend/model_manager/starter_models/qwen_image.py create mode 100644 invokeai/backend/model_manager/starter_models/sd_1.py create mode 100644 invokeai/backend/model_manager/starter_models/sd_3.py create mode 100644 invokeai/backend/model_manager/starter_models/sdxl.py create mode 100644 invokeai/backend/model_manager/starter_models/sdxl_refiner.py create mode 100644 invokeai/backend/model_manager/starter_models/types.py create mode 100644 invokeai/backend/model_manager/starter_models/wan.py create mode 100644 invokeai/backend/model_manager/starter_models/z_image.py create mode 100644 tests/backend/model_manager/test_starter_models_package.py diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py deleted file mode 100644 index ebb25c2afe1..00000000000 --- a/invokeai/backend/model_manager/starter_models.py +++ /dev/null @@ -1,2707 +0,0 @@ -from typing import Optional - -from pydantic import BaseModel - -from invokeai.backend.model_manager.configs.external_api import ( - ExternalApiModelDefaultSettings, - ExternalImageSize, - ExternalModelCapabilities, - ExternalModelPanelSchema, - ExternalResolutionPreset, -) -from invokeai.backend.model_manager.taxonomy import ( - AnyVariant, - BaseModelType, - Krea2VariantType, - ModelFormat, - ModelType, - PiDDecoderVariantType, - QwenImageVariantType, - WanVariantType, -) - - -class StarterModelWithoutDependencies(BaseModel): - description: str - source: str - name: str - base: BaseModelType - type: ModelType - format: Optional[ModelFormat] = None - variant: Optional[AnyVariant] = None - is_installed: bool = False - capabilities: ExternalModelCapabilities | None = None - default_settings: ExternalApiModelDefaultSettings | None = None - panel_schema: ExternalModelPanelSchema | None = None - # allows us to track what models a user has installed across name changes within starter models - # if you update a starter model name, please add the old one to this list for that starter model - previous_names: list[str] = [] - - -class StarterModel(StarterModelWithoutDependencies): - # Optional list of model source dependencies that need to be installed before this model can be used - dependencies: Optional[list[StarterModelWithoutDependencies]] = None - - -class StarterModelBundle(BaseModel): - name: str - models: list[StarterModel] - - -cyberrealistic_negative = StarterModel( - name="CyberRealistic Negative v3", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/cyberdelia/CyberRealistic_Negative/resolve/main/CyberRealistic_Negative_v3.pt", - description="Negative embedding specifically for use with CyberRealistic.", - type=ModelType.TextualInversion, -) - -# region CLIP Image Encoders - -# This is CLIP-ViT-H-14-laion2B-s32B-b79K -ip_adapter_sd_image_encoder = StarterModel( - name="IP Adapter SD1.5 Image Encoder", - base=BaseModelType.Any, - source="InvokeAI/ip_adapter_sd_image_encoder", - description="IP Adapter SD Image Encoder", - type=ModelType.CLIPVision, -) - -# This is CLIP-ViT-bigG-14-laion2B-39B-b160k -ip_adapter_sdxl_image_encoder = StarterModel( - name="IP Adapter SDXL Image Encoder", - base=BaseModelType.Any, - source="InvokeAI/ip_adapter_sdxl_image_encoder", - description="IP Adapter SDXL Image Encoder", - type=ModelType.CLIPVision, -) -# Note: This model is installed from the same source as the CLIPEmbed model below. The model contains both the image -# encoder and the text encoder, but we need separate model entries so that they get loaded correctly. -clip_vit_l_image_encoder = StarterModel( - name="clip-vit-large-patch14", - base=BaseModelType.Any, - source="InvokeAI/clip-vit-large-patch14", - description="CLIP VIT-L Image Encoder (used by the imagemap index) ~1.7GB", - type=ModelType.CLIPVision, -) -# endregion - -# region TextEncoders -t5_base_encoder = StarterModel( - name="t5_base_encoder", - base=BaseModelType.Any, - source="InvokeAI/t5-v1_1-xxl::bfloat16", - description="T5-XXL text encoder (used in FLUX pipelines). ~9.5GB", - type=ModelType.T5Encoder, -) - -t5_8b_quantized_encoder = StarterModel( - name="t5_bnb_int8_quantized_encoder", - base=BaseModelType.Any, - source="InvokeAI/t5-v1_1-xxl::bnb_llm_int8", - description="T5-XXL text encoder with bitsandbytes LLM.int8() quantization (used in FLUX pipelines). ~5GB", - type=ModelType.T5Encoder, - format=ModelFormat.BnbQuantizedLlmInt8b, -) - -t5_gguf_q3_k_s_encoder = StarterModel( - name="t5_gguf_q3_k_s_encoder", - base=BaseModelType.Any, - source="https://huggingface.co/city96/t5-v1_1-xxl-encoder-gguf/resolve/main/t5-v1_1-xxl-encoder-Q3_K_S.gguf", - description="T5-XXL text encoder, GGUF Q3_K_S quantized (used in FLUX pipelines). Smallest size for low VRAM, lower quality. ~2.1GB", - type=ModelType.T5Encoder, - format=ModelFormat.GGUFQuantized, -) - -t5_gguf_q6_k_encoder = StarterModel( - name="t5_gguf_q6_k_encoder", - base=BaseModelType.Any, - source="https://huggingface.co/city96/t5-v1_1-xxl-encoder-gguf/resolve/main/t5-v1_1-xxl-encoder-Q6_K.gguf", - description="T5-XXL text encoder, GGUF Q6_K quantized (used in FLUX pipelines). Near-lossless quality. ~3.9GB", - type=ModelType.T5Encoder, - format=ModelFormat.GGUFQuantized, -) - -clip_l_encoder = StarterModel( - name="clip-vit-large-patch14", - base=BaseModelType.Any, - source="InvokeAI/clip-vit-large-patch14-text-encoder::bfloat16", - description="CLIP-L text encoder (used in FLUX pipelines). ~250MB", - type=ModelType.CLIPEmbed, -) -# endregion - -# region VAE -sdxl_fp16_vae_fix = StarterModel( - name="sdxl-vae-fp16-fix", - base=BaseModelType.StableDiffusionXL, - source="madebyollin/sdxl-vae-fp16-fix", - description="SDXL VAE that works with FP16.", - type=ModelType.VAE, -) -flux_vae = StarterModel( - name="FLUX.1-schnell_ae", - base=BaseModelType.Flux, - source="black-forest-labs/FLUX.1-schnell::ae.safetensors", - description="FLUX VAE compatible with both schnell and dev variants.", - type=ModelType.VAE, -) -# endregion - - -# region PiD (Pixel Diffusion Decoder) -# PiD's pretrained decoders condition on Gemma-2-2b-it caption embeddings (2304-dim). NVIDIA references the ungated -# mirror Efficient-Large-Model/gemma-2-2b-it. It is shared across all PiD backbones, so it is a dependency of each -# decoder below (and offered standalone here so it can be installed once). -gemma2_2b_encoder = StarterModel( - name="Gemma 2 2B (PiD caption encoder)", - base=BaseModelType.Any, - source="Efficient-Large-Model/gemma-2-2b-it", - description="Gemma-2-2b-it text encoder that PiD uses to condition its diffusion decode on a caption. ~5GB", - type=ModelType.Gemma2Encoder, - format=ModelFormat.Gemma2Encoder, -) - -# NVIDIA PiD decoders (https://huggingface.co/nvidia/PiD). Code is Apache-2.0; weights are NSCLv1 (non-commercial / -# research). Each is a 4x super-resolution decoder that replaces the regular VAE decode and needs the Gemma-2 encoder. -pid_decoder_flux_2k = StarterModel( - name="PiD Decoder FLUX (2K)", - base=BaseModelType.Flux, - source="nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_flux_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for FLUX latents, 2K target preset (e.g. 512 -> 2048). ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -pid_decoder_flux_2kto4k = StarterModel( - name="PiD Decoder FLUX (2K to 4K)", - base=BaseModelType.Flux, - source="nvidia/PiD::checkpoints_deprecated/PiD_res2kto4k_sr4x_official_flux_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for FLUX latents, 2K-to-4K preset (legacy architecture; NVIDIA's newer v1.5 checkpoint uses a different network that is not yet supported). ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -# FLUX.2 Klein shares one 32-channel VAE across the 4B and 9B variants, so a single decoder per preset covers both. -# The 128-channel packed latent is unambiguous (unlike the 16ch FLUX/SD3 case), so no directory-name disambiguation -# is needed for the config probe. -pid_decoder_flux2_2k = StarterModel( - name="PiD Decoder FLUX.2 (2K)", - base=BaseModelType.Flux2, - source="nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_flux2_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for FLUX.2 Klein latents, 2K target preset (e.g. 512 -> 2048). ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -pid_decoder_flux2_2kto4k = StarterModel( - name="PiD Decoder FLUX.2 (2K to 4K)", - base=BaseModelType.Flux2, - source="nvidia/PiD::checkpoints_deprecated/PiD_res2kto4k_sr4x_official_flux2_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for FLUX.2 Klein latents, 2K-to-4K preset (legacy architecture; NVIDIA's newer v1.5 checkpoint uses a different network that is not yet supported). ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -# SD3 uses a 16-channel latent, architecturally identical to FLUX.1. The config probe disambiguates via the -# checkpoint's directory name (`…official_sd3_distill…`); if the HF single-file download drops that name, the -# explicit base=StableDiffusion3 override the installer sends is trusted instead (see pid_decoder.py::_validate_base). -pid_decoder_sd3_2k = StarterModel( - name="PiD Decoder SD3 (2K)", - base=BaseModelType.StableDiffusion3, - source="nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for SD3 latents, 2K target preset (e.g. 512 -> 2048). ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -pid_decoder_sd3_2kto4k = StarterModel( - name="PiD Decoder SD3 (2K to 4K)", - base=BaseModelType.StableDiffusion3, - source="nvidia/PiD::checkpoints/PiD_res2kto4k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for SD3 latents, 2K-to-4K preset for higher-resolution output. ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -# SDXL uses a 4-channel latent, which is unambiguous (no FLUX/SD3-style directory-name disambiguation needed). -# NVIDIA ships only the 2K-to-4K preset for SDXL (no plain 2K checkpoint). -pid_decoder_sdxl_2kto4k = StarterModel( - name="PiD Decoder SDXL (2K to 4K)", - base=BaseModelType.StableDiffusionXL, - source="nvidia/PiD::checkpoints/PiD_res2kto4k_sr4x_official_sdxl_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for SDXL latents, 2K-to-4K preset. ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -# Qwen-Image uses a 16-channel latent (ambiguous with FLUX/SD3). The config probe disambiguates via the checkpoint's -# directory name (`…official_qwenimage_distill…`); if the HF single-file download drops it, the explicit -# base=QwenImage override the installer sends is trusted instead (see pid_decoder.py::_validate_base). Only the -# 2K-to-4K preset exists. -pid_decoder_qwenimage_2kto4k = StarterModel( - name="PiD Decoder Qwen-Image (2K to 4K)", - base=BaseModelType.QwenImage, - source="nvidia/PiD::checkpoints_deprecated/PiD_res2kto4k_sr4x_official_qwenimage_distill_4step/model_ema_bf16.pth", - description="NVIDIA PiD 4x super-resolution decoder for Qwen-Image latents, 2K-to-4K preset (legacy architecture; NVIDIA's newer v1.5 checkpoint uses a different network that is not yet supported). ~5GB", - type=ModelType.PiDDecoder, - format=ModelFormat.Checkpoint, - variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, - dependencies=[gemma2_2b_encoder], -) -# endregion - - -# region: Main -flux_schnell_quantized = StarterModel( - name="FLUX.1 schnell (quantized)", - base=BaseModelType.Flux, - source="InvokeAI/flux_schnell::transformer/bnb_nf4/flux1-schnell-bnb_nf4.safetensors", - description="FLUX schnell transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB", - type=ModelType.Main, - dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], -) -flux_dev_quantized = StarterModel( - name="FLUX.1 dev (quantized)", - base=BaseModelType.Flux, - source="InvokeAI/flux_dev::transformer/bnb_nf4/flux1-dev-bnb_nf4.safetensors", - description="FLUX dev transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB", - type=ModelType.Main, - dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], -) -flux_schnell = StarterModel( - name="FLUX.1 schnell", - base=BaseModelType.Flux, - source="InvokeAI/flux_schnell::transformer/base/flux1-schnell.safetensors", - description="FLUX schnell transformer in bfloat16. Total size with dependencies: ~33GB", - type=ModelType.Main, - dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], -) -flux_dev = StarterModel( - name="FLUX.1 dev", - base=BaseModelType.Flux, - source="InvokeAI/flux_dev::transformer/base/flux1-dev.safetensors", - description="FLUX dev transformer in bfloat16. Total size with dependencies: ~33GB", - type=ModelType.Main, - dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], -) -flux_schnell_sdnq = StarterModel( - name="FLUX.1 schnell (SDNQ uint4 + SVD)", - base=BaseModelType.Flux, - source="Disty0/FLUX.1-schnell-SDNQ-uint4-svd-r32", - description="FLUX.1 schnell quantized via SDNQ to uint4 + SVD rank 32. Full self-contained " - "Flux pipeline (transformer + T5 + CLIP + VAE). ~15GB", - type=ModelType.Main, - format=ModelFormat.SDNQQuantized, -) -flux_kontext = StarterModel( - name="FLUX.1 Kontext dev", - base=BaseModelType.Flux, - source="https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev/resolve/main/flux1-kontext-dev.safetensors", - description="FLUX.1 Kontext dev transformer in bfloat16. Total size with dependencies: ~33GB", - type=ModelType.Main, - dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], -) -flux_kontext_quantized = StarterModel( - name="FLUX.1 Kontext dev (quantized)", - base=BaseModelType.Flux, - source="https://huggingface.co/unsloth/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_M.gguf", - description="FLUX.1 Kontext dev quantized (q4_k_m). Total size with dependencies: ~12GB", - type=ModelType.Main, - dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], -) -flux_krea = StarterModel( - name="FLUX.1 Krea dev", - base=BaseModelType.Flux, - source="https://huggingface.co/InvokeAI/FLUX.1-Krea-dev/resolve/main/flux1-krea-dev.safetensors", - description="FLUX.1 Krea dev. Total size with dependencies: ~29GB", - type=ModelType.Main, - dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], -) -flux_krea_quantized = StarterModel( - name="FLUX.1 Krea dev (quantized)", - base=BaseModelType.Flux, - source="https://huggingface.co/InvokeAI/FLUX.1-Krea-dev-GGUF/resolve/main/flux1-krea-dev-Q4_K_M.gguf", - description="FLUX.1 Krea dev quantized (q4_k_m). Total size with dependencies: ~12GB", - type=ModelType.Main, - dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], -) -sd35_medium = StarterModel( - name="SD3.5 Medium", - base=BaseModelType.StableDiffusion3, - source="stabilityai/stable-diffusion-3.5-medium", - description="Medium SD3.5 Model: ~16GB", - type=ModelType.Main, - dependencies=[], -) -sd35_large = StarterModel( - name="SD3.5 Large", - base=BaseModelType.StableDiffusion3, - source="stabilityai/stable-diffusion-3.5-large", - description="Large SD3.5 Model: ~28GB", - type=ModelType.Main, - dependencies=[], -) -cyberrealistic_sd1 = StarterModel( - name="CyberRealistic v4.1", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V4.1_FP16.safetensors", - description="Photorealistic model. See other variants in HF repo 'cyberdelia/CyberRealistic'.", - type=ModelType.Main, - dependencies=[cyberrealistic_negative], -) -rev_animated_sd1 = StarterModel( - name="ReV Animated", - base=BaseModelType.StableDiffusion1, - source="stablediffusionapi/rev-animated", - description="Fantasy and anime style images.", - type=ModelType.Main, -) -dreamshaper_8_sd1 = StarterModel( - name="Dreamshaper 8", - base=BaseModelType.StableDiffusion1, - source="Lykon/dreamshaper-8", - description="Popular versatile model.", - type=ModelType.Main, -) -dreamshaper_8_inpainting_sd1 = StarterModel( - name="Dreamshaper 8 (inpainting)", - base=BaseModelType.StableDiffusion1, - source="Lykon/dreamshaper-8-inpainting", - description="Inpainting version of Dreamshaper 8.", - type=ModelType.Main, -) -deliberate_sd1 = StarterModel( - name="Deliberate v5", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v5.safetensors", - description="Popular versatile model", - type=ModelType.Main, -) -deliberate_inpainting_sd1 = StarterModel( - name="Deliberate v5 (inpainting)", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v5-inpainting.safetensors", - description="Inpainting version of Deliberate v5.", - type=ModelType.Main, -) -juggernaut_sdxl = StarterModel( - name="Juggernaut XL v9", - base=BaseModelType.StableDiffusionXL, - source="RunDiffusion/Juggernaut-XL-v9", - description="Photograph-focused model.", - type=ModelType.Main, - dependencies=[sdxl_fp16_vae_fix], -) -dreamshaper_sdxl = StarterModel( - name="Dreamshaper XL v2 Turbo", - base=BaseModelType.StableDiffusionXL, - source="Lykon/dreamshaper-xl-v2-turbo", - description="For turbo, use CFG Scale 2, 4-8 steps, DPM++ SDE Karras. For non-turbo, use CFG Scale 6, 20-40 steps, DPM++ 2M SDE Karras.", - type=ModelType.Main, - dependencies=[sdxl_fp16_vae_fix], -) - -archvis_sdxl = StarterModel( - name="Architecture (RealVisXL5)", - base=BaseModelType.StableDiffusionXL, - source="SG161222/RealVisXL_V5.0", - description="A photorealistic model, with architecture among its many use cases", - type=ModelType.Main, - dependencies=[sdxl_fp16_vae_fix], -) - -sdxl_refiner = StarterModel( - name="SDXL Refiner", - base=BaseModelType.StableDiffusionXLRefiner, - source="stabilityai/stable-diffusion-xl-refiner-1.0", - description="The OG Stable Diffusion XL refiner model.", - type=ModelType.Main, - dependencies=[sdxl_fp16_vae_fix], -) -# endregion - -# region LoRA -alien_lora_sdxl = StarterModel( - name="Alien Style", - base=BaseModelType.StableDiffusionXL, - source="https://huggingface.co/RalFinger/alien-style-lora-sdxl/resolve/main/alienzkin-sdxl.safetensors", - description="Futuristic, intricate alien styles. Trigger with 'alienzkin'.", - type=ModelType.LoRA, -) -noodle_lora_sdxl = StarterModel( - name="Noodles Style", - base=BaseModelType.StableDiffusionXL, - source="https://huggingface.co/RalFinger/noodles-lora-sdxl/resolve/main/noodlez-sdxl.safetensors", - description="Never-ending, no-holds-barred, noodle nightmare. Trigger with 'noodlez'.", - type=ModelType.LoRA, -) -# endregion -# region TI -easy_neg_sd1 = StarterModel( - name="EasyNegative", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/embed/EasyNegative/resolve/main/EasyNegative.safetensors", - description="A textual inversion to use in the negative prompt to reduce bad anatomy", - type=ModelType.TextualInversion, -) -# endregion -# region IP Adapter -ip_adapter_sd1 = StarterModel( - name="Standard Reference (IP Adapter)", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/InvokeAI/ip_adapter_sd15/resolve/main/ip-adapter_sd15.safetensors", - description="References images with a more generalized/looser degree of precision.", - type=ModelType.IPAdapter, - dependencies=[ip_adapter_sd_image_encoder], - previous_names=["IP Adapter"], -) -ip_adapter_plus_sd1 = StarterModel( - name="Precise Reference (IP Adapter Plus)", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/InvokeAI/ip_adapter_plus_sd15/resolve/main/ip-adapter-plus_sd15.safetensors", - description="References images with a higher degree of precision.", - type=ModelType.IPAdapter, - dependencies=[ip_adapter_sd_image_encoder], - previous_names=["IP Adapter Plus"], -) -ip_adapter_plus_face_sd1 = StarterModel( - name="Face Reference (IP Adapter Plus Face)", - base=BaseModelType.StableDiffusion1, - source="https://huggingface.co/InvokeAI/ip_adapter_plus_face_sd15/resolve/main/ip-adapter-plus-face_sd15.safetensors", - description="References images with a higher degree of precision, adapted for faces", - type=ModelType.IPAdapter, - dependencies=[ip_adapter_sd_image_encoder], - previous_names=["IP Adapter Plus Face"], -) -ip_adapter_sdxl = StarterModel( - name="Standard Reference (IP Adapter ViT-H)", - base=BaseModelType.StableDiffusionXL, - source="https://huggingface.co/InvokeAI/ip_adapter_sdxl_vit_h/resolve/main/ip-adapter_sdxl_vit-h.safetensors", - description="References images with a higher degree of precision.", - type=ModelType.IPAdapter, - dependencies=[ip_adapter_sdxl_image_encoder], - previous_names=["IP Adapter SDXL"], -) -ip_adapter_plus_sdxl = StarterModel( - name="Precise Reference (IP Adapter Plus ViT-H)", - base=BaseModelType.StableDiffusionXL, - source="https://huggingface.co/InvokeAI/ip-adapter-plus_sdxl_vit-h/resolve/main/ip-adapter-plus_sdxl_vit-h.safetensors", - description="References images with a higher degree of precision.", - type=ModelType.IPAdapter, - dependencies=[ip_adapter_sdxl_image_encoder], - previous_names=["IP Adapter Plus SDXL"], -) -ip_adapter_flux = StarterModel( - name="Standard Reference (XLabs FLUX IP-Adapter v2)", - base=BaseModelType.Flux, - source="https://huggingface.co/XLabs-AI/flux-ip-adapter-v2/resolve/main/ip_adapter.safetensors", - description="References images with a more generalized/looser degree of precision.", - type=ModelType.IPAdapter, - dependencies=[clip_vit_l_image_encoder], -) -# endregion -# region ControlNet -qr_code_cnet_sd1 = StarterModel( - name="QRCode Monster v2 (SD1.5)", - base=BaseModelType.StableDiffusion1, - source="monster-labs/control_v1p_sd15_qrcode_monster::v2", - description="ControlNet model that generates scannable creative QR codes", - type=ModelType.ControlNet, -) -qr_code_cnet_sdxl = StarterModel( - name="QRCode Monster (SDXL)", - base=BaseModelType.StableDiffusionXL, - source="monster-labs/control_v1p_sdxl_qrcode_monster", - description="ControlNet model that generates scannable creative QR codes", - type=ModelType.ControlNet, -) -canny_sd1 = StarterModel( - name="Hard Edge Detection (canny)", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_canny", - description="Uses detected edges in the image to control composition.", - type=ModelType.ControlNet, - previous_names=["canny"], -) -inpaint_cnet_sd1 = StarterModel( - name="Inpainting", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_inpaint", - description="ControlNet weights trained on sd-1.5 with canny conditioning, inpaint version", - type=ModelType.ControlNet, - previous_names=["inpaint"], -) -mlsd_sd1 = StarterModel( - name="Line Drawing (mlsd)", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_mlsd", - description="Uses straight line detection for controlling the generation.", - type=ModelType.ControlNet, - previous_names=["mlsd"], -) -depth_sd1 = StarterModel( - name="Depth Map", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11f1p_sd15_depth", - description="Uses depth information in the image to control the depth in the generation.", - type=ModelType.ControlNet, - previous_names=["depth"], -) -normal_bae_sd1 = StarterModel( - name="Lighting Detection (Normals)", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_normalbae", - description="Uses detected lighting information to guide the lighting of the composition.", - type=ModelType.ControlNet, - previous_names=["normal_bae"], -) -seg_sd1 = StarterModel( - name="Segmentation Map", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_seg", - description="Uses segmentation maps to guide the structure of the composition.", - type=ModelType.ControlNet, - previous_names=["seg"], -) -lineart_sd1 = StarterModel( - name="Lineart", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_lineart", - description="Uses lineart detection to guide the lighting of the composition.", - type=ModelType.ControlNet, - previous_names=["lineart"], -) -lineart_anime_sd1 = StarterModel( - name="Lineart Anime", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15s2_lineart_anime", - description="Uses anime lineart detection to guide the lighting of the composition.", - type=ModelType.ControlNet, - previous_names=["lineart_anime"], -) -openpose_sd1 = StarterModel( - name="Pose Detection (openpose)", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_openpose", - description="Uses pose information to control the pose of human characters in the generation.", - type=ModelType.ControlNet, - previous_names=["openpose"], -) -scribble_sd1 = StarterModel( - name="Contour Detection (scribble)", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_scribble", - description="Uses edges, contours, or line art in the image to control composition.", - type=ModelType.ControlNet, - previous_names=["scribble"], -) -softedge_sd1 = StarterModel( - name="Soft Edge Detection (softedge)", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11p_sd15_softedge", - description="Uses a soft edge detection map to control composition.", - type=ModelType.ControlNet, - previous_names=["softedge"], -) -shuffle_sd1 = StarterModel( - name="Remix (shuffle)", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11e_sd15_shuffle", - description="ControlNet weights trained on sd-1.5 with shuffle image conditioning", - type=ModelType.ControlNet, - previous_names=["shuffle"], -) -tile_sd1 = StarterModel( - name="Tile", - base=BaseModelType.StableDiffusion1, - source="lllyasviel/control_v11f1e_sd15_tile", - description="Uses image data to replicate exact colors/structure in the resulting generation.", - type=ModelType.ControlNet, - previous_names=["tile"], -) -canny_sdxl = StarterModel( - name="Hard Edge Detection (canny)", - base=BaseModelType.StableDiffusionXL, - source="xinsir/controlNet-canny-sdxl-1.0", - description="Uses detected edges in the image to control composition.", - type=ModelType.ControlNet, - previous_names=["canny-sdxl"], -) -depth_sdxl = StarterModel( - name="Depth Map", - base=BaseModelType.StableDiffusionXL, - source="diffusers/controlNet-depth-sdxl-1.0", - description="Uses depth information in the image to control the depth in the generation.", - type=ModelType.ControlNet, - previous_names=["depth-sdxl"], -) -softedge_sdxl = StarterModel( - name="Soft Edge Detection (softedge)", - base=BaseModelType.StableDiffusionXL, - source="SargeZT/controlNet-sd-xl-1.0-softedge-dexined", - description="Uses a soft edge detection map to control composition.", - type=ModelType.ControlNet, - previous_names=["softedge-dexined-sdxl"], -) -openpose_sdxl = StarterModel( - name="Pose Detection (openpose)", - base=BaseModelType.StableDiffusionXL, - source="xinsir/controlNet-openpose-sdxl-1.0", - description="Uses pose information to control the pose of human characters in the generation.", - type=ModelType.ControlNet, - previous_names=["openpose-sdxl", "controlnet-openpose-sdxl"], -) -scribble_sdxl = StarterModel( - name="Contour Detection (scribble)", - base=BaseModelType.StableDiffusionXL, - source="xinsir/controlNet-scribble-sdxl-1.0", - description="Uses edges, contours, or line art in the image to control composition.", - type=ModelType.ControlNet, - previous_names=["scribble-sdxl", "controlnet-scribble-sdxl"], -) -tile_sdxl = StarterModel( - name="Tile", - base=BaseModelType.StableDiffusionXL, - source="xinsir/controlNet-tile-sdxl-1.0", - description="Uses image data to replicate exact colors/structure in the resulting generation.", - type=ModelType.ControlNet, - previous_names=["tile-sdxl"], -) -union_cnet_sdxl = StarterModel( - name="Multi-Guidance Detection (Union Pro)", - base=BaseModelType.StableDiffusionXL, - source="InvokeAI/Xinsir-SDXL_Controlnet_Union", - description="A unified ControlNet for SDXL model that supports 10+ control types", - type=ModelType.ControlNet, -) -union_cnet_flux = StarterModel( - name="FLUX.1-dev-Controlnet-Union", - base=BaseModelType.Flux, - source="InstantX/FLUX.1-dev-Controlnet-Union", - description="A unified ControlNet for FLUX.1-dev model that supports 7 control modes, including canny (0), tile (1), depth (2), blur (3), pose (4), gray (5), low quality (6)", - type=ModelType.ControlNet, -) -# endregion -# region Control LoRA -flux_canny_control_lora = StarterModel( - name="Hard Edge Detection (Canny)", - base=BaseModelType.Flux, - source="black-forest-labs/FLUX.1-Canny-dev-lora::flux1-canny-dev-lora.safetensors", - description="Uses detected edges in the image to control composition.", - type=ModelType.ControlLoRa, -) -flux_depth_control_lora = StarterModel( - name="Depth Map", - base=BaseModelType.Flux, - source="black-forest-labs/FLUX.1-Depth-dev-lora::flux1-depth-dev-lora.safetensors", - description="Uses depth information in the image to control the depth in the generation.", - type=ModelType.ControlLoRa, -) -# endregion -# region T2I Adapter -t2i_canny_sd1 = StarterModel( - name="Hard Edge Detection (canny)", - base=BaseModelType.StableDiffusion1, - source="TencentARC/t2iadapter_canny_sd15v2", - description="Uses detected edges in the image to control composition", - type=ModelType.T2IAdapter, - previous_names=["canny-sd15"], -) -t2i_sketch_sd1 = StarterModel( - name="Sketch", - base=BaseModelType.StableDiffusion1, - source="TencentARC/t2iadapter_sketch_sd15v2", - description="Uses a sketch to control composition", - type=ModelType.T2IAdapter, - previous_names=["sketch-sd15"], -) -t2i_depth_sd1 = StarterModel( - name="Depth Map", - base=BaseModelType.StableDiffusion1, - source="TencentARC/t2iadapter_depth_sd15v2", - description="Uses depth information in the image to control the depth in the generation.", - type=ModelType.T2IAdapter, - previous_names=["depth-sd15"], -) -t2i_canny_sdxl = StarterModel( - name="Hard Edge Detection (canny)", - base=BaseModelType.StableDiffusionXL, - source="TencentARC/t2i-adapter-canny-sdxl-1.0", - description="Uses detected edges in the image to control composition", - type=ModelType.T2IAdapter, - previous_names=["canny-sdxl"], -) -t2i_lineart_sdxl = StarterModel( - name="Lineart", - base=BaseModelType.StableDiffusionXL, - source="TencentARC/t2i-adapter-lineart-sdxl-1.0", - description="Uses lineart detection to guide the lighting of the composition.", - type=ModelType.T2IAdapter, - previous_names=["lineart-sdxl"], -) -t2i_sketch_sdxl = StarterModel( - name="Sketch", - base=BaseModelType.StableDiffusionXL, - source="TencentARC/t2i-adapter-sketch-sdxl-1.0", - description="Uses a sketch to control composition", - type=ModelType.T2IAdapter, - previous_names=["sketch-sdxl"], -) -# endregion -# region SpandrelImageToImage -animesharp_v4_rcan = StarterModel( - name="2x-AnimeSharpV4_RCAN", - base=BaseModelType.Any, - source="https://github.com/Kim2091/Kim2091-Models/releases/download/2x-AnimeSharpV4/2x-AnimeSharpV4_RCAN.safetensors", - description="A 2x upscaling model (optimized for anime images).", - type=ModelType.SpandrelImageToImage, -) - -realesrgan_x4 = StarterModel( - name="RealESRGAN_x4plus", - base=BaseModelType.Any, - source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth", - description="A Real-ESRGAN 4x upscaling model (general-purpose).", - type=ModelType.SpandrelImageToImage, -) -esrgan_srx4 = StarterModel( - name="ESRGAN_SRx4_DF2KOST_official", - base=BaseModelType.Any, - source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.1/ESRGAN_SRx4_DF2KOST_official-ff704c30.pth", - description="The official ESRGAN 4x upscaling model.", - type=ModelType.SpandrelImageToImage, -) -realesrgan_x2 = StarterModel( - name="RealESRGAN_x2plus", - base=BaseModelType.Any, - source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth", - description="A Real-ESRGAN 2x upscaling model (general-purpose).", - type=ModelType.SpandrelImageToImage, -) -swinir = StarterModel( - name="SwinIR - realSR_BSRGAN_DFOWMFC_s64w8_SwinIR-L_x4_GAN", - base=BaseModelType.Any, - source="https://github.com/JingyunLiang/SwinIR/releases/download/v0.0/003_realSR_BSRGAN_DFOWMFC_s64w8_SwinIR-L_x4_GAN-with-dict-keys-params-and-params_ema.pth", - description="A SwinIR 4x upscaling model.", - type=ModelType.SpandrelImageToImage, -) - -# endregion - -# region CogView4 -cogview4 = StarterModel( - name="CogView4", - base=BaseModelType.CogView4, - source="THUDM/CogView4-6B", - description="The base CogView4 model (~31GB).", - type=ModelType.Main, -) -# endregion - -# region Qwen Image components (shared between Edit and txt2img variants) -qwen_image_vae = StarterModel( - name="Qwen Image VAE", - base=BaseModelType.QwenImage, - source="Qwen/Qwen-Image-Edit-2511::vae/diffusion_pytorch_model.safetensors", - description="Qwen Image VAE (AutoencoderKLQwenImage), shared between the Edit and txt2img variants. " - "Use with GGUF transformers to avoid downloading the full ~40GB Diffusers pipeline. (~250MB)", - type=ModelType.VAE, - format=ModelFormat.Checkpoint, -) - -qwen_vl_encoder_fp8 = StarterModel( - name="Qwen2.5-VL Encoder (fp8 scaled)", - base=BaseModelType.Any, - source="https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors", - description="ComfyUI's single-file FP8-scaled Qwen2.5-VL 7B encoder. Bundles the language model and " - "visual tower; tokenizer/processor are fetched from HuggingFace on first use. (~7GB)", - type=ModelType.QwenVLEncoder, - format=ModelFormat.Checkpoint, -) - -qwen_vl_encoder_diffusers = StarterModel( - name="Qwen2.5-VL Encoder (Diffusers)", - base=BaseModelType.Any, - source="Qwen/Qwen-Image-Edit-2511::text_encoder+tokenizer+processor", - description="Full-precision Qwen2.5-VL 7B encoder in Diffusers folder layout (text_encoder + tokenizer + processor). " - "Larger than the fp8 variant but no on-the-fly dequantization. (~16GB)", - type=ModelType.QwenVLEncoder, - format=ModelFormat.QwenVLEncoder, -) -# endregion - -# region Qwen Image Edit -qwen_image_edit = StarterModel( - name="Qwen Image Edit 2511", - base=BaseModelType.QwenImage, - source="Qwen/Qwen-Image-Edit-2511", - description="Qwen Image Edit 2511 full diffusers model. Supports text-guided image editing with multiple reference images. (~40GB)", - type=ModelType.Main, - variant=QwenImageVariantType.Edit, -) - -qwen_image_edit_gguf_q4_k_m = StarterModel( - name="Qwen Image Edit 2511 (Q4_K_M)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q4_K_M.gguf", - description="Qwen Image Edit 2511 - Q4_K_M quantized transformer. Good quality/size balance. (~13GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=QwenImageVariantType.Edit, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_edit_gguf_q2_k = StarterModel( - name="Qwen Image Edit 2511 (Q2_K)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q2_K.gguf", - description="Qwen Image Edit 2511 - Q2_K heavily quantized transformer. Smallest size, lower quality. (~7.5GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=QwenImageVariantType.Edit, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_edit_gguf_q6_k = StarterModel( - name="Qwen Image Edit 2511 (Q6_K)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q6_K.gguf", - description="Qwen Image Edit 2511 - Q6_K quantized transformer. Near-lossless quality. (~17GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=QwenImageVariantType.Edit, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_edit_gguf_q8_0 = StarterModel( - name="Qwen Image Edit 2511 (Q8_0)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q8_0.gguf", - description="Qwen Image Edit 2511 - Q8_0 quantized transformer. Highest quality quantization. (~22GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=QwenImageVariantType.Edit, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_edit_lightning_4step = StarterModel( - name="Qwen Image Edit Lightning (4-step, bf16)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", - description="Lightning distillation LoRA for Qwen Image Edit — enables generation in just 4 steps. " - "Settings: Steps=4, CFG=1, Shift Override=3.", - type=ModelType.LoRA, -) - -qwen_image_edit_lightning_8step = StarterModel( - name="Qwen Image Edit Lightning (8-step, bf16)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-8steps-V1.0-bf16.safetensors", - description="Lightning distillation LoRA for Qwen Image Edit — enables generation in 8 steps with better quality. " - "Settings: Steps=8, CFG=1, Shift Override=3.", - type=ModelType.LoRA, -) - -# Qwen Image (txt2img) -qwen_image = StarterModel( - name="Qwen Image 2512", - base=BaseModelType.QwenImage, - source="Qwen/Qwen-Image-2512", - description="Qwen Image 2512 full diffusers model. High-quality text-to-image generation. (~40GB)", - type=ModelType.Main, -) - -qwen_image_gguf_q4_k_m = StarterModel( - name="Qwen Image 2512 (Q4_K_M)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q4_K_M.gguf", - description="Qwen Image 2512 - Q4_K_M quantized transformer. Good quality/size balance. (~13GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_gguf_q2_k = StarterModel( - name="Qwen Image 2512 (Q2_K)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q2_K.gguf", - description="Qwen Image 2512 - Q2_K heavily quantized transformer. Smallest size, lower quality. (~7.5GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_gguf_q6_k = StarterModel( - name="Qwen Image 2512 (Q6_K)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q6_K.gguf", - description="Qwen Image 2512 - Q6_K quantized transformer. Near-lossless quality. (~17GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_gguf_q8_0 = StarterModel( - name="Qwen Image 2512 (Q8_0)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q8_0.gguf", - description="Qwen Image 2512 - Q8_0 quantized transformer. Highest quality quantization. (~22GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], -) - -qwen_image_lightning_4step = StarterModel( - name="Qwen Image Lightning (4-step, V2.0, bf16)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors", - description="Lightning distillation LoRA for Qwen Image — enables generation in just 4 steps. " - "Settings: Steps=4, CFG=1, Shift Override=3.", - type=ModelType.LoRA, -) - -qwen_image_lightning_8step = StarterModel( - name="Qwen Image Lightning (8-step, V2.0, bf16)", - base=BaseModelType.QwenImage, - source="https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors", - description="Lightning distillation LoRA for Qwen Image — enables generation in 8 steps with better quality. " - "Settings: Steps=8, CFG=1, Shift Override=3.", - type=ModelType.LoRA, -) -# endregion - -# region SigLIP -siglip = StarterModel( - name="SigLIP - google/siglip-so400m-patch14-384", - base=BaseModelType.Any, - source="google/siglip-so400m-patch14-384", - description="A SigLIP model (used by FLUX Redux).", - type=ModelType.SigLIP, -) -# endregion - -# region FLUX Redux -flux_redux = StarterModel( - name="FLUX Redux", - base=BaseModelType.Flux, - source="black-forest-labs/FLUX.1-Redux-dev::flux1-redux-dev.safetensors", - description="FLUX Redux model (for image variation).", - type=ModelType.FluxRedux, - dependencies=[siglip], -) -# endregion - -# region LlavaOnevisionModel (vision-language models for Image-to-Prompt) -llava_onevision = StarterModel( - name="LLaVA Onevision Qwen2 0.5B", - base=BaseModelType.Any, - source="llava-hf/llava-onevision-qwen2-0.5b-ov-hf", - description="LLaVA Onevision vision-language model (~1 GB). Lightweight default for the Image-to-Prompt feature.", - type=ModelType.LlavaOnevision, -) - -llava_onevision_7b = StarterModel( - name="LLaVA Onevision Qwen2 7B", - base=BaseModelType.Any, - source="llava-hf/llava-onevision-qwen2-7b-ov-hf", - description="LLaVA Onevision 7B vision-language model. Larger, higher-quality alternative for Image-to-Prompt. (~16 GB)", - type=ModelType.LlavaOnevision, -) -# endregion - -# region TextLLM (causal language models for Prompt Expansion) -qwen2_5_1_5b_instruct = StarterModel( - name="Qwen2.5-1.5B-Instruct", - base=BaseModelType.Any, - source="Qwen/Qwen2.5-1.5B-Instruct", - description="Qwen2.5 1.5B instruction-tuned LLM. Recommended default for the Prompt Expansion feature — small and fast. (~3 GB)", - type=ModelType.TextLLM, -) - -qwen2_5_3b_instruct = StarterModel( - name="Qwen2.5-3B-Instruct", - base=BaseModelType.Any, - source="Qwen/Qwen2.5-3B-Instruct", - description="Qwen2.5 3B instruction-tuned LLM. Better prompt expansion quality at the cost of more VRAM. (~6 GB)", - type=ModelType.TextLLM, -) - -smollm2_1_7b_instruct = StarterModel( - name="SmolLM2-1.7B-Instruct", - base=BaseModelType.Any, - source="HuggingFaceTB/SmolLM2-1.7B-Instruct", - description="SmolLM2 1.7B instruction-tuned LLM (Apache-2.0). Alternative to Qwen for prompt expansion. (~3 GB)", - type=ModelType.TextLLM, -) -# endregion - -# region FLUX Fill -flux_fill = StarterModel( - name="FLUX Fill", - base=BaseModelType.Flux, - source="black-forest-labs/FLUX.1-Fill-dev::flux1-fill-dev.safetensors", - description="FLUX Fill model (for inpainting).", - type=ModelType.Main, -) -# endregion - -# region FLUX.2 Klein -flux2_vae = StarterModel( - name="FLUX.2 VAE", - base=BaseModelType.Flux2, - source="black-forest-labs/FLUX.2-klein-4B::vae", - description="FLUX.2 VAE (16-channel, same architecture as FLUX.1 VAE). ~168MB", - type=ModelType.VAE, -) - -flux2_klein_qwen3_4b_encoder = StarterModel( - name="FLUX.2 Klein Qwen3 4B Encoder", - base=BaseModelType.Any, - source="black-forest-labs/FLUX.2-klein-4B::text_encoder+tokenizer", - description="Qwen3 4B text encoder for FLUX.2 Klein 4B (also compatible with Z-Image). ~8GB", - type=ModelType.Qwen3Encoder, -) - -flux2_klein_qwen3_8b_encoder = StarterModel( - name="FLUX.2 Klein Qwen3 8B Encoder", - base=BaseModelType.Any, - source="black-forest-labs/FLUX.2-klein-9B::text_encoder+tokenizer", - description="Qwen3 8B text encoder for FLUX.2 Klein 9B models. ~16GB", - type=ModelType.Qwen3Encoder, -) - -flux2_klein_4b = StarterModel( - name="FLUX.2 Klein 4B (Diffusers)", - base=BaseModelType.Flux2, - source="black-forest-labs/FLUX.2-klein-4B", - description="FLUX.2 Klein 4B in Diffusers format - includes transformer, VAE and Qwen3 encoder. ~16GB", - type=ModelType.Main, -) - -flux2_klein_4b_single = StarterModel( - name="FLUX.2 Klein 4B", - base=BaseModelType.Flux2, - source="https://huggingface.co/black-forest-labs/FLUX.2-klein-4B/resolve/main/flux-2-klein-4b.safetensors", - description="FLUX.2 Klein 4B standalone transformer. Installs with VAE and Qwen3 4B encoder. ~8GB", - type=ModelType.Main, - dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], -) - -flux2_klein_4b_fp8 = StarterModel( - name="FLUX.2 Klein 4B (FP8)", - base=BaseModelType.Flux2, - source="https://huggingface.co/black-forest-labs/FLUX.2-klein-4b-fp8/resolve/main/flux-2-klein-4b-fp8.safetensors", - description="FLUX.2 Klein 4B FP8 quantized - smaller and faster. Installs with VAE and Qwen3 4B encoder. ~4GB", - type=ModelType.Main, - dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], -) - -flux2_klein_9b = StarterModel( - name="FLUX.2 Klein 9B (Diffusers)", - base=BaseModelType.Flux2, - source="black-forest-labs/FLUX.2-klein-9B", - description="FLUX.2 Klein 9B in Diffusers format - includes transformer, VAE and Qwen3 encoder. ~35GB", - type=ModelType.Main, -) - -flux2_klein_9b_fp8 = StarterModel( - name="FLUX.2 Klein 9B (FP8)", - base=BaseModelType.Flux2, - source="https://huggingface.co/black-forest-labs/FLUX.2-klein-9b-fp8/resolve/main/flux-2-klein-9b-fp8.safetensors", - description="FLUX.2 Klein 9B FP8 quantized - more efficient than full precision. Installs with VAE and Qwen3 8B encoder. ~9.5GB", - type=ModelType.Main, - dependencies=[flux2_vae, flux2_klein_qwen3_8b_encoder], -) - -flux2_klein_4b_sdnq = StarterModel( - name="FLUX.2 Klein 4B (SDNQ dynamic 4-bit)", - base=BaseModelType.Flux2, - source="Disty0/FLUX.2-klein-4B-SDNQ-4bit-dynamic", - description="FLUX.2 Klein 4B quantized via SDNQ to dynamic uint4/int5 mixed precision. " - "Full self-contained Flux2KleinPipeline (transformer + Qwen3 4B + AutoencoderKLFlux2). ~5GB", - type=ModelType.Main, - format=ModelFormat.SDNQQuantized, -) - -flux2_klein_9b_sdnq = StarterModel( - name="FLUX.2 Klein 9B (SDNQ dynamic 4-bit + SVD)", - base=BaseModelType.Flux2, - source="Disty0/FLUX.2-klein-9B-SDNQ-4bit-dynamic-svd-r32", - description="FLUX.2 Klein 9B quantized via SDNQ to dynamic uint4/int5 + SVD rank 32. " - "Full self-contained Flux2KleinPipeline. ~13GB", - type=ModelType.Main, - format=ModelFormat.SDNQQuantized, -) - -flux2_klein_4b_gguf_q4 = StarterModel( - name="FLUX.2 Klein 4B (GGUF Q4)", - base=BaseModelType.Flux2, - source="https://huggingface.co/unsloth/FLUX.2-klein-4B-GGUF/resolve/main/flux-2-klein-4b-Q4_K_M.gguf", - description="FLUX.2 Klein 4B GGUF Q4_K_M quantized - runs on 6-8GB VRAM. Installs with VAE and Qwen3 4B encoder. ~2.6GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], -) - -flux2_klein_4b_gguf_q8 = StarterModel( - name="FLUX.2 Klein 4B (GGUF Q8)", - base=BaseModelType.Flux2, - source="https://huggingface.co/unsloth/FLUX.2-klein-4B-GGUF/resolve/main/flux-2-klein-4b-Q8_0.gguf", - description="FLUX.2 Klein 4B GGUF Q8_0 quantized - higher quality than Q4. Installs with VAE and Qwen3 4B encoder. ~4.3GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], -) - -flux2_klein_9b_gguf_q4 = StarterModel( - name="FLUX.2 Klein 9B (GGUF Q4)", - base=BaseModelType.Flux2, - source="https://huggingface.co/unsloth/FLUX.2-klein-9B-GGUF/resolve/main/flux-2-klein-9b-Q4_K_M.gguf", - description="FLUX.2 Klein 9B GGUF Q4_K_M quantized - runs on 12GB+ VRAM. Installs with VAE and Qwen3 8B encoder. ~5.8GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_klein_qwen3_8b_encoder], -) - -flux2_klein_9b_gguf_q8 = StarterModel( - name="FLUX.2 Klein 9B (GGUF Q8)", - base=BaseModelType.Flux2, - source="https://huggingface.co/unsloth/FLUX.2-klein-9B-GGUF/resolve/main/flux-2-klein-9b-Q8_0.gguf", - description="FLUX.2 Klein 9B GGUF Q8_0 quantized - higher quality than Q4. Installs with VAE and Qwen3 8B encoder. ~10GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_klein_qwen3_8b_encoder], -) -# endregion - -# region FLUX.2 [dev] -# -# FLUX.2 [dev] is BFL's 32B guidance-distilled rectified-flow model. The bf16 -# transformer alone is ~64 GB, so most users want the GGUF quantizations from -# the curated `gguf-org/flux2-dev-gguf` repo (the same repo also ships the -# matching "cow-mistral3-small" text encoder — a FLUX.2-specific 30-layer -# Mistral distillation that BFL trained the joint attention against; the -# README notes "Q2 works, but use a higher tier encoder for better prompt -# adherence"). All FLUX.2 [dev] releases are governed by the FLUX.2 -# Non-Commercial License. - -# --- Text encoders --- -# FLUX.2 [dev] reads Mistral hidden states at indices (10, 20, 30). Two encoders work: -# - The 40-layer Mistral Small 3 (24B) that BFL ships as the canonical -# FLUX.2-dev/text_encoder — the default; loads fine but has visibly weaker prompt -# adherence because those indices land at different relative depths. -# - The 30-layer "cow-mistral3-small" distillation — recommended for best adherence -# (on a 30-layer model the indices hit 1/3, 2/3, last, matching what the joint -# attention was trained against). The gguf-org cow GGUFs and Comfy-Org's safetensors -# are the same 30-layer cow weights, just packaged differently. - -# Comfy-Org safetensors (single-file, 30-layer cow, with embedded Tekken tokenizer). -# Higher precision than the cow GGUFs and avoids the Tekken-via-HF-Hub fetch. -flux2_dev_comfy_mistral_fp8 = StarterModel( - name="FLUX.2 [dev] Mistral Encoder (Comfy FP8)", - base=BaseModelType.Any, - source="https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp8.safetensors", - description="Comfy-Org FP8 of BFL's 30-layer cow-mistral3-small. Best quality/size for prompt adherence; embeds Tekken tokenizer (no HF fetch needed). ~18GB", - type=ModelType.MistralEncoder, -) - -flux2_dev_comfy_mistral_bf16 = StarterModel( - name="FLUX.2 [dev] Mistral Encoder (Comfy BF16)", - base=BaseModelType.Any, - source="https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_bf16.safetensors", - description="Comfy-Org BF16 of BFL's 30-layer cow-mistral3-small. Reference precision; embeds Tekken tokenizer. ~35.6GB", - type=ModelType.MistralEncoder, -) - -flux2_dev_comfy_mistral_fp4 = StarterModel( - name="FLUX.2 [dev] Mistral Encoder (Comfy FP4 mixed)", - base=BaseModelType.Any, - source="https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp4_mixed.safetensors", - description="Comfy-Org FP4-mixed of BFL's 30-layer cow-mistral3-small. Smallest safetensors variant; embeds Tekken tokenizer. ~12.3GB", - type=ModelType.MistralEncoder, -) - -# gguf-org cow GGUF variants (30-layer cow, llama.cpp packaging, also embed Tekken). -# Lower memory footprint than the Comfy safetensors but slightly lower fidelity. -flux2_dev_cow_mistral_q4 = StarterModel( - name="FLUX.2 [dev] cow Mistral Encoder (GGUF Q4)", - base=BaseModelType.Any, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/cow-mistral3-small-q4_0.gguf", - description="cow-mistral3-small Q4_0 — 30-layer cow distillation BFL trained against. ~11.6GB", - type=ModelType.MistralEncoder, - format=ModelFormat.GGUFQuantized, -) - -flux2_dev_cow_mistral_q8 = StarterModel( - name="FLUX.2 [dev] cow Mistral Encoder (GGUF Q8)", - base=BaseModelType.Any, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/cow-mistral3-small-q8_0.gguf", - description="cow-mistral3-small Q8_0 — best prompt adherence among cow GGUF quants. ~20GB", - type=ModelType.MistralEncoder, - format=ModelFormat.GGUFQuantized, -) - -flux2_dev_cow_mistral_iq4_xs = StarterModel( - name="FLUX.2 [dev] cow Mistral Encoder (GGUF IQ4_XS)", - base=BaseModelType.Any, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/cow-mistral3-small-iq4_xs.gguf", - description="cow-mistral3-small IQ4_XS — smallest usable quant with reasonable adherence. ~11.1GB", - type=ModelType.MistralEncoder, - format=ModelFormat.GGUFQuantized, -) - -# --- Diffusers transformer --- -flux2_dev_diffusers = StarterModel( - name="FLUX.2 [dev] (Diffusers)", - base=BaseModelType.Flux2, - source="black-forest-labs/FLUX.2-dev", - description="FLUX.2 [dev] full Diffusers pipeline - includes transformer, VAE, and Mistral text encoder. ~80GB. Non-Commercial License.", - type=ModelType.Main, -) - -flux2_dev_diffusers_nf4 = StarterModel( - name="FLUX.2 [dev] (Diffusers, NF4)", - base=BaseModelType.Flux2, - source="diffusers/FLUX.2-dev-bnb-4bit", - description="FLUX.2 [dev] with NF4-quantized DiT and text encoder - runs on ~18GB VRAM with offload. Non-Commercial License.", - type=ModelType.Main, -) - -# --- GGUF transformers from gguf-org/flux2-dev-gguf (canonical repo) --- -# These are the GGUFs BFL/community curate for cow-paired inference. Default -# encoder dependency is cow Q4 to make starter installs work out of the box. -flux2_dev_gguf_q3_k_m = StarterModel( - name="FLUX.2 [dev] Transformer (GGUF Q3_K_M)", - base=BaseModelType.Flux2, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q3_k_m.gguf", - description="FLUX.2 [dev] transformer Q3_K_M — fits ~12GB VRAM with offload. ~15.9GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_dev_cow_mistral_q4], -) - -flux2_dev_gguf_q4_k_m = StarterModel( - name="FLUX.2 [dev] Transformer (GGUF Q4_K_M)", - base=BaseModelType.Flux2, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q4_k_m.gguf", - description="FLUX.2 [dev] transformer Q4_K_M — good quality / size tradeoff. ~20GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_dev_cow_mistral_q4], -) - -flux2_dev_gguf_q5_k_m = StarterModel( - name="FLUX.2 [dev] Transformer (GGUF Q5_K_M)", - base=BaseModelType.Flux2, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q5_k_m.gguf", - description="FLUX.2 [dev] transformer Q5_K_M — higher fidelity than Q4. ~24GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_dev_cow_mistral_q8], -) - -flux2_dev_gguf_q6_k = StarterModel( - name="FLUX.2 [dev] Transformer (GGUF Q6_K)", - base=BaseModelType.Flux2, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q6_k.gguf", - description="FLUX.2 [dev] transformer Q6_K — near-Q8 quality at lower size. ~27.9GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_dev_cow_mistral_q8], -) - -flux2_dev_gguf_q8_0 = StarterModel( - name="FLUX.2 [dev] Transformer (GGUF Q8_0)", - base=BaseModelType.Flux2, - source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q8_0.gguf", - description="FLUX.2 [dev] transformer Q8_0 — highest GGUF fidelity. ~35.5GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[flux2_vae, flux2_dev_cow_mistral_q8], -) -# endregion - -# region Z-Image -z_image_qwen3_encoder = StarterModel( - name="Z-Image Qwen3 Text Encoder", - base=BaseModelType.Any, - source="Tongyi-MAI/Z-Image-Turbo::text_encoder+tokenizer", - description="Qwen3 4B text encoder with tokenizer for Z-Image (full precision). ~8GB", - type=ModelType.Qwen3Encoder, -) - -z_image_qwen3_encoder_quantized = StarterModel( - name="Z-Image Qwen3 Text Encoder (quantized)", - base=BaseModelType.Any, - source="https://huggingface.co/worstplayer/Z-Image_Qwen_3_4b_text_encoder_GGUF/resolve/main/Qwen_3_4b-Q6_K.gguf", - description="Qwen3 4B text encoder for Z-Image quantized to GGUF Q6_K format. ~3.3GB", - type=ModelType.Qwen3Encoder, - format=ModelFormat.GGUFQuantized, -) - -z_image_turbo = StarterModel( - name="Z-Image Turbo", - base=BaseModelType.ZImage, - source="Tongyi-MAI/Z-Image-Turbo", - description="Z-Image Turbo - fast 6B parameter text-to-image model with 8 inference steps. Supports bilingual prompts (English & Chinese). ~33GB", - type=ModelType.Main, -) - -z_image_turbo_quantized = StarterModel( - name="Z-Image Turbo (quantized)", - base=BaseModelType.ZImage, - source="https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_K.gguf", - description="Z-Image Turbo quantized to GGUF Q4_K format. Requires standalone Qwen3 text encoder and Flux VAE. ~4GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[z_image_qwen3_encoder_quantized, flux_vae], -) - -z_image_turbo_q8 = StarterModel( - name="Z-Image Turbo (Q8)", - base=BaseModelType.ZImage, - source="https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q8_0.gguf", - description="Z-Image Turbo quantized to GGUF Q8_0 format. Higher quality, larger size. Requires standalone Qwen3 text encoder and Flux VAE. ~6.6GB", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - dependencies=[z_image_qwen3_encoder_quantized, flux_vae], -) - -z_image_turbo_sdnq = StarterModel( - name="Z-Image Turbo (SDNQ uint4 + SVD)", - base=BaseModelType.ZImage, - source="Disty0/Z-Image-Turbo-SDNQ-uint4-svd-r32", - description="Z-Image Turbo quantized via SDNQ to uint4 + SVD rank 32. Full self-contained " - "ZImagePipeline (transformer + Qwen3 + VAE). ~5GB", - type=ModelType.Main, - format=ModelFormat.SDNQQuantized, -) - -z_image_controlnet_union = StarterModel( - name="Z-Image ControlNet Union", - base=BaseModelType.ZImage, - source="https://huggingface.co/alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union-2.1/resolve/main/Z-Image-Turbo-Fun-Controlnet-Union-2.1-8steps.safetensors", - description="Unified ControlNet for Z-Image Turbo supporting Canny, HED, Depth, Pose, MLSD, and Inpainting modes.", - type=ModelType.ControlNet, -) - -z_image_controlnet_tile = StarterModel( - name="Z-Image ControlNet Tile", - base=BaseModelType.ZImage, - source="https://huggingface.co/alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union-2.1/resolve/main/Z-Image-Turbo-Fun-Controlnet-Tile-2.1-8steps.safetensors", - description="Dedicated Tile ControlNet for Z-Image Turbo. Useful for upscaling and adding detail. ~6.7GB", - type=ModelType.ControlNet, -) -# endregion - -# region ERNIE-Image -ernie_image = StarterModel( - name="ERNIE-Image", - base=BaseModelType.ErnieImage, - source="baidu/ERNIE-Image", - description=( - "Baidu ERNIE-Image: 8B single-stream DiT with Mistral3 text encoder, AutoencoderKLFlux2 VAE, " - "and bundled Ministral3 prompt enhancer. Defaults to 50 steps with CFG 4.0." - ), - type=ModelType.Main, -) - -ernie_image_turbo = StarterModel( - name="ERNIE-Image Turbo", - base=BaseModelType.ErnieImage, - source="baidu/ERNIE-Image-Turbo", - description=( - "ERNIE-Image-Turbo: distilled variant of ERNIE-Image. Same architecture as ERNIE-Image but " - "tuned for fast inference at 8 steps with CFG disabled (1.0)." - ), - type=ModelType.Main, -) -# endregion - -# region Krea-2 -# Standalone Qwen3-VL text encoder used by Krea-2 (distinct from the Qwen2.5-VL encoder above). Pair -# with single-file / GGUF Krea-2 transformers, which ship only the transformer. The Qwen-Image VAE -# dependency reuses the `qwen_image_vae` starter defined in the Qwen Image region. -qwen3_vl_encoder_4b = StarterModel( - name="Qwen3-VL 4B Encoder (Diffusers)", - base=BaseModelType.Any, - source="Qwen/Qwen3-VL-4B-Instruct", - description="Qwen3-VL 4B text encoder (Qwen3VLModel) used by Krea-2, in HuggingFace folder layout " - "(includes tokenizer). Use with single-file / GGUF Krea-2 transformers. (~8GB)", - type=ModelType.Qwen3VLEncoder, - format=ModelFormat.Qwen3VLEncoder, -) - -krea2_turbo = StarterModel( - name="Krea-2 Turbo", - base=BaseModelType.Krea2, - source="krea/Krea-2-Turbo", - description="Krea-2 Turbo - distilled 12B parameter text-to-image model (8 steps, CFG disabled). " - "Full diffusers pipeline including the Qwen-Image VAE and Qwen3-VL text encoder. ~26GB", - type=ModelType.Main, - variant=Krea2VariantType.Turbo, -) - -krea2_raw = StarterModel( - name="Krea-2 Raw", - base=BaseModelType.Krea2, - source="krea/Krea-2-Raw", - description="Krea-2 Raw - undistilled 12B base model (28 steps, CFG enabled). Full diffusers pipeline " - "including the Qwen-Image VAE and Qwen3-VL text encoder. Primarily a base for finetuning / LoRA " - "training; Turbo is recommended for standard inference. ~26GB", - type=ModelType.Main, - variant=Krea2VariantType.Base, -) - -krea2_turbo_gguf_q4_k_m = StarterModel( - name="Krea-2 Turbo (Q4_K_M GGUF)", - base=BaseModelType.Krea2, - source="https://huggingface.co/vantagewithai/Krea-2-Turbo-GGUF/resolve/main/krea2_turbo-Q4_K_M.gguf", - description="Krea-2 Turbo transformer quantized to GGUF Q4_K_M for lower VRAM (~7GB transformer). " - "GGUF ships only the transformer, so the Qwen-Image VAE and Qwen3-VL encoder are installed as " - "dependencies.", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=Krea2VariantType.Turbo, - dependencies=[qwen_image_vae, qwen3_vl_encoder_4b], -) - -krea2_turbo_gguf_q8_0 = StarterModel( - name="Krea-2 Turbo (Q8_0 GGUF)", - base=BaseModelType.Krea2, - source="https://huggingface.co/vantagewithai/Krea-2-Turbo-GGUF/resolve/main/krea2_turbo-Q8_0.gguf", - description="Krea-2 Turbo transformer quantized to GGUF Q8_0 (near-full quality, ~13GB transformer). " - "GGUF ships only the transformer, so the Qwen-Image VAE and Qwen3-VL encoder are installed as " - "dependencies.", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=Krea2VariantType.Turbo, - dependencies=[qwen_image_vae, qwen3_vl_encoder_4b], -) -# endregion - -# region External API -GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS = [ - "1:1", - "1:4", - "1:8", - "2:3", - "3:2", - "3:4", - "4:1", - "4:3", - "4:5", - "5:4", - "8:1", - "9:16", - "16:9", - "21:9", -] -GEMINI_3_IMAGE_MAX_SIZE = ExternalImageSize(width=4096, height=4096) - - -def _gemini_3_resolution_presets( - image_sizes: list[str], - aspect_ratios: list[str] | None = None, -) -> list[ExternalResolutionPreset]: - """Build resolution presets for Gemini 3 models. - - Each preset combines an aspect ratio with an image size preset (512/1K/2K/4K). - Pixel dimensions are approximations based on the preset name (longest side). - """ - if aspect_ratios is None: - aspect_ratios = GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS - base_pixels = {"512": 512, "1K": 1024, "2K": 2048, "4K": 4096} - presets: list[ExternalResolutionPreset] = [] - for image_size in image_sizes: - base = base_pixels[image_size] - for ratio_str in aspect_ratios: - w_part, h_part = (int(x) for x in ratio_str.split(":")) - if w_part >= h_part: - w = base - h = max(1, round(base * h_part / w_part)) - else: - h = base - w = max(1, round(base * w_part / h_part)) - presets.append( - ExternalResolutionPreset( - label=f"{ratio_str} ({image_size}) — {w}\u00d7{h}", - aspect_ratio=ratio_str, - image_size=image_size, - width=w, - height=h, - ) - ) - return presets - - -GEMINI_3_PRO_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["1K", "2K", "4K"]) -GEMINI_3_1_FLASH_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["512", "1K", "2K", "4K"]) - -gemini_flash_image = StarterModel( - name="Gemini 2.5 Flash Image", - base=BaseModelType.External, - source="external://gemini/gemini-2.5-flash-image", - description="Google Gemini 2.5 Flash image generation model (external API). Requires a configured Gemini API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_seed=True, - supports_reference_images=True, - max_images_per_request=1, - allowed_aspect_ratios=[ - "1:1", - "2:3", - "3:2", - "3:4", - "4:3", - "4:5", - "5:4", - "9:16", - "16:9", - "21:9", - ], - aspect_ratio_sizes={ - "1:1": ExternalImageSize(width=1024, height=1024), - "2:3": ExternalImageSize(width=832, height=1248), - "3:2": ExternalImageSize(width=1248, height=832), - "3:4": ExternalImageSize(width=864, height=1184), - "4:3": ExternalImageSize(width=1184, height=864), - "4:5": ExternalImageSize(width=896, height=1152), - "5:4": ExternalImageSize(width=1152, height=896), - "9:16": ExternalImageSize(width=768, height=1344), - "16:9": ExternalImageSize(width=1344, height=768), - "21:9": ExternalImageSize(width=1536, height=672), - }, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), -) -gemini_pro_image_preview = StarterModel( - name="Gemini 3 Pro Image Preview", - base=BaseModelType.External, - source="external://gemini/gemini-3-pro-image-preview", - description="Google Gemini 3 Pro image generation preview model (external API). Supports up to 14 reference images, including up to 6 object references and up to 5 character references. Supports 1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_seed=True, - supports_reference_images=True, - max_reference_images=14, - max_images_per_request=1, - max_image_size=GEMINI_3_IMAGE_MAX_SIZE, - allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS, - resolution_presets=GEMINI_3_PRO_RESOLUTION_PRESETS, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), -) -gemini_3_1_flash_image_preview = StarterModel( - name="Gemini 3.1 Flash Image Preview", - base=BaseModelType.External, - source="external://gemini/gemini-3.1-flash-image-preview", - description="Google Gemini 3.1 Flash image generation preview model (external API). Supports up to 14 reference images, including up to 10 object references and up to 4 character references. Supports 512/1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_seed=True, - supports_reference_images=True, - max_reference_images=14, - max_images_per_request=1, - max_image_size=GEMINI_3_IMAGE_MAX_SIZE, - allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS, - resolution_presets=GEMINI_3_1_FLASH_RESOLUTION_PRESETS, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), -) -QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] -QWEN_IMAGE_MAX_ALLOWED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] -WAN_V2_ALLOWED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] - -alibabacloud_qwen_image_2_pro = StarterModel( - name="Qwen Image 2.0 Pro", - base=BaseModelType.External, - source="external://alibabacloud/qwen-image-2.0-pro", - description="Alibaba Cloud Qwen Image 2.0 Pro model (external API). Best quality text-to-image with excellent bilingual text rendering. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_negative_prompt=False, - supports_seed=True, - max_images_per_request=4, - allowed_aspect_ratios=QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS, - aspect_ratio_sizes={ - "1:1": ExternalImageSize(width=2048, height=2048), - "4:3": ExternalImageSize(width=2368, height=1728), - "3:4": ExternalImageSize(width=1728, height=2368), - "16:9": ExternalImageSize(width=2688, height=1536), - "9:16": ExternalImageSize(width=1536, height=2688), - }, - ), - default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), - panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), -) -alibabacloud_qwen_image_2 = StarterModel( - name="Qwen Image 2.0", - base=BaseModelType.External, - source="external://alibabacloud/qwen-image-2.0", - description="Alibaba Cloud Qwen Image 2.0 model (external API). Fast text-to-image with good bilingual text rendering. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_negative_prompt=False, - supports_seed=True, - max_images_per_request=4, - allowed_aspect_ratios=QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS, - aspect_ratio_sizes={ - "1:1": ExternalImageSize(width=2048, height=2048), - "4:3": ExternalImageSize(width=2368, height=1728), - "3:4": ExternalImageSize(width=1728, height=2368), - "16:9": ExternalImageSize(width=2688, height=1536), - "9:16": ExternalImageSize(width=1536, height=2688), - }, - ), - default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), - panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), -) -alibabacloud_qwen_image_max = StarterModel( - name="Qwen Image Max", - base=BaseModelType.External, - source="external://alibabacloud/qwen-image-max", - description="Alibaba Cloud Qwen Image Max model (external API). High quality text-to-image generation. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_negative_prompt=False, - supports_seed=True, - max_images_per_request=4, - allowed_aspect_ratios=QWEN_IMAGE_MAX_ALLOWED_ASPECT_RATIOS, - aspect_ratio_sizes={ - "1:1": ExternalImageSize(width=1328, height=1328), - "4:3": ExternalImageSize(width=1472, height=1104), - "3:4": ExternalImageSize(width=1104, height=1472), - "16:9": ExternalImageSize(width=1664, height=928), - "9:16": ExternalImageSize(width=928, height=1664), - }, - ), - default_settings=ExternalApiModelDefaultSettings(width=1328, height=1328, num_images=1), - panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), -) -# region Wan 2.2 (local) -# Shared components — all Wan 2.2 variants use the UMT5-XXL text encoder. A14B -# (both T2V and I2V) uses a 16-channel VAE; TI2V-5B uses a 48-channel VAE. The -# two VAEs are not interchangeable. -wan_22_t5_encoder = StarterModel( - name="Wan T5 Encoder (UMT5-XXL)", - base=BaseModelType.Any, - source="Wan-AI/Wan2.2-T2V-A14B-Diffusers::text_encoder+tokenizer", - description="UMT5-XXL text encoder used by all Wan 2.2 variants (T2V/I2V A14B and TI2V-5B). " - "Required when running a GGUF Wan main without a Diffusers Component Source. (~11GB)", - type=ModelType.WanT5Encoder, - format=ModelFormat.WanT5Encoder, -) - -wan_22_a14b_vae = StarterModel( - name="Wan 2.2 A14B VAE", - base=BaseModelType.Wan, - source="Wan-AI/Wan2.2-T2V-A14B-Diffusers::vae/diffusion_pytorch_model.safetensors", - description="Wan 2.2 A14B VAE (16-channel). Shared between T2V and I2V A14B variants. " - "Not interchangeable with the TI2V-5B VAE. (~250MB)", - type=ModelType.VAE, - format=ModelFormat.Checkpoint, -) - -wan_22_5b_vae = StarterModel( - name="Wan 2.2 TI2V-5B VAE", - base=BaseModelType.Wan, - source="Wan-AI/Wan2.2-TI2V-5B-Diffusers::vae/diffusion_pytorch_model.safetensors", - description="Wan 2.2 TI2V-5B VAE (48-channel). Required for the TI2V-5B model family. " - "Not interchangeable with the A14B VAE. (~400MB)", - type=ModelType.VAE, - format=ModelFormat.Checkpoint, -) - -# T2V A14B — full Diffusers + GGUF expert pairs (Q4_K_M and Q8_0). -# The high-noise GGUF is the "main" entry the user picks; the low-noise GGUF -# is wired as the partner expert via the Advanced panel. Each high-noise entry -# lists its low-noise partner plus the shared VAE/encoder as dependencies so -# the bundle/dependency installer pulls everything together. -wan_22_t2v_a14b_diffusers = StarterModel( - name="Wan 2.2 T2V A14B (Diffusers)", - base=BaseModelType.Wan, - source="Wan-AI/Wan2.2-T2V-A14B-Diffusers", - description="Full Diffusers Wan 2.2 T2V A14B model — both expert transformers, VAE, and UMT5-XXL " - "encoder in a single folder. No additional components needed. (~80GB)", - type=ModelType.Main, - format=ModelFormat.Diffusers, - variant=WanVariantType.T2V_A14B, -) - -wan_22_t2v_a14b_low_gguf_q4_k_m = StarterModel( - name="Wan 2.2 T2V A14B Low Noise (Q4_K_M)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-T2V-A14B-LowNoise-Q4_K_M.gguf", - description="Wan 2.2 T2V A14B low-noise expert transformer (Q4_K_M). Paired with the high-noise " - "expert; selected via the Advanced 'Transformer (Low Noise)' field. (~9.7GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.T2V_A14B, -) - -wan_22_t2v_a14b_gguf_q4_k_m = StarterModel( - name="Wan 2.2 T2V A14B High Noise (Q4_K_M)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q4_K_M.gguf", - description="Wan 2.2 T2V A14B high-noise expert transformer (Q4_K_M). Pick this as the main model; " - "the low-noise partner is wired in Advanced. Good quality/size balance. (~9.7GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.T2V_A14B, - dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_t2v_a14b_low_gguf_q4_k_m], -) - -wan_22_t2v_a14b_low_gguf_q8_0 = StarterModel( - name="Wan 2.2 T2V A14B Low Noise (Q8_0)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-T2V-A14B-LowNoise-Q8_0.gguf", - description="Wan 2.2 T2V A14B low-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.T2V_A14B, -) - -wan_22_t2v_a14b_gguf_q8_0 = StarterModel( - name="Wan 2.2 T2V A14B High Noise (Q8_0)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q8_0.gguf", - description="Wan 2.2 T2V A14B high-noise expert transformer (Q8_0). Pick as the main; pair with the " - "low-noise Q8_0 partner in Advanced. Highest quality quantization. (~15.4GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.T2V_A14B, - dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_t2v_a14b_low_gguf_q8_0], -) - -# T2V Lightning LoRAs — V1.1 Seko rank-64 pair (4-step inference). -wan_22_t2v_lightning_high = StarterModel( - name="Wan 2.2 T2V Lightning High Noise (4-step, V1.1)", - base=BaseModelType.Wan, - source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V1.1/high_noise_model.safetensors", - description="Lightning distillation LoRA for the Wan 2.2 T2V A14B high-noise expert — enables " - "4-step generation. Use together with the low-noise variant. Settings: Steps=4, CFG=1.", - type=ModelType.LoRA, -) - -wan_22_t2v_lightning_low = StarterModel( - name="Wan 2.2 T2V Lightning Low Noise (4-step, V1.1)", - base=BaseModelType.Wan, - source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V1.1/low_noise_model.safetensors", - description="Lightning distillation LoRA for the Wan 2.2 T2V A14B low-noise expert — enables " - "4-step generation. Use together with the high-noise variant. Settings: Steps=4, CFG=1.", - type=ModelType.LoRA, -) - -# I2V A14B — full Diffusers + GGUF expert pairs (Q4_K_M and Q8_0). -wan_22_i2v_a14b_diffusers = StarterModel( - name="Wan 2.2 I2V A14B (Diffusers)", - base=BaseModelType.Wan, - source="Wan-AI/Wan2.2-I2V-A14B-Diffusers", - description="Full Diffusers Wan 2.2 I2V A14B model — both expert transformers, VAE, and UMT5-XXL " - "encoder. Use the Reference Images panel to provide the conditioning image. (~80GB)", - type=ModelType.Main, - format=ModelFormat.Diffusers, - variant=WanVariantType.I2V_A14B, -) - -wan_22_i2v_a14b_low_gguf_q4_k_m = StarterModel( - name="Wan 2.2 I2V A14B Low Noise (Q4_K_M)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-I2V-A14B-LowNoise-Q4_K_M.gguf", - description="Wan 2.2 I2V A14B low-noise expert transformer (Q4_K_M). (~9.7GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.I2V_A14B, -) - -wan_22_i2v_a14b_gguf_q4_k_m = StarterModel( - name="Wan 2.2 I2V A14B High Noise (Q4_K_M)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-I2V-A14B-HighNoise-Q4_K_M.gguf", - description="Wan 2.2 I2V A14B high-noise expert transformer (Q4_K_M). Pick as the main; pair with " - "the low-noise partner in Advanced. Use the Reference Images panel for the conditioning image. (~9.7GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.I2V_A14B, - dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_i2v_a14b_low_gguf_q4_k_m], -) - -wan_22_i2v_a14b_low_gguf_q8_0 = StarterModel( - name="Wan 2.2 I2V A14B Low Noise (Q8_0)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-I2V-A14B-LowNoise-Q8_0.gguf", - description="Wan 2.2 I2V A14B low-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.I2V_A14B, -) - -wan_22_i2v_a14b_gguf_q8_0 = StarterModel( - name="Wan 2.2 I2V A14B High Noise (Q8_0)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-I2V-A14B-HighNoise-Q8_0.gguf", - description="Wan 2.2 I2V A14B high-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.I2V_A14B, - dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_i2v_a14b_low_gguf_q8_0], -) - -# I2V Lightning LoRAs — Seko rank-64 pair (4-step inference). Currently only V1. -wan_22_i2v_lightning_high = StarterModel( - name="Wan 2.2 I2V Lightning High Noise (4-step, V1)", - base=BaseModelType.Wan, - source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/high_noise_model.safetensors", - description="Lightning distillation LoRA for the Wan 2.2 I2V A14B high-noise expert — enables " - "4-step image-to-image generation. Use together with the low-noise variant. Settings: Steps=4, CFG=1.", - type=ModelType.LoRA, -) - -wan_22_i2v_lightning_low = StarterModel( - name="Wan 2.2 I2V Lightning Low Noise (4-step, V1)", - base=BaseModelType.Wan, - source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/low_noise_model.safetensors", - description="Lightning distillation LoRA for the Wan 2.2 I2V A14B low-noise expert — enables " - "4-step image-to-image generation. Use together with the high-noise variant. Settings: Steps=4, CFG=1.", - type=ModelType.LoRA, -) - -# TI2V-5B — single-transformer model (no expert pair). Uses its own 48-channel VAE. -wan_22_ti2v_5b_diffusers = StarterModel( - name="Wan 2.2 TI2V-5B (Diffusers)", - base=BaseModelType.Wan, - source="Wan-AI/Wan2.2-TI2V-5B-Diffusers", - description="Full Diffusers Wan 2.2 TI2V-5B model — single 5B transformer, 48-channel VAE, and " - "UMT5-XXL encoder. Smaller and faster than A14B; runs on consumer GPUs. (~20GB)", - type=ModelType.Main, - format=ModelFormat.Diffusers, - variant=WanVariantType.TI2V_5B, -) - -wan_22_ti2v_5b_gguf_q4_k_m = StarterModel( - name="Wan 2.2 TI2V-5B (Q4_K_M)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-TI2V-5B-GGUF/resolve/main/Wan2.2-TI2V-5B-Q4_K_M.gguf", - description="Wan 2.2 TI2V-5B transformer (Q4_K_M). Single-expert model — no low-noise partner needed. (~3.4GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.TI2V_5B, - dependencies=[wan_22_5b_vae, wan_22_t5_encoder], -) - -wan_22_ti2v_5b_gguf_q8_0 = StarterModel( - name="Wan 2.2 TI2V-5B (Q8_0)", - base=BaseModelType.Wan, - source="https://huggingface.co/QuantStack/Wan2.2-TI2V-5B-GGUF/resolve/main/Wan2.2-TI2V-5B-Q8_0.gguf", - description="Wan 2.2 TI2V-5B transformer (Q8_0). Highest quality quantization. (~5.4GB)", - type=ModelType.Main, - format=ModelFormat.GGUFQuantized, - variant=WanVariantType.TI2V_5B, - dependencies=[wan_22_5b_vae, wan_22_t5_encoder], -) -# endregion - -# region MiniMax H3 (local) -# License note: the MiniMax H3 Community License requires prominent "MiniMax H3" attribution -# (keep it verbatim in every name/description below) and restricts use by territory (excludes -# the US, EU, UK and South Korea, extending to outputs). These entries live in their own -# droppable commit so a release can exclude them without touching anything else. -# -# The full huggingface.co/MiniMaxAI/MiniMax-H3 repo is ~498 GB (it also carries the Ref2VA -# transformer and the original remote-code checkpoints). The slim main below downloads only the -# shared components (tokenizer, processor, video/audio VAEs) plus the two config JSONs that -# identification needs (~11 GB); the transformer and text encoder come from Comfy-Org's int8 -# single-file repacks, selected in the MiniMax H3 Model Loader. Total ~59 GB. - -minimax_h3_components = StarterModel( - name="MiniMax H3 Components", - base=BaseModelType.MiniMaxH3, - source="MiniMaxAI/MiniMax-H3::modular_model_index.json+transformer/config.json+tokenizer+processor+vae+audio_vae", - description="MiniMax H3 shared components: tokenizer, processor and video/audio VAEs, without " - "transformer or text-encoder weights (~11 GB). Pair with the MiniMax H3 single-file transformer " - "and text encoder. NOTE: This model is distributed under a restrictive license that forbids its " - "use in certain territories. Please see https://huggingface.co/MiniMaxAI/MiniMax-H3 for details.", - type=ModelType.Main, - format=ModelFormat.Diffusers, -) - -minimax_h3_int8_text_encoder = StarterModel( - name="MiniMax H3 Text Encoder (int8)", - base=BaseModelType.MiniMaxH3, - source="Comfy-Org/MiniMax-H3::text_encoders/qwen3vl_32b_minimax_h3_int8_convrot.safetensors", - description="Truncated Qwen3-VL-32B conditioning encoder for MiniMax H3, int8 quantized (~27 GB). " - "Select it in the MiniMax H3 Model Loader's text encoder field. NOTE: This model is distributed " - "under a restrictive license that forbids its use in certain territories. Please see " - "https://huggingface.co/MiniMaxAI/MiniMax-H3 for details.", - type=ModelType.Qwen3VLEncoder, - format=ModelFormat.Checkpoint, -) - -minimax_h3_int8_transformer = StarterModel( - name="MiniMax H3 FL2VA Transformer (int8, pruned)", - base=BaseModelType.MiniMaxH3, - source="Comfy-Org/MiniMax-H3::diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors", - description="MiniMax H3 video+audio generation. AdaLN-pruned int8 single-file transformer (~21 GB); " - "select it in the MiniMax H3 Model Loader's transformer field. Total size with dependencies: ~59 GB. " - "NOTE: This model is distributed under a restrictive license that forbids its use in certain " - "territories. Please see https://huggingface.co/MiniMaxAI/MiniMax-H3 for details.", - type=ModelType.Main, - format=ModelFormat.Checkpoint, - dependencies=[minimax_h3_components, minimax_h3_int8_text_encoder], -) - -minimax_h3_turbo_lora = StarterModel( - name="MiniMax H3 Turbo LoRA", - base=BaseModelType.MiniMaxH3, - source="larryvrh/MiniMax-H3-Turbo-Lora::minimax_h3_turbo_v4_step600_ema.safetensors", - description="Step-distillation LoRA for MiniMax H3 (Apache 2.0): renders video+audio in 4-8 " - "denoising steps instead of ~50. Apply at strength 1.0 and lower Steps to 6-8. Works with the " - "full and the pruned int8 transformers.", - type=ModelType.LoRA, - format=ModelFormat.LyCORIS, -) -# endregion - -alibabacloud_wan26_t2i = StarterModel( - name="Wan 2.6 Text-to-Image", - base=BaseModelType.External, - source="external://alibabacloud/wan2.6-t2i", - description="Alibaba Cloud Wan 2.6 text-to-image model (external API). Photorealistic image generation. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_negative_prompt=False, - supports_seed=True, - max_images_per_request=4, - allowed_aspect_ratios=WAN_V2_ALLOWED_ASPECT_RATIOS, - aspect_ratio_sizes={ - "1:1": ExternalImageSize(width=1024, height=1024), - "4:3": ExternalImageSize(width=1440, height=1080), - "3:4": ExternalImageSize(width=1080, height=1440), - "16:9": ExternalImageSize(width=1440, height=810), - "9:16": ExternalImageSize(width=810, height=1440), - }, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), -) -alibabacloud_qwen_image_edit_max = StarterModel( - name="Qwen Image Edit Max", - base=BaseModelType.External, - source="external://alibabacloud/qwen-image-edit-max", - description="Alibaba Cloud Qwen Image Edit Max model (external API). Image editing with industrial design and geometric reasoning, driven by up to 3 reference images. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - supports_negative_prompt=False, - supports_reference_images=True, - supports_seed=True, - max_reference_images=3, - max_images_per_request=4, - allowed_aspect_ratios=QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS, - aspect_ratio_sizes={ - "1:1": ExternalImageSize(width=2048, height=2048), - "4:3": ExternalImageSize(width=2368, height=1728), - "3:4": ExternalImageSize(width=1728, height=2368), - "16:9": ExternalImageSize(width=2688, height=1536), - "9:16": ExternalImageSize(width=1536, height=2688), - }, - ), - default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), - panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), -) -OPENAI_GPT_IMAGE_ASPECT_RATIOS = ["1:1", "3:2", "2:3"] -OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES = { - "1:1": ExternalImageSize(width=1024, height=1024), - "3:2": ExternalImageSize(width=1536, height=1024), - "2:3": ExternalImageSize(width=1024, height=1536), -} -OPENAI_GPT_IMAGE_PANEL_SCHEMA = ExternalModelPanelSchema( - prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}] -) - -openai_gpt_image_2 = StarterModel( - name="GPT Image 2", - base=BaseModelType.External, - source="external://openai/gpt-image-2", - description="OpenAI GPT-Image-2 image generation model. State-of-the-art image generation and editing with flexible sizing and high-fidelity image inputs. Does not support transparent backgrounds or configurable input fidelity. Requires a configured OpenAI API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_images_per_request=10, - allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, - aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, -) -openai_gpt_image_1_5 = StarterModel( - name="GPT Image 1.5", - base=BaseModelType.External, - source="external://openai/gpt-image-1.5", - description="OpenAI GPT-Image-1.5 image generation model. Fastest and most affordable GPT image model. Requires a configured OpenAI API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_images_per_request=10, - allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, - aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, -) -openai_gpt_image_1 = StarterModel( - name="GPT Image 1", - base=BaseModelType.External, - source="external://openai/gpt-image-1", - description="OpenAI GPT-Image-1 image generation model. High quality image generation. Requires a configured OpenAI API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_images_per_request=10, - allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, - aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, -) -openai_gpt_image_1_mini = StarterModel( - name="GPT Image 1 Mini", - base=BaseModelType.External, - source="external://openai/gpt-image-1-mini", - description="OpenAI GPT-Image-1-Mini image generation model. Cost-efficient option, 80%% cheaper than GPT-Image-1. Requires a configured OpenAI API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_images_per_request=10, - allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, - aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, -) -openai_dall_e_3 = StarterModel( - name="DALL-E 3", - base=BaseModelType.External, - source="external://openai/dall-e-3", - description="OpenAI DALL-E 3 image generation model. Supports vivid and natural styles. Only text-to-image, no editing. Requires a configured OpenAI API key and may incur provider usage costs.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img"], - max_images_per_request=1, - allowed_aspect_ratios=["1:1", "7:4", "4:7"], - aspect_ratio_sizes={ - "1:1": ExternalImageSize(width=1024, height=1024), - "7:4": ExternalImageSize(width=1792, height=1024), - "4:7": ExternalImageSize(width=1024, height=1792), - }, - ), - default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), - panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), -) -SEEDREAM_ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"] -SEEDREAM_2K_SIZES = { - "1:1": ExternalImageSize(width=2048, height=2048), - "3:4": ExternalImageSize(width=1728, height=2304), - "4:3": ExternalImageSize(width=2304, height=1728), - "16:9": ExternalImageSize(width=2848, height=1600), - "9:16": ExternalImageSize(width=1600, height=2848), - "3:2": ExternalImageSize(width=2496, height=1664), - "2:3": ExternalImageSize(width=1664, height=2496), - "21:9": ExternalImageSize(width=3136, height=1344), -} -SEEDREAM_1K_SIZES = { - "1:1": ExternalImageSize(width=1024, height=1024), - "3:4": ExternalImageSize(width=864, height=1152), - "4:3": ExternalImageSize(width=1152, height=864), - "16:9": ExternalImageSize(width=1312, height=736), - "9:16": ExternalImageSize(width=736, height=1312), - "2:3": ExternalImageSize(width=832, height=1248), - "3:2": ExternalImageSize(width=1248, height=832), - "21:9": ExternalImageSize(width=1568, height=672), -} -SEEDREAM_PANEL_SCHEMA = ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]) -seedream_5_0 = StarterModel( - name="Seedream 5.0", - base=BaseModelType.External, - source="external://seedream/seedream-5-0-260128", - description="BytePlus Seedream 5.0 flagship image generation model (external API). Supports 2K and 4K resolutions, txt2img and img2img with multi-image reference input.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_reference_images=14, - max_images_per_request=15, - allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, - aspect_ratio_sizes=SEEDREAM_2K_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), - panel_schema=SEEDREAM_PANEL_SCHEMA, -) -seedream_5_0_lite = StarterModel( - name="Seedream 5.0 Lite", - base=BaseModelType.External, - source="external://seedream/seedream-5-0-lite-260128", - description="BytePlus Seedream 5.0 Lite image generation model (external API). Supports 2K and 4K resolutions, txt2img and img2img with multi-image reference input.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_reference_images=14, - max_images_per_request=15, - allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, - aspect_ratio_sizes=SEEDREAM_2K_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), - panel_schema=SEEDREAM_PANEL_SCHEMA, -) -seedream_4_5 = StarterModel( - name="Seedream 4.5", - base=BaseModelType.External, - source="external://seedream/seedream-4-5-251128", - description="BytePlus Seedream 4.5 image generation model (external API). Supports 2K and 4K resolutions, txt2img, img2img, batch generation, and multi-image reference input.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_reference_images=14, - max_images_per_request=15, - allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, - aspect_ratio_sizes=SEEDREAM_2K_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), - panel_schema=SEEDREAM_PANEL_SCHEMA, -) -seedream_4_0 = StarterModel( - name="Seedream 4.0", - base=BaseModelType.External, - source="external://seedream/seedream-4-0-250828", - description="BytePlus Seedream 4.0 image generation model (external API). Supports 1K, 2K, and 4K resolutions, txt2img, img2img, batch generation, and multi-image reference input.", - type=ModelType.ExternalImageGenerator, - format=ModelFormat.ExternalApi, - capabilities=ExternalModelCapabilities( - modes=["txt2img", "img2img"], - supports_reference_images=True, - max_reference_images=14, - max_images_per_request=15, - allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, - aspect_ratio_sizes=SEEDREAM_2K_SIZES, - ), - default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), - panel_schema=SEEDREAM_PANEL_SCHEMA, -) -# Seedream 3.0 T2I (seedream-3-0-t2i-250415) removed — deprecated by BytePlus, replaced by seedream-4-0-250828. - -# DALL-E 2 removed — deprecated by OpenAI, shutdown May 12, 2026. -# region Anima -anima_qwen3_encoder = StarterModel( - name="Anima Qwen3 0.6B Text Encoder", - base=BaseModelType.Any, - source="https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/text_encoders/qwen_3_06b_base.safetensors", - description="Qwen3 0.6B text encoder for Anima. ~1.2GB", - type=ModelType.Qwen3Encoder, - format=ModelFormat.Checkpoint, -) - -anima_vae = StarterModel( - name="Anima QwenImage VAE", - base=BaseModelType.Anima, - source="https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/vae/qwen_image_vae.safetensors", - description="QwenImage VAE for Anima (fine-tuned Wan 2.1 VAE, 16 latent channels). ~200MB", - type=ModelType.VAE, - format=ModelFormat.Checkpoint, -) - -anima_base = StarterModel( - name="Anima Base 1.0", - base=BaseModelType.Anima, - source="https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/diffusion_models/anima-base-v1.0.safetensors", - description="Anima Base 1.0 - 2B parameter anime-focused text-to-image model built on Cosmos Predict2 DiT. ~4.5GB", - type=ModelType.Main, - format=ModelFormat.Checkpoint, - dependencies=[anima_qwen3_encoder, anima_vae], -) - -anima_lllite_inpainting = StarterModel( - name="Anima LLLite Inpainting", - base=BaseModelType.Anima, - source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-inpainting-v2.safetensors", - description="ControlNet-LLLite inpainting adapter for Anima by kohya-ss. Conditions the model on the masked image content during inpainting/outpainting. ~66MB", - type=ModelType.ControlNet, - format=ModelFormat.Checkpoint, -) - -anima_lllite_sketch = StarterModel( - name="Anima LLLite Sketch", - base=BaseModelType.Anima, - source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-any-test-like-v2.safetensors", - description="ControlNet-LLLite control adapter for Anima by kohya-ss. Trained on mixed scribble/HED/lineart/grayscale conditioning images. ~16MB", - type=ModelType.ControlNet, - format=ModelFormat.Checkpoint, -) - -anima_lllite_depth_preview3 = StarterModel( - name="Anima LLLite Depth (Preview3)", - base=BaseModelType.Anima, - source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-depth-1.safetensors", - description="ControlNet-LLLite depth adapter for Anima by kohya-ss. Trained on the Preview3 build; reduced quality on Anima Base 1.0. ~8MB", - type=ModelType.ControlNet, - format=ModelFormat.Checkpoint, -) - -anima_lllite_scribble_preview3 = StarterModel( - name="Anima LLLite Scribble (Preview3)", - base=BaseModelType.Anima, - source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-scribble-1.safetensors", - description="ControlNet-LLLite scribble adapter for Anima by kohya-ss. Trained on the Preview3 build; reduced quality on Anima Base 1.0. ~8MB", - type=ModelType.ControlNet, - format=ModelFormat.Checkpoint, -) - -anima_lllite_lineart_preview3 = StarterModel( - name="Anima LLLite Lineart (Preview3)", - base=BaseModelType.Anima, - source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-lineart-1.safetensors", - description="ControlNet-LLLite lineart adapter for Anima by kohya-ss. Trained on the Preview3 build; reduced quality on Anima Base 1.0. ~8MB", - type=ModelType.ControlNet, - format=ModelFormat.Checkpoint, -) - -anima_lllite_pose_preview3 = StarterModel( - name="Anima LLLite Pose (Preview3)", - base=BaseModelType.Anima, - source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-pose-1.safetensors", - description="ControlNet-LLLite pose adapter for Anima by kohya-ss. Trained on the Preview3 build; notably weak on Anima Base 1.0. ~23MB", - type=ModelType.ControlNet, - format=ModelFormat.Checkpoint, -) -# endregion - -# region Ideogram 4 -# Self-contained diffusers pipelines (both transformers + Qwen3-VL text encoder + VAE in one folder), so -# no separate dependencies. Gated, non-commercial license: the license must be accepted on the -# HuggingFace model page and a HuggingFace token configured before the download will succeed — same as -# FLUX.1 dev. -ideogram_4_nf4 = StarterModel( - name="Ideogram 4 (nf4)", - base=BaseModelType.Ideogram4, - source="ideogram-ai/ideogram-4-nf4", - description="Ideogram 4 text-to-image in nf4-quantized Diffusers format (CUDA only). Structured JSON " - "prompting with regional layout control. Non-commercial license — accept it on HuggingFace first. ~16GB", - type=ModelType.Main, -) - -ideogram_4_fp8 = StarterModel( - name="Ideogram 4 (fp8)", - base=BaseModelType.Ideogram4, - source="ideogram-ai/ideogram-4-fp8", - description="Ideogram 4 text-to-image in fp8-quantized Diffusers format (runs on any device, higher " - "memory use). Non-commercial license — accept it on HuggingFace first. ~26GB", - type=ModelType.Main, -) -# endregion - -# List of starter models, displayed on the frontend. -# The order/sort of this list is not changed by the frontend - set it how you want it here. -STARTER_MODELS: list[StarterModel] = [ - flux_kontext_quantized, - flux_schnell_quantized, - flux_dev_quantized, - flux_schnell, - flux_dev, - flux_schnell_sdnq, - sd35_medium, - sd35_large, - ideogram_4_nf4, - ideogram_4_fp8, - cyberrealistic_sd1, - rev_animated_sd1, - dreamshaper_8_sd1, - dreamshaper_8_inpainting_sd1, - deliberate_sd1, - deliberate_inpainting_sd1, - juggernaut_sdxl, - dreamshaper_sdxl, - archvis_sdxl, - sdxl_refiner, - sdxl_fp16_vae_fix, - flux_vae, - alien_lora_sdxl, - noodle_lora_sdxl, - easy_neg_sd1, - ip_adapter_sd1, - ip_adapter_plus_sd1, - ip_adapter_plus_face_sd1, - ip_adapter_sdxl, - ip_adapter_plus_sdxl, - ip_adapter_flux, - qr_code_cnet_sd1, - qr_code_cnet_sdxl, - canny_sd1, - inpaint_cnet_sd1, - mlsd_sd1, - depth_sd1, - normal_bae_sd1, - seg_sd1, - lineart_sd1, - lineart_anime_sd1, - openpose_sd1, - scribble_sd1, - softedge_sd1, - shuffle_sd1, - tile_sd1, - canny_sdxl, - depth_sdxl, - softedge_sdxl, - openpose_sdxl, - scribble_sdxl, - tile_sdxl, - union_cnet_sdxl, - union_cnet_flux, - flux_canny_control_lora, - flux_depth_control_lora, - t2i_canny_sd1, - t2i_sketch_sd1, - t2i_depth_sd1, - t2i_canny_sdxl, - t2i_lineart_sdxl, - t2i_sketch_sdxl, - realesrgan_x4, - animesharp_v4_rcan, - realesrgan_x2, - swinir, - t5_base_encoder, - t5_8b_quantized_encoder, - t5_gguf_q3_k_s_encoder, - t5_gguf_q6_k_encoder, - clip_l_encoder, - clip_vit_l_image_encoder, - siglip, - flux_redux, - llava_onevision, - llava_onevision_7b, - qwen2_5_1_5b_instruct, - qwen2_5_3b_instruct, - smollm2_1_7b_instruct, - flux_fill, - flux2_vae, - flux2_klein_4b, - flux2_klein_4b_single, - flux2_klein_4b_fp8, - flux2_klein_9b, - flux2_klein_9b_fp8, - flux2_klein_4b_sdnq, - flux2_klein_9b_sdnq, - flux2_klein_4b_gguf_q4, - flux2_klein_4b_gguf_q8, - flux2_klein_9b_gguf_q4, - flux2_klein_9b_gguf_q8, - flux2_klein_qwen3_4b_encoder, - flux2_klein_qwen3_8b_encoder, - flux2_dev_comfy_mistral_bf16, - flux2_dev_comfy_mistral_fp4, - flux2_dev_comfy_mistral_fp8, - flux2_dev_cow_mistral_iq4_xs, - flux2_dev_cow_mistral_q4, - flux2_dev_cow_mistral_q8, - flux2_dev_diffusers, - flux2_dev_diffusers_nf4, - flux2_dev_gguf_q3_k_m, - flux2_dev_gguf_q4_k_m, - flux2_dev_gguf_q5_k_m, - flux2_dev_gguf_q6_k, - flux2_dev_gguf_q8_0, - cogview4, - qwen_image_vae, - qwen_vl_encoder_fp8, - qwen_vl_encoder_diffusers, - qwen_image_edit, - qwen_image_edit_gguf_q2_k, - qwen_image_edit_gguf_q4_k_m, - qwen_image_edit_gguf_q6_k, - qwen_image_edit_gguf_q8_0, - qwen_image_edit_lightning_4step, - qwen_image_edit_lightning_8step, - qwen_image, - qwen_image_gguf_q2_k, - qwen_image_gguf_q4_k_m, - qwen_image_gguf_q6_k, - qwen_image_gguf_q8_0, - qwen_image_lightning_4step, - qwen_image_lightning_8step, - flux_krea, - flux_krea_quantized, - z_image_turbo, - z_image_turbo_quantized, - z_image_turbo_q8, - z_image_turbo_sdnq, - z_image_qwen3_encoder, - z_image_qwen3_encoder_quantized, - z_image_controlnet_union, - z_image_controlnet_tile, - ernie_image, - ernie_image_turbo, - krea2_turbo, - krea2_raw, - krea2_turbo_gguf_q4_k_m, - krea2_turbo_gguf_q8_0, - qwen3_vl_encoder_4b, - wan_22_t5_encoder, - wan_22_a14b_vae, - wan_22_5b_vae, - wan_22_t2v_a14b_diffusers, - wan_22_t2v_a14b_low_gguf_q4_k_m, - wan_22_t2v_a14b_gguf_q4_k_m, - wan_22_t2v_a14b_low_gguf_q8_0, - wan_22_t2v_a14b_gguf_q8_0, - wan_22_t2v_lightning_high, - wan_22_t2v_lightning_low, - wan_22_i2v_a14b_diffusers, - wan_22_i2v_a14b_low_gguf_q4_k_m, - wan_22_i2v_a14b_gguf_q4_k_m, - wan_22_i2v_a14b_low_gguf_q8_0, - wan_22_i2v_a14b_gguf_q8_0, - wan_22_i2v_lightning_high, - wan_22_i2v_lightning_low, - wan_22_ti2v_5b_diffusers, - wan_22_ti2v_5b_gguf_q4_k_m, - wan_22_ti2v_5b_gguf_q8_0, - minimax_h3_int8_transformer, - minimax_h3_int8_text_encoder, - minimax_h3_components, - minimax_h3_turbo_lora, - gemini_flash_image, - gemini_pro_image_preview, - gemini_3_1_flash_image_preview, - openai_gpt_image_2, - openai_gpt_image_1_5, - openai_gpt_image_1, - openai_gpt_image_1_mini, - openai_dall_e_3, - seedream_5_0, - seedream_5_0_lite, - seedream_4_5, - seedream_4_0, - alibabacloud_qwen_image_2_pro, - alibabacloud_qwen_image_2, - alibabacloud_qwen_image_max, - alibabacloud_wan26_t2i, - alibabacloud_qwen_image_edit_max, - anima_base, - anima_qwen3_encoder, - anima_vae, - anima_lllite_inpainting, - anima_lllite_sketch, - anima_lllite_depth_preview3, - anima_lllite_scribble_preview3, - anima_lllite_lineart_preview3, - anima_lllite_pose_preview3, - gemma2_2b_encoder, - pid_decoder_flux_2k, - pid_decoder_flux_2kto4k, - pid_decoder_flux2_2k, - pid_decoder_flux2_2kto4k, - pid_decoder_sd3_2k, - pid_decoder_sd3_2kto4k, - pid_decoder_sdxl_2kto4k, - pid_decoder_qwenimage_2kto4k, -] - -sd1_bundle: list[StarterModel] = [ - dreamshaper_8_sd1, - easy_neg_sd1, - ip_adapter_sd1, - ip_adapter_plus_sd1, - ip_adapter_plus_face_sd1, - canny_sd1, - inpaint_cnet_sd1, - mlsd_sd1, - depth_sd1, - normal_bae_sd1, - seg_sd1, - lineart_sd1, - lineart_anime_sd1, - openpose_sd1, - scribble_sd1, - softedge_sd1, - shuffle_sd1, - tile_sd1, - swinir, -] - -sdxl_bundle: list[StarterModel] = [ - juggernaut_sdxl, - sdxl_fp16_vae_fix, - ip_adapter_sdxl, - ip_adapter_plus_sdxl, - canny_sdxl, - depth_sdxl, - softedge_sdxl, - openpose_sdxl, - scribble_sdxl, - tile_sdxl, - swinir, -] - -flux_bundle: list[StarterModel] = [ - flux_schnell_quantized, - flux_dev_quantized, - flux_vae, - t5_8b_quantized_encoder, - clip_l_encoder, - union_cnet_flux, - ip_adapter_flux, - flux_canny_control_lora, - flux_depth_control_lora, - flux_redux, - flux_fill, - flux_kontext_quantized, - flux_krea_quantized, -] - -zimage_bundle: list[StarterModel] = [ - z_image_turbo_quantized, - z_image_qwen3_encoder_quantized, - z_image_controlnet_union, - z_image_controlnet_tile, - flux_vae, -] - -flux2_klein_bundle: list[StarterModel] = [ - flux2_klein_4b_gguf_q4, - flux2_vae, - flux2_klein_qwen3_4b_encoder, -] - -# Turbo only: both checkpoints are 8B and the full pipeline is a large download, so the bundle -# ships the fast default. The undistilled `ernie_image` is still installable individually. -ernie_image_bundle: list[StarterModel] = [ - ernie_image_turbo, -] - -qwen_image_bundle: list[StarterModel] = [ - qwen_image_vae, - qwen_vl_encoder_fp8, - qwen_image_edit, - qwen_image_edit_gguf_q4_k_m, - qwen_image_edit_gguf_q8_0, - qwen_image_edit_lightning_4step, - qwen_image_edit_lightning_8step, - qwen_image, - qwen_image_gguf_q4_k_m, - qwen_image_gguf_q8_0, - qwen_image_lightning_4step, - qwen_image_lightning_8step, -] - -anima_bundle: list[StarterModel] = [ - anima_base, - anima_qwen3_encoder, - anima_vae, - anima_lllite_inpainting, - anima_lllite_sketch, -] - -krea2_bundle: list[StarterModel] = [ - qwen_image_vae, - qwen3_vl_encoder_4b, - krea2_turbo, - krea2_raw, - krea2_turbo_gguf_q4_k_m, - krea2_turbo_gguf_q8_0, -] - -# Wan 2.2 starter bundles. Split into T2V and I2V so users only pay for the -# capability they need: a 12 GB card can install just the T2V bundle and have -# both text-to-video (T2V-A14B) and a low-VRAM image-to-video option (via -# TI2V-5B, which handles both modes in one ~3.4 GB model). The I2V bundle adds -# the heavier I2V-A14B path for users with more headroom. Q8 variants and full -# Diffusers builds stay available as a-la-carte starters. -wan_t2v_bundle: list[StarterModel] = [ - wan_22_t5_encoder, - wan_22_a14b_vae, - wan_22_5b_vae, - wan_22_ti2v_5b_gguf_q4_k_m, - wan_22_t2v_a14b_gguf_q4_k_m, - wan_22_t2v_a14b_low_gguf_q4_k_m, - wan_22_t2v_lightning_high, - wan_22_t2v_lightning_low, -] -wan_i2v_bundle: list[StarterModel] = [ - wan_22_t5_encoder, - wan_22_a14b_vae, - wan_22_i2v_a14b_gguf_q4_k_m, - wan_22_i2v_a14b_low_gguf_q4_k_m, - wan_22_i2v_lightning_high, - wan_22_i2v_lightning_low, -] - -# nf4 is the recommended 24GB CUDA path; the fp8 build is offered separately for non-CUDA / more VRAM. -ideogram_bundle: list[StarterModel] = [ - ideogram_4_nf4, -] - -# The minimal working set for MiniMax H3 video+audio generation (~59 GB): shared components from -# the official repo plus Comfy-Org's int8 single-file transformer and text encoder. See the -# license note in the MiniMax H3 region above. -minimax_h3_bundle: list[StarterModel] = [ - minimax_h3_components, - minimax_h3_int8_text_encoder, - minimax_h3_int8_transformer, -] - -STARTER_BUNDLES: dict[str, StarterModelBundle] = { - BaseModelType.StableDiffusion1: StarterModelBundle(name="Stable Diffusion 1.5", models=sd1_bundle), - BaseModelType.StableDiffusionXL: StarterModelBundle(name="SDXL", models=sdxl_bundle), - BaseModelType.Flux: StarterModelBundle(name="FLUX.1 dev", models=flux_bundle), - BaseModelType.Flux2: StarterModelBundle(name="FLUX.2 Klein", models=flux2_klein_bundle), - BaseModelType.ZImage: StarterModelBundle(name="Z-Image Turbo", models=zimage_bundle), - BaseModelType.ErnieImage: StarterModelBundle(name="ERNIE-Image", models=ernie_image_bundle), - BaseModelType.QwenImage: StarterModelBundle(name="Qwen Image", models=qwen_image_bundle), - BaseModelType.Anima: StarterModelBundle(name="Anima", models=anima_bundle), - BaseModelType.Krea2: StarterModelBundle(name="Krea-2", models=krea2_bundle), - "wan_t2v": StarterModelBundle(name="Wan 2.2 Text-to-Video", models=wan_t2v_bundle), - "wan_i2v": StarterModelBundle(name="Wan 2.2 Image-to-Video", models=wan_i2v_bundle), - BaseModelType.MiniMaxH3: StarterModelBundle(name="MiniMax H3", models=minimax_h3_bundle), - BaseModelType.Ideogram4: StarterModelBundle(name="Ideogram 4", models=ideogram_bundle), -} - -assert len(STARTER_MODELS) == len({m.source for m in STARTER_MODELS}), "Duplicate starter models" diff --git a/invokeai/backend/model_manager/starter_models/__init__.py b/invokeai/backend/model_manager/starter_models/__init__.py new file mode 100644 index 00000000000..d9ea1c27378 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/__init__.py @@ -0,0 +1,1051 @@ +"""The starter model catalogue. + +`STARTER_MODELS` is a curated order, not a derived one: it is what the install dialog shows, in +the sequence someone decided on. It stays written out here rather than assembled from the +per-architecture modules, because assembling it would lose that sequence and nothing could +reconstruct it. + +Every name is re-exported, so `from ...starter_models import ` keeps working. +""" + +from invokeai.backend.model_manager.starter_models.anima import ( + anima_base as anima_base, +) +from invokeai.backend.model_manager.starter_models.anima import ( + anima_lllite_depth_preview3 as anima_lllite_depth_preview3, +) +from invokeai.backend.model_manager.starter_models.anima import ( + anima_lllite_inpainting as anima_lllite_inpainting, +) +from invokeai.backend.model_manager.starter_models.anima import ( + anima_lllite_lineart_preview3 as anima_lllite_lineart_preview3, +) +from invokeai.backend.model_manager.starter_models.anima import ( + anima_lllite_pose_preview3 as anima_lllite_pose_preview3, +) +from invokeai.backend.model_manager.starter_models.anima import ( + anima_lllite_scribble_preview3 as anima_lllite_scribble_preview3, +) +from invokeai.backend.model_manager.starter_models.anima import ( + anima_lllite_sketch as anima_lllite_sketch, +) +from invokeai.backend.model_manager.starter_models.anima import ( + anima_vae as anima_vae, +) +from invokeai.backend.model_manager.starter_models.cogview4 import ( + cogview4 as cogview4, +) +from invokeai.backend.model_manager.starter_models.common import ( + anima_qwen3_encoder as anima_qwen3_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + animesharp_v4_rcan as animesharp_v4_rcan, +) +from invokeai.backend.model_manager.starter_models.common import ( + clip_l_encoder as clip_l_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + clip_vit_l_image_encoder as clip_vit_l_image_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + esrgan_srx4 as esrgan_srx4, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_dev_comfy_mistral_bf16 as flux2_dev_comfy_mistral_bf16, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_dev_comfy_mistral_fp4 as flux2_dev_comfy_mistral_fp4, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_dev_comfy_mistral_fp8 as flux2_dev_comfy_mistral_fp8, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_dev_cow_mistral_iq4_xs as flux2_dev_cow_mistral_iq4_xs, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_dev_cow_mistral_q4 as flux2_dev_cow_mistral_q4, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_dev_cow_mistral_q8 as flux2_dev_cow_mistral_q8, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_klein_qwen3_4b_encoder as flux2_klein_qwen3_4b_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + flux2_klein_qwen3_8b_encoder as flux2_klein_qwen3_8b_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + gemma2_2b_encoder as gemma2_2b_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + ip_adapter_sd_image_encoder as ip_adapter_sd_image_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + ip_adapter_sdxl_image_encoder as ip_adapter_sdxl_image_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + llava_onevision as llava_onevision, +) +from invokeai.backend.model_manager.starter_models.common import ( + llava_onevision_7b as llava_onevision_7b, +) +from invokeai.backend.model_manager.starter_models.common import ( + qwen2_5_1_5b_instruct as qwen2_5_1_5b_instruct, +) +from invokeai.backend.model_manager.starter_models.common import ( + qwen2_5_3b_instruct as qwen2_5_3b_instruct, +) +from invokeai.backend.model_manager.starter_models.common import ( + qwen3_vl_encoder_4b as qwen3_vl_encoder_4b, +) +from invokeai.backend.model_manager.starter_models.common import ( + qwen_vl_encoder_diffusers as qwen_vl_encoder_diffusers, +) +from invokeai.backend.model_manager.starter_models.common import ( + qwen_vl_encoder_fp8 as qwen_vl_encoder_fp8, +) +from invokeai.backend.model_manager.starter_models.common import ( + realesrgan_x2 as realesrgan_x2, +) +from invokeai.backend.model_manager.starter_models.common import ( + realesrgan_x4 as realesrgan_x4, +) +from invokeai.backend.model_manager.starter_models.common import ( + siglip as siglip, +) +from invokeai.backend.model_manager.starter_models.common import ( + smollm2_1_7b_instruct as smollm2_1_7b_instruct, +) +from invokeai.backend.model_manager.starter_models.common import ( + swinir as swinir, +) +from invokeai.backend.model_manager.starter_models.common import ( + t5_8b_quantized_encoder as t5_8b_quantized_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + t5_base_encoder as t5_base_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + t5_gguf_q3_k_s_encoder as t5_gguf_q3_k_s_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + t5_gguf_q6_k_encoder as t5_gguf_q6_k_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + wan_22_t5_encoder as wan_22_t5_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + z_image_qwen3_encoder as z_image_qwen3_encoder, +) +from invokeai.backend.model_manager.starter_models.common import ( + z_image_qwen3_encoder_quantized as z_image_qwen3_encoder_quantized, +) +from invokeai.backend.model_manager.starter_models.ernie_image import ( + ernie_image as ernie_image, +) +from invokeai.backend.model_manager.starter_models.ernie_image import ( + ernie_image_turbo as ernie_image_turbo, +) +from invokeai.backend.model_manager.starter_models.external import ( + GEMINI_3_1_FLASH_RESOLUTION_PRESETS as GEMINI_3_1_FLASH_RESOLUTION_PRESETS, +) +from invokeai.backend.model_manager.starter_models.external import ( + GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS as GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS, +) +from invokeai.backend.model_manager.starter_models.external import ( + GEMINI_3_IMAGE_MAX_SIZE as GEMINI_3_IMAGE_MAX_SIZE, +) +from invokeai.backend.model_manager.starter_models.external import ( + GEMINI_3_PRO_RESOLUTION_PRESETS as GEMINI_3_PRO_RESOLUTION_PRESETS, +) +from invokeai.backend.model_manager.starter_models.external import ( + OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES as OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, +) +from invokeai.backend.model_manager.starter_models.external import ( + OPENAI_GPT_IMAGE_ASPECT_RATIOS as OPENAI_GPT_IMAGE_ASPECT_RATIOS, +) +from invokeai.backend.model_manager.starter_models.external import ( + OPENAI_GPT_IMAGE_PANEL_SCHEMA as OPENAI_GPT_IMAGE_PANEL_SCHEMA, +) +from invokeai.backend.model_manager.starter_models.external import ( + QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS as QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS, +) +from invokeai.backend.model_manager.starter_models.external import ( + QWEN_IMAGE_MAX_ALLOWED_ASPECT_RATIOS as QWEN_IMAGE_MAX_ALLOWED_ASPECT_RATIOS, +) +from invokeai.backend.model_manager.starter_models.external import ( + SEEDREAM_1K_SIZES as SEEDREAM_1K_SIZES, +) +from invokeai.backend.model_manager.starter_models.external import ( + SEEDREAM_2K_SIZES as SEEDREAM_2K_SIZES, +) +from invokeai.backend.model_manager.starter_models.external import ( + SEEDREAM_ASPECT_RATIOS as SEEDREAM_ASPECT_RATIOS, +) +from invokeai.backend.model_manager.starter_models.external import ( + SEEDREAM_PANEL_SCHEMA as SEEDREAM_PANEL_SCHEMA, +) +from invokeai.backend.model_manager.starter_models.external import ( + WAN_V2_ALLOWED_ASPECT_RATIOS as WAN_V2_ALLOWED_ASPECT_RATIOS, +) +from invokeai.backend.model_manager.starter_models.external import ( + _gemini_3_resolution_presets as _gemini_3_resolution_presets, +) +from invokeai.backend.model_manager.starter_models.external import ( + alibabacloud_qwen_image_2 as alibabacloud_qwen_image_2, +) +from invokeai.backend.model_manager.starter_models.external import ( + alibabacloud_qwen_image_2_pro as alibabacloud_qwen_image_2_pro, +) +from invokeai.backend.model_manager.starter_models.external import ( + alibabacloud_qwen_image_edit_max as alibabacloud_qwen_image_edit_max, +) +from invokeai.backend.model_manager.starter_models.external import ( + alibabacloud_qwen_image_max as alibabacloud_qwen_image_max, +) +from invokeai.backend.model_manager.starter_models.external import ( + alibabacloud_wan26_t2i as alibabacloud_wan26_t2i, +) +from invokeai.backend.model_manager.starter_models.external import ( + gemini_3_1_flash_image_preview as gemini_3_1_flash_image_preview, +) +from invokeai.backend.model_manager.starter_models.external import ( + gemini_flash_image as gemini_flash_image, +) +from invokeai.backend.model_manager.starter_models.external import ( + gemini_pro_image_preview as gemini_pro_image_preview, +) +from invokeai.backend.model_manager.starter_models.external import ( + openai_dall_e_3 as openai_dall_e_3, +) +from invokeai.backend.model_manager.starter_models.external import ( + openai_gpt_image_1 as openai_gpt_image_1, +) +from invokeai.backend.model_manager.starter_models.external import ( + openai_gpt_image_1_5 as openai_gpt_image_1_5, +) +from invokeai.backend.model_manager.starter_models.external import ( + openai_gpt_image_1_mini as openai_gpt_image_1_mini, +) +from invokeai.backend.model_manager.starter_models.external import ( + openai_gpt_image_2 as openai_gpt_image_2, +) +from invokeai.backend.model_manager.starter_models.external import ( + seedream_4_0 as seedream_4_0, +) +from invokeai.backend.model_manager.starter_models.external import ( + seedream_4_5 as seedream_4_5, +) +from invokeai.backend.model_manager.starter_models.external import ( + seedream_5_0 as seedream_5_0, +) +from invokeai.backend.model_manager.starter_models.external import ( + seedream_5_0_lite as seedream_5_0_lite, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_canny_control_lora as flux_canny_control_lora, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_depth_control_lora as flux_depth_control_lora, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_dev as flux_dev, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_dev_quantized as flux_dev_quantized, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_fill as flux_fill, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_kontext as flux_kontext, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_kontext_quantized as flux_kontext_quantized, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_krea as flux_krea, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_krea_quantized as flux_krea_quantized, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_redux as flux_redux, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_schnell as flux_schnell, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_schnell_quantized as flux_schnell_quantized, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_schnell_sdnq as flux_schnell_sdnq, +) +from invokeai.backend.model_manager.starter_models.flux import ( + flux_vae as flux_vae, +) +from invokeai.backend.model_manager.starter_models.flux import ( + ip_adapter_flux as ip_adapter_flux, +) +from invokeai.backend.model_manager.starter_models.flux import ( + pid_decoder_flux_2k as pid_decoder_flux_2k, +) +from invokeai.backend.model_manager.starter_models.flux import ( + pid_decoder_flux_2kto4k as pid_decoder_flux_2kto4k, +) +from invokeai.backend.model_manager.starter_models.flux import ( + union_cnet_flux as union_cnet_flux, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_dev_diffusers as flux2_dev_diffusers, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_dev_diffusers_nf4 as flux2_dev_diffusers_nf4, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_dev_gguf_q3_k_m as flux2_dev_gguf_q3_k_m, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_dev_gguf_q4_k_m as flux2_dev_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_dev_gguf_q5_k_m as flux2_dev_gguf_q5_k_m, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_dev_gguf_q6_k as flux2_dev_gguf_q6_k, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_dev_gguf_q8_0 as flux2_dev_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_4b as flux2_klein_4b, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_4b_fp8 as flux2_klein_4b_fp8, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_4b_gguf_q4 as flux2_klein_4b_gguf_q4, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_4b_gguf_q8 as flux2_klein_4b_gguf_q8, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_4b_sdnq as flux2_klein_4b_sdnq, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_4b_single as flux2_klein_4b_single, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_9b as flux2_klein_9b, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_9b_fp8 as flux2_klein_9b_fp8, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_9b_gguf_q4 as flux2_klein_9b_gguf_q4, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_9b_gguf_q8 as flux2_klein_9b_gguf_q8, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_klein_9b_sdnq as flux2_klein_9b_sdnq, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + flux2_vae as flux2_vae, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + pid_decoder_flux2_2k as pid_decoder_flux2_2k, +) +from invokeai.backend.model_manager.starter_models.flux2 import ( + pid_decoder_flux2_2kto4k as pid_decoder_flux2_2kto4k, +) +from invokeai.backend.model_manager.starter_models.ideogram_4 import ( + ideogram_4_fp8 as ideogram_4_fp8, +) +from invokeai.backend.model_manager.starter_models.ideogram_4 import ( + ideogram_4_nf4 as ideogram_4_nf4, +) +from invokeai.backend.model_manager.starter_models.krea_2 import ( + krea2_raw as krea2_raw, +) +from invokeai.backend.model_manager.starter_models.krea_2 import ( + krea2_turbo as krea2_turbo, +) +from invokeai.backend.model_manager.starter_models.krea_2 import ( + krea2_turbo_gguf_q4_k_m as krea2_turbo_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.krea_2 import ( + krea2_turbo_gguf_q8_0 as krea2_turbo_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.minimax_h3 import ( + minimax_h3_components as minimax_h3_components, +) +from invokeai.backend.model_manager.starter_models.minimax_h3 import ( + minimax_h3_int8_text_encoder as minimax_h3_int8_text_encoder, +) +from invokeai.backend.model_manager.starter_models.minimax_h3 import ( + minimax_h3_int8_transformer as minimax_h3_int8_transformer, +) +from invokeai.backend.model_manager.starter_models.minimax_h3 import ( + minimax_h3_turbo_lora as minimax_h3_turbo_lora, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + pid_decoder_qwenimage_2kto4k as pid_decoder_qwenimage_2kto4k, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image as qwen_image, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_edit as qwen_image_edit, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_edit_gguf_q2_k as qwen_image_edit_gguf_q2_k, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_edit_gguf_q4_k_m as qwen_image_edit_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_edit_gguf_q6_k as qwen_image_edit_gguf_q6_k, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_edit_gguf_q8_0 as qwen_image_edit_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_edit_lightning_4step as qwen_image_edit_lightning_4step, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_edit_lightning_8step as qwen_image_edit_lightning_8step, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_gguf_q2_k as qwen_image_gguf_q2_k, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_gguf_q4_k_m as qwen_image_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_gguf_q6_k as qwen_image_gguf_q6_k, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_gguf_q8_0 as qwen_image_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_lightning_4step as qwen_image_lightning_4step, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_lightning_8step as qwen_image_lightning_8step, +) +from invokeai.backend.model_manager.starter_models.qwen_image import ( + qwen_image_vae as qwen_image_vae, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + canny_sd1 as canny_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + cyberrealistic_negative as cyberrealistic_negative, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + cyberrealistic_sd1 as cyberrealistic_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + deliberate_inpainting_sd1 as deliberate_inpainting_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + deliberate_sd1 as deliberate_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + depth_sd1 as depth_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + dreamshaper_8_inpainting_sd1 as dreamshaper_8_inpainting_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + dreamshaper_8_sd1 as dreamshaper_8_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + easy_neg_sd1 as easy_neg_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + inpaint_cnet_sd1 as inpaint_cnet_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + ip_adapter_plus_face_sd1 as ip_adapter_plus_face_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + ip_adapter_plus_sd1 as ip_adapter_plus_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + ip_adapter_sd1 as ip_adapter_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + lineart_anime_sd1 as lineart_anime_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + lineart_sd1 as lineart_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + mlsd_sd1 as mlsd_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + normal_bae_sd1 as normal_bae_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + openpose_sd1 as openpose_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + qr_code_cnet_sd1 as qr_code_cnet_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + rev_animated_sd1 as rev_animated_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + scribble_sd1 as scribble_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + seg_sd1 as seg_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + shuffle_sd1 as shuffle_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + softedge_sd1 as softedge_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + t2i_canny_sd1 as t2i_canny_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + t2i_depth_sd1 as t2i_depth_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + t2i_sketch_sd1 as t2i_sketch_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_1 import ( + tile_sd1 as tile_sd1, +) +from invokeai.backend.model_manager.starter_models.sd_3 import ( + pid_decoder_sd3_2k as pid_decoder_sd3_2k, +) +from invokeai.backend.model_manager.starter_models.sd_3 import ( + pid_decoder_sd3_2kto4k as pid_decoder_sd3_2kto4k, +) +from invokeai.backend.model_manager.starter_models.sd_3 import ( + sd35_large as sd35_large, +) +from invokeai.backend.model_manager.starter_models.sd_3 import ( + sd35_medium as sd35_medium, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + alien_lora_sdxl as alien_lora_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + archvis_sdxl as archvis_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + canny_sdxl as canny_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + depth_sdxl as depth_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + dreamshaper_sdxl as dreamshaper_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + ip_adapter_plus_sdxl as ip_adapter_plus_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + ip_adapter_sdxl as ip_adapter_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + juggernaut_sdxl as juggernaut_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + noodle_lora_sdxl as noodle_lora_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + openpose_sdxl as openpose_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + pid_decoder_sdxl_2kto4k as pid_decoder_sdxl_2kto4k, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + qr_code_cnet_sdxl as qr_code_cnet_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + scribble_sdxl as scribble_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + sdxl_fp16_vae_fix as sdxl_fp16_vae_fix, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + softedge_sdxl as softedge_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + t2i_canny_sdxl as t2i_canny_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + t2i_lineart_sdxl as t2i_lineart_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + t2i_sketch_sdxl as t2i_sketch_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + tile_sdxl as tile_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl import ( + union_cnet_sdxl as union_cnet_sdxl, +) +from invokeai.backend.model_manager.starter_models.sdxl_refiner import ( + sdxl_refiner as sdxl_refiner, +) +from invokeai.backend.model_manager.starter_models.types import ( + StarterModel as StarterModel, +) +from invokeai.backend.model_manager.starter_models.types import ( + StarterModelBundle as StarterModelBundle, +) +from invokeai.backend.model_manager.starter_models.types import ( + StarterModelWithoutDependencies as StarterModelWithoutDependencies, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_5b_vae as wan_22_5b_vae, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_a14b_vae as wan_22_a14b_vae, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_i2v_a14b_diffusers as wan_22_i2v_a14b_diffusers, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_i2v_a14b_gguf_q4_k_m as wan_22_i2v_a14b_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_i2v_a14b_gguf_q8_0 as wan_22_i2v_a14b_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_i2v_a14b_low_gguf_q4_k_m as wan_22_i2v_a14b_low_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_i2v_a14b_low_gguf_q8_0 as wan_22_i2v_a14b_low_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_i2v_lightning_high as wan_22_i2v_lightning_high, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_i2v_lightning_low as wan_22_i2v_lightning_low, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_t2v_a14b_diffusers as wan_22_t2v_a14b_diffusers, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_t2v_a14b_gguf_q4_k_m as wan_22_t2v_a14b_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_t2v_a14b_gguf_q8_0 as wan_22_t2v_a14b_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_t2v_a14b_low_gguf_q4_k_m as wan_22_t2v_a14b_low_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_t2v_a14b_low_gguf_q8_0 as wan_22_t2v_a14b_low_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_t2v_lightning_high as wan_22_t2v_lightning_high, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_t2v_lightning_low as wan_22_t2v_lightning_low, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_ti2v_5b_diffusers as wan_22_ti2v_5b_diffusers, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_ti2v_5b_gguf_q4_k_m as wan_22_ti2v_5b_gguf_q4_k_m, +) +from invokeai.backend.model_manager.starter_models.wan import ( + wan_22_ti2v_5b_gguf_q8_0 as wan_22_ti2v_5b_gguf_q8_0, +) +from invokeai.backend.model_manager.starter_models.z_image import ( + z_image_controlnet_tile as z_image_controlnet_tile, +) +from invokeai.backend.model_manager.starter_models.z_image import ( + z_image_controlnet_union as z_image_controlnet_union, +) +from invokeai.backend.model_manager.starter_models.z_image import ( + z_image_turbo as z_image_turbo, +) +from invokeai.backend.model_manager.starter_models.z_image import ( + z_image_turbo_q8 as z_image_turbo_q8, +) +from invokeai.backend.model_manager.starter_models.z_image import ( + z_image_turbo_quantized as z_image_turbo_quantized, +) +from invokeai.backend.model_manager.starter_models.z_image import ( + z_image_turbo_sdnq as z_image_turbo_sdnq, +) +from invokeai.backend.model_manager.taxonomy import BaseModelType + +# List of starter models, displayed on the frontend. +# The order/sort of this list is not changed by the frontend - set it how you want it here. +STARTER_MODELS: list[StarterModel] = [ + flux_kontext_quantized, + flux_schnell_quantized, + flux_dev_quantized, + flux_schnell, + flux_dev, + flux_schnell_sdnq, + sd35_medium, + sd35_large, + ideogram_4_nf4, + ideogram_4_fp8, + cyberrealistic_sd1, + rev_animated_sd1, + dreamshaper_8_sd1, + dreamshaper_8_inpainting_sd1, + deliberate_sd1, + deliberate_inpainting_sd1, + juggernaut_sdxl, + dreamshaper_sdxl, + archvis_sdxl, + sdxl_refiner, + sdxl_fp16_vae_fix, + flux_vae, + alien_lora_sdxl, + noodle_lora_sdxl, + easy_neg_sd1, + ip_adapter_sd1, + ip_adapter_plus_sd1, + ip_adapter_plus_face_sd1, + ip_adapter_sdxl, + ip_adapter_plus_sdxl, + ip_adapter_flux, + qr_code_cnet_sd1, + qr_code_cnet_sdxl, + canny_sd1, + inpaint_cnet_sd1, + mlsd_sd1, + depth_sd1, + normal_bae_sd1, + seg_sd1, + lineart_sd1, + lineart_anime_sd1, + openpose_sd1, + scribble_sd1, + softedge_sd1, + shuffle_sd1, + tile_sd1, + canny_sdxl, + depth_sdxl, + softedge_sdxl, + openpose_sdxl, + scribble_sdxl, + tile_sdxl, + union_cnet_sdxl, + union_cnet_flux, + flux_canny_control_lora, + flux_depth_control_lora, + t2i_canny_sd1, + t2i_sketch_sd1, + t2i_depth_sd1, + t2i_canny_sdxl, + t2i_lineart_sdxl, + t2i_sketch_sdxl, + realesrgan_x4, + animesharp_v4_rcan, + realesrgan_x2, + swinir, + t5_base_encoder, + t5_8b_quantized_encoder, + t5_gguf_q3_k_s_encoder, + t5_gguf_q6_k_encoder, + clip_l_encoder, + clip_vit_l_image_encoder, + siglip, + flux_redux, + llava_onevision, + llava_onevision_7b, + qwen2_5_1_5b_instruct, + qwen2_5_3b_instruct, + smollm2_1_7b_instruct, + flux_fill, + flux2_vae, + flux2_klein_4b, + flux2_klein_4b_single, + flux2_klein_4b_fp8, + flux2_klein_9b, + flux2_klein_9b_fp8, + flux2_klein_4b_sdnq, + flux2_klein_9b_sdnq, + flux2_klein_4b_gguf_q4, + flux2_klein_4b_gguf_q8, + flux2_klein_9b_gguf_q4, + flux2_klein_9b_gguf_q8, + flux2_klein_qwen3_4b_encoder, + flux2_klein_qwen3_8b_encoder, + flux2_dev_comfy_mistral_bf16, + flux2_dev_comfy_mistral_fp4, + flux2_dev_comfy_mistral_fp8, + flux2_dev_cow_mistral_iq4_xs, + flux2_dev_cow_mistral_q4, + flux2_dev_cow_mistral_q8, + flux2_dev_diffusers, + flux2_dev_diffusers_nf4, + flux2_dev_gguf_q3_k_m, + flux2_dev_gguf_q4_k_m, + flux2_dev_gguf_q5_k_m, + flux2_dev_gguf_q6_k, + flux2_dev_gguf_q8_0, + cogview4, + qwen_image_vae, + qwen_vl_encoder_fp8, + qwen_vl_encoder_diffusers, + qwen_image_edit, + qwen_image_edit_gguf_q2_k, + qwen_image_edit_gguf_q4_k_m, + qwen_image_edit_gguf_q6_k, + qwen_image_edit_gguf_q8_0, + qwen_image_edit_lightning_4step, + qwen_image_edit_lightning_8step, + qwen_image, + qwen_image_gguf_q2_k, + qwen_image_gguf_q4_k_m, + qwen_image_gguf_q6_k, + qwen_image_gguf_q8_0, + qwen_image_lightning_4step, + qwen_image_lightning_8step, + flux_krea, + flux_krea_quantized, + z_image_turbo, + z_image_turbo_quantized, + z_image_turbo_q8, + z_image_turbo_sdnq, + z_image_qwen3_encoder, + z_image_qwen3_encoder_quantized, + z_image_controlnet_union, + z_image_controlnet_tile, + ernie_image, + ernie_image_turbo, + krea2_turbo, + krea2_raw, + krea2_turbo_gguf_q4_k_m, + krea2_turbo_gguf_q8_0, + qwen3_vl_encoder_4b, + wan_22_t5_encoder, + wan_22_a14b_vae, + wan_22_5b_vae, + wan_22_t2v_a14b_diffusers, + wan_22_t2v_a14b_low_gguf_q4_k_m, + wan_22_t2v_a14b_gguf_q4_k_m, + wan_22_t2v_a14b_low_gguf_q8_0, + wan_22_t2v_a14b_gguf_q8_0, + wan_22_t2v_lightning_high, + wan_22_t2v_lightning_low, + wan_22_i2v_a14b_diffusers, + wan_22_i2v_a14b_low_gguf_q4_k_m, + wan_22_i2v_a14b_gguf_q4_k_m, + wan_22_i2v_a14b_low_gguf_q8_0, + wan_22_i2v_a14b_gguf_q8_0, + wan_22_i2v_lightning_high, + wan_22_i2v_lightning_low, + wan_22_ti2v_5b_diffusers, + wan_22_ti2v_5b_gguf_q4_k_m, + wan_22_ti2v_5b_gguf_q8_0, + minimax_h3_int8_transformer, + minimax_h3_int8_text_encoder, + minimax_h3_components, + minimax_h3_turbo_lora, + gemini_flash_image, + gemini_pro_image_preview, + gemini_3_1_flash_image_preview, + openai_gpt_image_2, + openai_gpt_image_1_5, + openai_gpt_image_1, + openai_gpt_image_1_mini, + openai_dall_e_3, + seedream_5_0, + seedream_5_0_lite, + seedream_4_5, + seedream_4_0, + alibabacloud_qwen_image_2_pro, + alibabacloud_qwen_image_2, + alibabacloud_qwen_image_max, + alibabacloud_wan26_t2i, + alibabacloud_qwen_image_edit_max, + anima_base, + anima_qwen3_encoder, + anima_vae, + anima_lllite_inpainting, + anima_lllite_sketch, + anima_lllite_depth_preview3, + anima_lllite_scribble_preview3, + anima_lllite_lineart_preview3, + anima_lllite_pose_preview3, + gemma2_2b_encoder, + pid_decoder_flux_2k, + pid_decoder_flux_2kto4k, + pid_decoder_flux2_2k, + pid_decoder_flux2_2kto4k, + pid_decoder_sd3_2k, + pid_decoder_sd3_2kto4k, + pid_decoder_sdxl_2kto4k, + pid_decoder_qwenimage_2kto4k, +] + +sd1_bundle: list[StarterModel] = [ + dreamshaper_8_sd1, + easy_neg_sd1, + ip_adapter_sd1, + ip_adapter_plus_sd1, + ip_adapter_plus_face_sd1, + canny_sd1, + inpaint_cnet_sd1, + mlsd_sd1, + depth_sd1, + normal_bae_sd1, + seg_sd1, + lineart_sd1, + lineart_anime_sd1, + openpose_sd1, + scribble_sd1, + softedge_sd1, + shuffle_sd1, + tile_sd1, + swinir, +] + +sdxl_bundle: list[StarterModel] = [ + juggernaut_sdxl, + sdxl_fp16_vae_fix, + ip_adapter_sdxl, + ip_adapter_plus_sdxl, + canny_sdxl, + depth_sdxl, + softedge_sdxl, + openpose_sdxl, + scribble_sdxl, + tile_sdxl, + swinir, +] + +flux_bundle: list[StarterModel] = [ + flux_schnell_quantized, + flux_dev_quantized, + flux_vae, + t5_8b_quantized_encoder, + clip_l_encoder, + union_cnet_flux, + ip_adapter_flux, + flux_canny_control_lora, + flux_depth_control_lora, + flux_redux, + flux_fill, + flux_kontext_quantized, + flux_krea_quantized, +] + +zimage_bundle: list[StarterModel] = [ + z_image_turbo_quantized, + z_image_qwen3_encoder_quantized, + z_image_controlnet_union, + z_image_controlnet_tile, + flux_vae, +] + +flux2_klein_bundle: list[StarterModel] = [ + flux2_klein_4b_gguf_q4, + flux2_vae, + flux2_klein_qwen3_4b_encoder, +] + +# Turbo only: both checkpoints are 8B and the full pipeline is a large download, so the bundle +# ships the fast default. The undistilled `ernie_image` is still installable individually. +ernie_image_bundle: list[StarterModel] = [ + ernie_image_turbo, +] + +qwen_image_bundle: list[StarterModel] = [ + qwen_image_vae, + qwen_vl_encoder_fp8, + qwen_image_edit, + qwen_image_edit_gguf_q4_k_m, + qwen_image_edit_gguf_q8_0, + qwen_image_edit_lightning_4step, + qwen_image_edit_lightning_8step, + qwen_image, + qwen_image_gguf_q4_k_m, + qwen_image_gguf_q8_0, + qwen_image_lightning_4step, + qwen_image_lightning_8step, +] + +anima_bundle: list[StarterModel] = [ + anima_base, + anima_qwen3_encoder, + anima_vae, + anima_lllite_inpainting, + anima_lllite_sketch, +] + +krea2_bundle: list[StarterModel] = [ + qwen_image_vae, + qwen3_vl_encoder_4b, + krea2_turbo, + krea2_raw, + krea2_turbo_gguf_q4_k_m, + krea2_turbo_gguf_q8_0, +] + +# Wan 2.2 starter bundles. Split into T2V and I2V so users only pay for the +# capability they need: a 12 GB card can install just the T2V bundle and have +# both text-to-video (T2V-A14B) and a low-VRAM image-to-video option (via +# TI2V-5B, which handles both modes in one ~3.4 GB model). The I2V bundle adds +# the heavier I2V-A14B path for users with more headroom. Q8 variants and full +# Diffusers builds stay available as a-la-carte starters. +wan_t2v_bundle: list[StarterModel] = [ + wan_22_t5_encoder, + wan_22_a14b_vae, + wan_22_5b_vae, + wan_22_ti2v_5b_gguf_q4_k_m, + wan_22_t2v_a14b_gguf_q4_k_m, + wan_22_t2v_a14b_low_gguf_q4_k_m, + wan_22_t2v_lightning_high, + wan_22_t2v_lightning_low, +] + +wan_i2v_bundle: list[StarterModel] = [ + wan_22_t5_encoder, + wan_22_a14b_vae, + wan_22_i2v_a14b_gguf_q4_k_m, + wan_22_i2v_a14b_low_gguf_q4_k_m, + wan_22_i2v_lightning_high, + wan_22_i2v_lightning_low, +] + +# nf4 is the recommended 24GB CUDA path; the fp8 build is offered separately for non-CUDA / more VRAM. +ideogram_bundle: list[StarterModel] = [ + ideogram_4_nf4, +] + +# The minimal working set for MiniMax H3 video+audio generation (~59 GB): shared components from +# the official repo plus Comfy-Org's int8 single-file transformer and text encoder. See the +# license note in the MiniMax H3 region above. +minimax_h3_bundle: list[StarterModel] = [ + minimax_h3_components, + minimax_h3_int8_text_encoder, + minimax_h3_int8_transformer, +] + +STARTER_BUNDLES: dict[str, StarterModelBundle] = { + BaseModelType.StableDiffusion1: StarterModelBundle(name="Stable Diffusion 1.5", models=sd1_bundle), + BaseModelType.StableDiffusionXL: StarterModelBundle(name="SDXL", models=sdxl_bundle), + BaseModelType.Flux: StarterModelBundle(name="FLUX.1 dev", models=flux_bundle), + BaseModelType.Flux2: StarterModelBundle(name="FLUX.2 Klein", models=flux2_klein_bundle), + BaseModelType.ZImage: StarterModelBundle(name="Z-Image Turbo", models=zimage_bundle), + BaseModelType.ErnieImage: StarterModelBundle(name="ERNIE-Image", models=ernie_image_bundle), + BaseModelType.QwenImage: StarterModelBundle(name="Qwen Image", models=qwen_image_bundle), + BaseModelType.Anima: StarterModelBundle(name="Anima", models=anima_bundle), + BaseModelType.Krea2: StarterModelBundle(name="Krea-2", models=krea2_bundle), + "wan_t2v": StarterModelBundle(name="Wan 2.2 Text-to-Video", models=wan_t2v_bundle), + "wan_i2v": StarterModelBundle(name="Wan 2.2 Image-to-Video", models=wan_i2v_bundle), + BaseModelType.MiniMaxH3: StarterModelBundle(name="MiniMax H3", models=minimax_h3_bundle), + BaseModelType.Ideogram4: StarterModelBundle(name="Ideogram 4", models=ideogram_bundle), +} + +assert len(STARTER_MODELS) == len({m.source for m in STARTER_MODELS}), "Duplicate starter models" diff --git a/invokeai/backend/model_manager/starter_models/anima.py b/invokeai/backend/model_manager/starter_models/anima.py new file mode 100644 index 00000000000..417a7c0c177 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/anima.py @@ -0,0 +1,82 @@ +"""Anima starter models.""" + +from invokeai.backend.model_manager.starter_models.common import anima_qwen3_encoder +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, +) + +anima_vae = StarterModel( + name="Anima QwenImage VAE", + base=BaseModelType.Anima, + source="https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/vae/qwen_image_vae.safetensors", + description="QwenImage VAE for Anima (fine-tuned Wan 2.1 VAE, 16 latent channels). ~200MB", + type=ModelType.VAE, + format=ModelFormat.Checkpoint, +) + +anima_base = StarterModel( + name="Anima Base 1.0", + base=BaseModelType.Anima, + source="https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/diffusion_models/anima-base-v1.0.safetensors", + description="Anima Base 1.0 - 2B parameter anime-focused text-to-image model built on Cosmos Predict2 DiT. ~4.5GB", + type=ModelType.Main, + format=ModelFormat.Checkpoint, + dependencies=[anima_qwen3_encoder, anima_vae], +) + +anima_lllite_inpainting = StarterModel( + name="Anima LLLite Inpainting", + base=BaseModelType.Anima, + source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-inpainting-v2.safetensors", + description="ControlNet-LLLite inpainting adapter for Anima by kohya-ss. Conditions the model on the masked image content during inpainting/outpainting. ~66MB", + type=ModelType.ControlNet, + format=ModelFormat.Checkpoint, +) + +anima_lllite_sketch = StarterModel( + name="Anima LLLite Sketch", + base=BaseModelType.Anima, + source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-any-test-like-v2.safetensors", + description="ControlNet-LLLite control adapter for Anima by kohya-ss. Trained on mixed scribble/HED/lineart/grayscale conditioning images. ~16MB", + type=ModelType.ControlNet, + format=ModelFormat.Checkpoint, +) + +anima_lllite_depth_preview3 = StarterModel( + name="Anima LLLite Depth (Preview3)", + base=BaseModelType.Anima, + source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-depth-1.safetensors", + description="ControlNet-LLLite depth adapter for Anima by kohya-ss. Trained on the Preview3 build; reduced quality on Anima Base 1.0. ~8MB", + type=ModelType.ControlNet, + format=ModelFormat.Checkpoint, +) + +anima_lllite_scribble_preview3 = StarterModel( + name="Anima LLLite Scribble (Preview3)", + base=BaseModelType.Anima, + source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-scribble-1.safetensors", + description="ControlNet-LLLite scribble adapter for Anima by kohya-ss. Trained on the Preview3 build; reduced quality on Anima Base 1.0. ~8MB", + type=ModelType.ControlNet, + format=ModelFormat.Checkpoint, +) + +anima_lllite_lineart_preview3 = StarterModel( + name="Anima LLLite Lineart (Preview3)", + base=BaseModelType.Anima, + source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-lineart-1.safetensors", + description="ControlNet-LLLite lineart adapter for Anima by kohya-ss. Trained on the Preview3 build; reduced quality on Anima Base 1.0. ~8MB", + type=ModelType.ControlNet, + format=ModelFormat.Checkpoint, +) + +anima_lllite_pose_preview3 = StarterModel( + name="Anima LLLite Pose (Preview3)", + base=BaseModelType.Anima, + source="https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-pose-1.safetensors", + description="ControlNet-LLLite pose adapter for Anima by kohya-ss. Trained on the Preview3 build; notably weak on Anima Base 1.0. ~23MB", + type=ModelType.ControlNet, + format=ModelFormat.Checkpoint, +) diff --git a/invokeai/backend/model_manager/starter_models/cogview4.py b/invokeai/backend/model_manager/starter_models/cogview4.py new file mode 100644 index 00000000000..4620c994223 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/cogview4.py @@ -0,0 +1,16 @@ +"""CogView 4 starter models.""" + +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelType, +) + +# region CogView4 +cogview4 = StarterModel( + name="CogView4", + base=BaseModelType.CogView4, + source="THUDM/CogView4-6B", + description="The base CogView4 model (~31GB).", + type=ModelType.Main, +) diff --git a/invokeai/backend/model_manager/starter_models/common.py b/invokeai/backend/model_manager/starter_models/common.py new file mode 100644 index 00000000000..de0f18ebe67 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/common.py @@ -0,0 +1,335 @@ +"""Starter models with no architecture of their own — CLIP encoders, upscalers, and the +like — shared by everything that needs them.""" + +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, +) + +# This is CLIP-ViT-H-14-laion2B-s32B-b79K +ip_adapter_sd_image_encoder = StarterModel( + name="IP Adapter SD1.5 Image Encoder", + base=BaseModelType.Any, + source="InvokeAI/ip_adapter_sd_image_encoder", + description="IP Adapter SD Image Encoder", + type=ModelType.CLIPVision, +) + +# This is CLIP-ViT-bigG-14-laion2B-39B-b160k +ip_adapter_sdxl_image_encoder = StarterModel( + name="IP Adapter SDXL Image Encoder", + base=BaseModelType.Any, + source="InvokeAI/ip_adapter_sdxl_image_encoder", + description="IP Adapter SDXL Image Encoder", + type=ModelType.CLIPVision, +) + +# Note: This model is installed from the same source as the CLIPEmbed model below. The model contains both the image +# encoder and the text encoder, but we need separate model entries so that they get loaded correctly. +clip_vit_l_image_encoder = StarterModel( + name="clip-vit-large-patch14", + base=BaseModelType.Any, + source="InvokeAI/clip-vit-large-patch14", + description="CLIP VIT-L Image Encoder (used by the imagemap index) ~1.7GB", + type=ModelType.CLIPVision, +) + +# region TextEncoders +t5_base_encoder = StarterModel( + name="t5_base_encoder", + base=BaseModelType.Any, + source="InvokeAI/t5-v1_1-xxl::bfloat16", + description="T5-XXL text encoder (used in FLUX pipelines). ~9.5GB", + type=ModelType.T5Encoder, +) + +t5_8b_quantized_encoder = StarterModel( + name="t5_bnb_int8_quantized_encoder", + base=BaseModelType.Any, + source="InvokeAI/t5-v1_1-xxl::bnb_llm_int8", + description="T5-XXL text encoder with bitsandbytes LLM.int8() quantization (used in FLUX pipelines). ~5GB", + type=ModelType.T5Encoder, + format=ModelFormat.BnbQuantizedLlmInt8b, +) + +t5_gguf_q3_k_s_encoder = StarterModel( + name="t5_gguf_q3_k_s_encoder", + base=BaseModelType.Any, + source="https://huggingface.co/city96/t5-v1_1-xxl-encoder-gguf/resolve/main/t5-v1_1-xxl-encoder-Q3_K_S.gguf", + description="T5-XXL text encoder, GGUF Q3_K_S quantized (used in FLUX pipelines). Smallest size for low VRAM, lower quality. ~2.1GB", + type=ModelType.T5Encoder, + format=ModelFormat.GGUFQuantized, +) + +t5_gguf_q6_k_encoder = StarterModel( + name="t5_gguf_q6_k_encoder", + base=BaseModelType.Any, + source="https://huggingface.co/city96/t5-v1_1-xxl-encoder-gguf/resolve/main/t5-v1_1-xxl-encoder-Q6_K.gguf", + description="T5-XXL text encoder, GGUF Q6_K quantized (used in FLUX pipelines). Near-lossless quality. ~3.9GB", + type=ModelType.T5Encoder, + format=ModelFormat.GGUFQuantized, +) + +clip_l_encoder = StarterModel( + name="clip-vit-large-patch14", + base=BaseModelType.Any, + source="InvokeAI/clip-vit-large-patch14-text-encoder::bfloat16", + description="CLIP-L text encoder (used in FLUX pipelines). ~250MB", + type=ModelType.CLIPEmbed, +) + +# region PiD (Pixel Diffusion Decoder) +# PiD's pretrained decoders condition on Gemma-2-2b-it caption embeddings (2304-dim). NVIDIA references the ungated +# mirror Efficient-Large-Model/gemma-2-2b-it. It is shared across all PiD backbones, so it is a dependency of each +# decoder below (and offered standalone here so it can be installed once). +gemma2_2b_encoder = StarterModel( + name="Gemma 2 2B (PiD caption encoder)", + base=BaseModelType.Any, + source="Efficient-Large-Model/gemma-2-2b-it", + description="Gemma-2-2b-it text encoder that PiD uses to condition its diffusion decode on a caption. ~5GB", + type=ModelType.Gemma2Encoder, + format=ModelFormat.Gemma2Encoder, +) + +# endregion +# region SpandrelImageToImage +animesharp_v4_rcan = StarterModel( + name="2x-AnimeSharpV4_RCAN", + base=BaseModelType.Any, + source="https://github.com/Kim2091/Kim2091-Models/releases/download/2x-AnimeSharpV4/2x-AnimeSharpV4_RCAN.safetensors", + description="A 2x upscaling model (optimized for anime images).", + type=ModelType.SpandrelImageToImage, +) + +realesrgan_x4 = StarterModel( + name="RealESRGAN_x4plus", + base=BaseModelType.Any, + source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth", + description="A Real-ESRGAN 4x upscaling model (general-purpose).", + type=ModelType.SpandrelImageToImage, +) + +esrgan_srx4 = StarterModel( + name="ESRGAN_SRx4_DF2KOST_official", + base=BaseModelType.Any, + source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.1/ESRGAN_SRx4_DF2KOST_official-ff704c30.pth", + description="The official ESRGAN 4x upscaling model.", + type=ModelType.SpandrelImageToImage, +) + +realesrgan_x2 = StarterModel( + name="RealESRGAN_x2plus", + base=BaseModelType.Any, + source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth", + description="A Real-ESRGAN 2x upscaling model (general-purpose).", + type=ModelType.SpandrelImageToImage, +) + +swinir = StarterModel( + name="SwinIR - realSR_BSRGAN_DFOWMFC_s64w8_SwinIR-L_x4_GAN", + base=BaseModelType.Any, + source="https://github.com/JingyunLiang/SwinIR/releases/download/v0.0/003_realSR_BSRGAN_DFOWMFC_s64w8_SwinIR-L_x4_GAN-with-dict-keys-params-and-params_ema.pth", + description="A SwinIR 4x upscaling model.", + type=ModelType.SpandrelImageToImage, +) + +qwen_vl_encoder_fp8 = StarterModel( + name="Qwen2.5-VL Encoder (fp8 scaled)", + base=BaseModelType.Any, + source="https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors", + description="ComfyUI's single-file FP8-scaled Qwen2.5-VL 7B encoder. Bundles the language model and " + "visual tower; tokenizer/processor are fetched from HuggingFace on first use. (~7GB)", + type=ModelType.QwenVLEncoder, + format=ModelFormat.Checkpoint, +) + +qwen_vl_encoder_diffusers = StarterModel( + name="Qwen2.5-VL Encoder (Diffusers)", + base=BaseModelType.Any, + source="Qwen/Qwen-Image-Edit-2511::text_encoder+tokenizer+processor", + description="Full-precision Qwen2.5-VL 7B encoder in Diffusers folder layout (text_encoder + tokenizer + processor). " + "Larger than the fp8 variant but no on-the-fly dequantization. (~16GB)", + type=ModelType.QwenVLEncoder, + format=ModelFormat.QwenVLEncoder, +) + +# region SigLIP +siglip = StarterModel( + name="SigLIP - google/siglip-so400m-patch14-384", + base=BaseModelType.Any, + source="google/siglip-so400m-patch14-384", + description="A SigLIP model (used by FLUX Redux).", + type=ModelType.SigLIP, +) + +# region LlavaOnevisionModel (vision-language models for Image-to-Prompt) +llava_onevision = StarterModel( + name="LLaVA Onevision Qwen2 0.5B", + base=BaseModelType.Any, + source="llava-hf/llava-onevision-qwen2-0.5b-ov-hf", + description="LLaVA Onevision vision-language model (~1 GB). Lightweight default for the Image-to-Prompt feature.", + type=ModelType.LlavaOnevision, +) + +llava_onevision_7b = StarterModel( + name="LLaVA Onevision Qwen2 7B", + base=BaseModelType.Any, + source="llava-hf/llava-onevision-qwen2-7b-ov-hf", + description="LLaVA Onevision 7B vision-language model. Larger, higher-quality alternative for Image-to-Prompt. (~16 GB)", + type=ModelType.LlavaOnevision, +) + +# region TextLLM (causal language models for Prompt Expansion) +qwen2_5_1_5b_instruct = StarterModel( + name="Qwen2.5-1.5B-Instruct", + base=BaseModelType.Any, + source="Qwen/Qwen2.5-1.5B-Instruct", + description="Qwen2.5 1.5B instruction-tuned LLM. Recommended default for the Prompt Expansion feature — small and fast. (~3 GB)", + type=ModelType.TextLLM, +) + +qwen2_5_3b_instruct = StarterModel( + name="Qwen2.5-3B-Instruct", + base=BaseModelType.Any, + source="Qwen/Qwen2.5-3B-Instruct", + description="Qwen2.5 3B instruction-tuned LLM. Better prompt expansion quality at the cost of more VRAM. (~6 GB)", + type=ModelType.TextLLM, +) + +smollm2_1_7b_instruct = StarterModel( + name="SmolLM2-1.7B-Instruct", + base=BaseModelType.Any, + source="HuggingFaceTB/SmolLM2-1.7B-Instruct", + description="SmolLM2 1.7B instruction-tuned LLM (Apache-2.0). Alternative to Qwen for prompt expansion. (~3 GB)", + type=ModelType.TextLLM, +) + +flux2_klein_qwen3_4b_encoder = StarterModel( + name="FLUX.2 Klein Qwen3 4B Encoder", + base=BaseModelType.Any, + source="black-forest-labs/FLUX.2-klein-4B::text_encoder+tokenizer", + description="Qwen3 4B text encoder for FLUX.2 Klein 4B (also compatible with Z-Image). ~8GB", + type=ModelType.Qwen3Encoder, +) + +flux2_klein_qwen3_8b_encoder = StarterModel( + name="FLUX.2 Klein Qwen3 8B Encoder", + base=BaseModelType.Any, + source="black-forest-labs/FLUX.2-klein-9B::text_encoder+tokenizer", + description="Qwen3 8B text encoder for FLUX.2 Klein 9B models. ~16GB", + type=ModelType.Qwen3Encoder, +) + +# Comfy-Org safetensors (single-file, 30-layer cow, with embedded Tekken tokenizer). +# Higher precision than the cow GGUFs and avoids the Tekken-via-HF-Hub fetch. +flux2_dev_comfy_mistral_fp8 = StarterModel( + name="FLUX.2 [dev] Mistral Encoder (Comfy FP8)", + base=BaseModelType.Any, + source="https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp8.safetensors", + description="Comfy-Org FP8 of BFL's 30-layer cow-mistral3-small. Best quality/size for prompt adherence; embeds Tekken tokenizer (no HF fetch needed). ~18GB", + type=ModelType.MistralEncoder, +) + +flux2_dev_comfy_mistral_bf16 = StarterModel( + name="FLUX.2 [dev] Mistral Encoder (Comfy BF16)", + base=BaseModelType.Any, + source="https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_bf16.safetensors", + description="Comfy-Org BF16 of BFL's 30-layer cow-mistral3-small. Reference precision; embeds Tekken tokenizer. ~35.6GB", + type=ModelType.MistralEncoder, +) + +flux2_dev_comfy_mistral_fp4 = StarterModel( + name="FLUX.2 [dev] Mistral Encoder (Comfy FP4 mixed)", + base=BaseModelType.Any, + source="https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp4_mixed.safetensors", + description="Comfy-Org FP4-mixed of BFL's 30-layer cow-mistral3-small. Smallest safetensors variant; embeds Tekken tokenizer. ~12.3GB", + type=ModelType.MistralEncoder, +) + +# gguf-org cow GGUF variants (30-layer cow, llama.cpp packaging, also embed Tekken). +# Lower memory footprint than the Comfy safetensors but slightly lower fidelity. +flux2_dev_cow_mistral_q4 = StarterModel( + name="FLUX.2 [dev] cow Mistral Encoder (GGUF Q4)", + base=BaseModelType.Any, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/cow-mistral3-small-q4_0.gguf", + description="cow-mistral3-small Q4_0 — 30-layer cow distillation BFL trained against. ~11.6GB", + type=ModelType.MistralEncoder, + format=ModelFormat.GGUFQuantized, +) + +flux2_dev_cow_mistral_q8 = StarterModel( + name="FLUX.2 [dev] cow Mistral Encoder (GGUF Q8)", + base=BaseModelType.Any, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/cow-mistral3-small-q8_0.gguf", + description="cow-mistral3-small Q8_0 — best prompt adherence among cow GGUF quants. ~20GB", + type=ModelType.MistralEncoder, + format=ModelFormat.GGUFQuantized, +) + +flux2_dev_cow_mistral_iq4_xs = StarterModel( + name="FLUX.2 [dev] cow Mistral Encoder (GGUF IQ4_XS)", + base=BaseModelType.Any, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/cow-mistral3-small-iq4_xs.gguf", + description="cow-mistral3-small IQ4_XS — smallest usable quant with reasonable adherence. ~11.1GB", + type=ModelType.MistralEncoder, + format=ModelFormat.GGUFQuantized, +) + +# region Z-Image +z_image_qwen3_encoder = StarterModel( + name="Z-Image Qwen3 Text Encoder", + base=BaseModelType.Any, + source="Tongyi-MAI/Z-Image-Turbo::text_encoder+tokenizer", + description="Qwen3 4B text encoder with tokenizer for Z-Image (full precision). ~8GB", + type=ModelType.Qwen3Encoder, +) + +z_image_qwen3_encoder_quantized = StarterModel( + name="Z-Image Qwen3 Text Encoder (quantized)", + base=BaseModelType.Any, + source="https://huggingface.co/worstplayer/Z-Image_Qwen_3_4b_text_encoder_GGUF/resolve/main/Qwen_3_4b-Q6_K.gguf", + description="Qwen3 4B text encoder for Z-Image quantized to GGUF Q6_K format. ~3.3GB", + type=ModelType.Qwen3Encoder, + format=ModelFormat.GGUFQuantized, +) + +# region Krea-2 +# Standalone Qwen3-VL text encoder used by Krea-2 (distinct from the Qwen2.5-VL encoder above). Pair +# with single-file / GGUF Krea-2 transformers, which ship only the transformer. The Qwen-Image VAE +# dependency reuses the `qwen_image_vae` starter defined in the Qwen Image region. +qwen3_vl_encoder_4b = StarterModel( + name="Qwen3-VL 4B Encoder (Diffusers)", + base=BaseModelType.Any, + source="Qwen/Qwen3-VL-4B-Instruct", + description="Qwen3-VL 4B text encoder (Qwen3VLModel) used by Krea-2, in HuggingFace folder layout " + "(includes tokenizer). Use with single-file / GGUF Krea-2 transformers. (~8GB)", + type=ModelType.Qwen3VLEncoder, + format=ModelFormat.Qwen3VLEncoder, +) + +# region Wan 2.2 (local) +# Shared components — all Wan 2.2 variants use the UMT5-XXL text encoder. A14B +# (both T2V and I2V) uses a 16-channel VAE; TI2V-5B uses a 48-channel VAE. The +# two VAEs are not interchangeable. +wan_22_t5_encoder = StarterModel( + name="Wan T5 Encoder (UMT5-XXL)", + base=BaseModelType.Any, + source="Wan-AI/Wan2.2-T2V-A14B-Diffusers::text_encoder+tokenizer", + description="UMT5-XXL text encoder used by all Wan 2.2 variants (T2V/I2V A14B and TI2V-5B). " + "Required when running a GGUF Wan main without a Diffusers Component Source. (~11GB)", + type=ModelType.WanT5Encoder, + format=ModelFormat.WanT5Encoder, +) + +# DALL-E 2 removed — deprecated by OpenAI, shutdown May 12, 2026. +# region Anima +anima_qwen3_encoder = StarterModel( + name="Anima Qwen3 0.6B Text Encoder", + base=BaseModelType.Any, + source="https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/text_encoders/qwen_3_06b_base.safetensors", + description="Qwen3 0.6B text encoder for Anima. ~1.2GB", + type=ModelType.Qwen3Encoder, + format=ModelFormat.Checkpoint, +) diff --git a/invokeai/backend/model_manager/starter_models/ernie_image.py b/invokeai/backend/model_manager/starter_models/ernie_image.py new file mode 100644 index 00000000000..48ee173fcd9 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/ernie_image.py @@ -0,0 +1,30 @@ +"""ERNIE-Image starter models.""" + +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelType, +) + +# region ERNIE-Image +ernie_image = StarterModel( + name="ERNIE-Image", + base=BaseModelType.ErnieImage, + source="baidu/ERNIE-Image", + description=( + "Baidu ERNIE-Image: 8B single-stream DiT with Mistral3 text encoder, AutoencoderKLFlux2 VAE, " + "and bundled Ministral3 prompt enhancer. Defaults to 50 steps with CFG 4.0." + ), + type=ModelType.Main, +) + +ernie_image_turbo = StarterModel( + name="ERNIE-Image Turbo", + base=BaseModelType.ErnieImage, + source="baidu/ERNIE-Image-Turbo", + description=( + "ERNIE-Image-Turbo: distilled variant of ERNIE-Image. Same architecture as ERNIE-Image but " + "tuned for fast inference at 8 steps with CFG disabled (1.0)." + ), + type=ModelType.Main, +) diff --git a/invokeai/backend/model_manager/starter_models/external.py b/invokeai/backend/model_manager/starter_models/external.py new file mode 100644 index 00000000000..7513df8821a --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/external.py @@ -0,0 +1,498 @@ +"""Starter entries for externally hosted models, and the provider-specific presets they carry.""" + +from invokeai.backend.model_manager.configs.external_api import ( + ExternalApiModelDefaultSettings, + ExternalImageSize, + ExternalModelCapabilities, + ExternalModelPanelSchema, + ExternalResolutionPreset, +) +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, +) + +# region External API +GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS = [ + "1:1", + "1:4", + "1:8", + "2:3", + "3:2", + "3:4", + "4:1", + "4:3", + "4:5", + "5:4", + "8:1", + "9:16", + "16:9", + "21:9", +] + +GEMINI_3_IMAGE_MAX_SIZE = ExternalImageSize(width=4096, height=4096) + + +def _gemini_3_resolution_presets( + image_sizes: list[str], + aspect_ratios: list[str] | None = None, +) -> list[ExternalResolutionPreset]: + """Build resolution presets for Gemini 3 models. + + Each preset combines an aspect ratio with an image size preset (512/1K/2K/4K). + Pixel dimensions are approximations based on the preset name (longest side). + """ + if aspect_ratios is None: + aspect_ratios = GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS + base_pixels = {"512": 512, "1K": 1024, "2K": 2048, "4K": 4096} + presets: list[ExternalResolutionPreset] = [] + for image_size in image_sizes: + base = base_pixels[image_size] + for ratio_str in aspect_ratios: + w_part, h_part = (int(x) for x in ratio_str.split(":")) + if w_part >= h_part: + w = base + h = max(1, round(base * h_part / w_part)) + else: + h = base + w = max(1, round(base * w_part / h_part)) + presets.append( + ExternalResolutionPreset( + label=f"{ratio_str} ({image_size}) — {w}\u00d7{h}", + aspect_ratio=ratio_str, + image_size=image_size, + width=w, + height=h, + ) + ) + return presets + + +GEMINI_3_PRO_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["1K", "2K", "4K"]) + +GEMINI_3_1_FLASH_RESOLUTION_PRESETS = _gemini_3_resolution_presets(["512", "1K", "2K", "4K"]) + +gemini_flash_image = StarterModel( + name="Gemini 2.5 Flash Image", + base=BaseModelType.External, + source="external://gemini/gemini-2.5-flash-image", + description="Google Gemini 2.5 Flash image generation model (external API). Requires a configured Gemini API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_seed=True, + supports_reference_images=True, + max_images_per_request=1, + allowed_aspect_ratios=[ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9", + ], + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1024, height=1024), + "2:3": ExternalImageSize(width=832, height=1248), + "3:2": ExternalImageSize(width=1248, height=832), + "3:4": ExternalImageSize(width=864, height=1184), + "4:3": ExternalImageSize(width=1184, height=864), + "4:5": ExternalImageSize(width=896, height=1152), + "5:4": ExternalImageSize(width=1152, height=896), + "9:16": ExternalImageSize(width=768, height=1344), + "16:9": ExternalImageSize(width=1344, height=768), + "21:9": ExternalImageSize(width=1536, height=672), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), +) + +gemini_pro_image_preview = StarterModel( + name="Gemini 3 Pro Image Preview", + base=BaseModelType.External, + source="external://gemini/gemini-3-pro-image-preview", + description="Google Gemini 3 Pro image generation preview model (external API). Supports up to 14 reference images, including up to 6 object references and up to 5 character references. Supports 1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_seed=True, + supports_reference_images=True, + max_reference_images=14, + max_images_per_request=1, + max_image_size=GEMINI_3_IMAGE_MAX_SIZE, + allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS, + resolution_presets=GEMINI_3_PRO_RESOLUTION_PRESETS, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), +) + +gemini_3_1_flash_image_preview = StarterModel( + name="Gemini 3.1 Flash Image Preview", + base=BaseModelType.External, + source="external://gemini/gemini-3.1-flash-image-preview", + description="Google Gemini 3.1 Flash image generation preview model (external API). Supports up to 14 reference images, including up to 10 object references and up to 4 character references. Supports 512/1K/2K/4K resolution presets. Requires a configured Gemini API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_seed=True, + supports_reference_images=True, + max_reference_images=14, + max_images_per_request=1, + max_image_size=GEMINI_3_IMAGE_MAX_SIZE, + allowed_aspect_ratios=GEMINI_3_IMAGE_ALLOWED_ASPECT_RATIOS, + resolution_presets=GEMINI_3_1_FLASH_RESOLUTION_PRESETS, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), +) + +QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] + +QWEN_IMAGE_MAX_ALLOWED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] + +WAN_V2_ALLOWED_ASPECT_RATIOS = ["1:1", "4:3", "3:4", "16:9", "9:16"] + +alibabacloud_qwen_image_2_pro = StarterModel( + name="Qwen Image 2.0 Pro", + base=BaseModelType.External, + source="external://alibabacloud/qwen-image-2.0-pro", + description="Alibaba Cloud Qwen Image 2.0 Pro model (external API). Best quality text-to-image with excellent bilingual text rendering. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_negative_prompt=False, + supports_seed=True, + max_images_per_request=4, + allowed_aspect_ratios=QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS, + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=2048, height=2048), + "4:3": ExternalImageSize(width=2368, height=1728), + "3:4": ExternalImageSize(width=1728, height=2368), + "16:9": ExternalImageSize(width=2688, height=1536), + "9:16": ExternalImageSize(width=1536, height=2688), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), + panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), +) + +alibabacloud_qwen_image_2 = StarterModel( + name="Qwen Image 2.0", + base=BaseModelType.External, + source="external://alibabacloud/qwen-image-2.0", + description="Alibaba Cloud Qwen Image 2.0 model (external API). Fast text-to-image with good bilingual text rendering. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_negative_prompt=False, + supports_seed=True, + max_images_per_request=4, + allowed_aspect_ratios=QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS, + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=2048, height=2048), + "4:3": ExternalImageSize(width=2368, height=1728), + "3:4": ExternalImageSize(width=1728, height=2368), + "16:9": ExternalImageSize(width=2688, height=1536), + "9:16": ExternalImageSize(width=1536, height=2688), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), + panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), +) + +alibabacloud_qwen_image_max = StarterModel( + name="Qwen Image Max", + base=BaseModelType.External, + source="external://alibabacloud/qwen-image-max", + description="Alibaba Cloud Qwen Image Max model (external API). High quality text-to-image generation. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_negative_prompt=False, + supports_seed=True, + max_images_per_request=4, + allowed_aspect_ratios=QWEN_IMAGE_MAX_ALLOWED_ASPECT_RATIOS, + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1328, height=1328), + "4:3": ExternalImageSize(width=1472, height=1104), + "3:4": ExternalImageSize(width=1104, height=1472), + "16:9": ExternalImageSize(width=1664, height=928), + "9:16": ExternalImageSize(width=928, height=1664), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=1328, height=1328, num_images=1), + panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), +) + +alibabacloud_wan26_t2i = StarterModel( + name="Wan 2.6 Text-to-Image", + base=BaseModelType.External, + source="external://alibabacloud/wan2.6-t2i", + description="Alibaba Cloud Wan 2.6 text-to-image model (external API). Photorealistic image generation. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_negative_prompt=False, + supports_seed=True, + max_images_per_request=4, + allowed_aspect_ratios=WAN_V2_ALLOWED_ASPECT_RATIOS, + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1024, height=1024), + "4:3": ExternalImageSize(width=1440, height=1080), + "3:4": ExternalImageSize(width=1080, height=1440), + "16:9": ExternalImageSize(width=1440, height=810), + "9:16": ExternalImageSize(width=810, height=1440), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), +) + +alibabacloud_qwen_image_edit_max = StarterModel( + name="Qwen Image Edit Max", + base=BaseModelType.External, + source="external://alibabacloud/qwen-image-edit-max", + description="Alibaba Cloud Qwen Image Edit Max model (external API). Image editing with industrial design and geometric reasoning, driven by up to 3 reference images. Requires a configured Alibaba Cloud DashScope API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + supports_negative_prompt=False, + supports_reference_images=True, + supports_seed=True, + max_reference_images=3, + max_images_per_request=4, + allowed_aspect_ratios=QWEN_IMAGE_2_ALLOWED_ASPECT_RATIOS, + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=2048, height=2048), + "4:3": ExternalImageSize(width=2368, height=1728), + "3:4": ExternalImageSize(width=1728, height=2368), + "16:9": ExternalImageSize(width=2688, height=1536), + "9:16": ExternalImageSize(width=1536, height=2688), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), + panel_schema=ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]), +) + +OPENAI_GPT_IMAGE_ASPECT_RATIOS = ["1:1", "3:2", "2:3"] + +OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES = { + "1:1": ExternalImageSize(width=1024, height=1024), + "3:2": ExternalImageSize(width=1536, height=1024), + "2:3": ExternalImageSize(width=1024, height=1536), +} + +OPENAI_GPT_IMAGE_PANEL_SCHEMA = ExternalModelPanelSchema( + prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}] +) + +openai_gpt_image_2 = StarterModel( + name="GPT Image 2", + base=BaseModelType.External, + source="external://openai/gpt-image-2", + description="OpenAI GPT-Image-2 image generation model. State-of-the-art image generation and editing with flexible sizing and high-fidelity image inputs. Does not support transparent backgrounds or configurable input fidelity. Requires a configured OpenAI API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_images_per_request=10, + allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, + aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, +) + +openai_gpt_image_1_5 = StarterModel( + name="GPT Image 1.5", + base=BaseModelType.External, + source="external://openai/gpt-image-1.5", + description="OpenAI GPT-Image-1.5 image generation model. Fastest and most affordable GPT image model. Requires a configured OpenAI API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_images_per_request=10, + allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, + aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, +) + +openai_gpt_image_1 = StarterModel( + name="GPT Image 1", + base=BaseModelType.External, + source="external://openai/gpt-image-1", + description="OpenAI GPT-Image-1 image generation model. High quality image generation. Requires a configured OpenAI API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_images_per_request=10, + allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, + aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, +) + +openai_gpt_image_1_mini = StarterModel( + name="GPT Image 1 Mini", + base=BaseModelType.External, + source="external://openai/gpt-image-1-mini", + description="OpenAI GPT-Image-1-Mini image generation model. Cost-efficient option, 80%% cheaper than GPT-Image-1. Requires a configured OpenAI API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_images_per_request=10, + allowed_aspect_ratios=OPENAI_GPT_IMAGE_ASPECT_RATIOS, + aspect_ratio_sizes=OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=OPENAI_GPT_IMAGE_PANEL_SCHEMA, +) + +openai_dall_e_3 = StarterModel( + name="DALL-E 3", + base=BaseModelType.External, + source="external://openai/dall-e-3", + description="OpenAI DALL-E 3 image generation model. Supports vivid and natural styles. Only text-to-image, no editing. Requires a configured OpenAI API key and may incur provider usage costs.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img"], + max_images_per_request=1, + allowed_aspect_ratios=["1:1", "7:4", "4:7"], + aspect_ratio_sizes={ + "1:1": ExternalImageSize(width=1024, height=1024), + "7:4": ExternalImageSize(width=1792, height=1024), + "4:7": ExternalImageSize(width=1024, height=1792), + }, + ), + default_settings=ExternalApiModelDefaultSettings(width=1024, height=1024, num_images=1), + panel_schema=ExternalModelPanelSchema(image=[{"name": "dimensions"}]), +) + +SEEDREAM_ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"] + +SEEDREAM_2K_SIZES = { + "1:1": ExternalImageSize(width=2048, height=2048), + "3:4": ExternalImageSize(width=1728, height=2304), + "4:3": ExternalImageSize(width=2304, height=1728), + "16:9": ExternalImageSize(width=2848, height=1600), + "9:16": ExternalImageSize(width=1600, height=2848), + "3:2": ExternalImageSize(width=2496, height=1664), + "2:3": ExternalImageSize(width=1664, height=2496), + "21:9": ExternalImageSize(width=3136, height=1344), +} + +SEEDREAM_1K_SIZES = { + "1:1": ExternalImageSize(width=1024, height=1024), + "3:4": ExternalImageSize(width=864, height=1152), + "4:3": ExternalImageSize(width=1152, height=864), + "16:9": ExternalImageSize(width=1312, height=736), + "9:16": ExternalImageSize(width=736, height=1312), + "2:3": ExternalImageSize(width=832, height=1248), + "3:2": ExternalImageSize(width=1248, height=832), + "21:9": ExternalImageSize(width=1568, height=672), +} + +SEEDREAM_PANEL_SCHEMA = ExternalModelPanelSchema(prompts=[{"name": "reference_images"}], image=[{"name": "dimensions"}]) + +seedream_5_0 = StarterModel( + name="Seedream 5.0", + base=BaseModelType.External, + source="external://seedream/seedream-5-0-260128", + description="BytePlus Seedream 5.0 flagship image generation model (external API). Supports 2K and 4K resolutions, txt2img and img2img with multi-image reference input.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_reference_images=14, + max_images_per_request=15, + allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, + aspect_ratio_sizes=SEEDREAM_2K_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), + panel_schema=SEEDREAM_PANEL_SCHEMA, +) + +seedream_5_0_lite = StarterModel( + name="Seedream 5.0 Lite", + base=BaseModelType.External, + source="external://seedream/seedream-5-0-lite-260128", + description="BytePlus Seedream 5.0 Lite image generation model (external API). Supports 2K and 4K resolutions, txt2img and img2img with multi-image reference input.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_reference_images=14, + max_images_per_request=15, + allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, + aspect_ratio_sizes=SEEDREAM_2K_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), + panel_schema=SEEDREAM_PANEL_SCHEMA, +) + +seedream_4_5 = StarterModel( + name="Seedream 4.5", + base=BaseModelType.External, + source="external://seedream/seedream-4-5-251128", + description="BytePlus Seedream 4.5 image generation model (external API). Supports 2K and 4K resolutions, txt2img, img2img, batch generation, and multi-image reference input.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_reference_images=14, + max_images_per_request=15, + allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, + aspect_ratio_sizes=SEEDREAM_2K_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), + panel_schema=SEEDREAM_PANEL_SCHEMA, +) + +seedream_4_0 = StarterModel( + name="Seedream 4.0", + base=BaseModelType.External, + source="external://seedream/seedream-4-0-250828", + description="BytePlus Seedream 4.0 image generation model (external API). Supports 1K, 2K, and 4K resolutions, txt2img, img2img, batch generation, and multi-image reference input.", + type=ModelType.ExternalImageGenerator, + format=ModelFormat.ExternalApi, + capabilities=ExternalModelCapabilities( + modes=["txt2img", "img2img"], + supports_reference_images=True, + max_reference_images=14, + max_images_per_request=15, + allowed_aspect_ratios=SEEDREAM_ASPECT_RATIOS, + aspect_ratio_sizes=SEEDREAM_2K_SIZES, + ), + default_settings=ExternalApiModelDefaultSettings(width=2048, height=2048, num_images=1), + panel_schema=SEEDREAM_PANEL_SCHEMA, +) diff --git a/invokeai/backend/model_manager/starter_models/flux.py b/invokeai/backend/model_manager/starter_models/flux.py new file mode 100644 index 00000000000..164df604060 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/flux.py @@ -0,0 +1,186 @@ +"""FLUX.1 starter models.""" + +from invokeai.backend.model_manager.starter_models.common import ( + clip_l_encoder, + clip_vit_l_image_encoder, + gemma2_2b_encoder, + siglip, + t5_8b_quantized_encoder, + t5_base_encoder, +) +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, + PiDDecoderVariantType, +) + +flux_vae = StarterModel( + name="FLUX.1-schnell_ae", + base=BaseModelType.Flux, + source="black-forest-labs/FLUX.1-schnell::ae.safetensors", + description="FLUX VAE compatible with both schnell and dev variants.", + type=ModelType.VAE, +) + +# NVIDIA PiD decoders (https://huggingface.co/nvidia/PiD). Code is Apache-2.0; weights are NSCLv1 (non-commercial / +# research). Each is a 4x super-resolution decoder that replaces the regular VAE decode and needs the Gemma-2 encoder. +pid_decoder_flux_2k = StarterModel( + name="PiD Decoder FLUX (2K)", + base=BaseModelType.Flux, + source="nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_flux_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for FLUX latents, 2K target preset (e.g. 512 -> 2048). ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +pid_decoder_flux_2kto4k = StarterModel( + name="PiD Decoder FLUX (2K to 4K)", + base=BaseModelType.Flux, + source="nvidia/PiD::checkpoints_deprecated/PiD_res2kto4k_sr4x_official_flux_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for FLUX latents, 2K-to-4K preset (legacy architecture; NVIDIA's newer v1.5 checkpoint uses a different network that is not yet supported). ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +# region: Main +flux_schnell_quantized = StarterModel( + name="FLUX.1 schnell (quantized)", + base=BaseModelType.Flux, + source="InvokeAI/flux_schnell::transformer/bnb_nf4/flux1-schnell-bnb_nf4.safetensors", + description="FLUX schnell transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB", + type=ModelType.Main, + dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], +) + +flux_dev_quantized = StarterModel( + name="FLUX.1 dev (quantized)", + base=BaseModelType.Flux, + source="InvokeAI/flux_dev::transformer/bnb_nf4/flux1-dev-bnb_nf4.safetensors", + description="FLUX dev transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB", + type=ModelType.Main, + dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], +) + +flux_schnell = StarterModel( + name="FLUX.1 schnell", + base=BaseModelType.Flux, + source="InvokeAI/flux_schnell::transformer/base/flux1-schnell.safetensors", + description="FLUX schnell transformer in bfloat16. Total size with dependencies: ~33GB", + type=ModelType.Main, + dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], +) + +flux_dev = StarterModel( + name="FLUX.1 dev", + base=BaseModelType.Flux, + source="InvokeAI/flux_dev::transformer/base/flux1-dev.safetensors", + description="FLUX dev transformer in bfloat16. Total size with dependencies: ~33GB", + type=ModelType.Main, + dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], +) + +flux_schnell_sdnq = StarterModel( + name="FLUX.1 schnell (SDNQ uint4 + SVD)", + base=BaseModelType.Flux, + source="Disty0/FLUX.1-schnell-SDNQ-uint4-svd-r32", + description="FLUX.1 schnell quantized via SDNQ to uint4 + SVD rank 32. Full self-contained " + "Flux pipeline (transformer + T5 + CLIP + VAE). ~15GB", + type=ModelType.Main, + format=ModelFormat.SDNQQuantized, +) + +flux_kontext = StarterModel( + name="FLUX.1 Kontext dev", + base=BaseModelType.Flux, + source="https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev/resolve/main/flux1-kontext-dev.safetensors", + description="FLUX.1 Kontext dev transformer in bfloat16. Total size with dependencies: ~33GB", + type=ModelType.Main, + dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], +) + +flux_kontext_quantized = StarterModel( + name="FLUX.1 Kontext dev (quantized)", + base=BaseModelType.Flux, + source="https://huggingface.co/unsloth/FLUX.1-Kontext-dev-GGUF/resolve/main/flux1-kontext-dev-Q4_K_M.gguf", + description="FLUX.1 Kontext dev quantized (q4_k_m). Total size with dependencies: ~12GB", + type=ModelType.Main, + dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], +) + +flux_krea = StarterModel( + name="FLUX.1 Krea dev", + base=BaseModelType.Flux, + source="https://huggingface.co/InvokeAI/FLUX.1-Krea-dev/resolve/main/flux1-krea-dev.safetensors", + description="FLUX.1 Krea dev. Total size with dependencies: ~29GB", + type=ModelType.Main, + dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], +) + +flux_krea_quantized = StarterModel( + name="FLUX.1 Krea dev (quantized)", + base=BaseModelType.Flux, + source="https://huggingface.co/InvokeAI/FLUX.1-Krea-dev-GGUF/resolve/main/flux1-krea-dev-Q4_K_M.gguf", + description="FLUX.1 Krea dev quantized (q4_k_m). Total size with dependencies: ~12GB", + type=ModelType.Main, + dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], +) + +ip_adapter_flux = StarterModel( + name="Standard Reference (XLabs FLUX IP-Adapter v2)", + base=BaseModelType.Flux, + source="https://huggingface.co/XLabs-AI/flux-ip-adapter-v2/resolve/main/ip_adapter.safetensors", + description="References images with a more generalized/looser degree of precision.", + type=ModelType.IPAdapter, + dependencies=[clip_vit_l_image_encoder], +) + +union_cnet_flux = StarterModel( + name="FLUX.1-dev-Controlnet-Union", + base=BaseModelType.Flux, + source="InstantX/FLUX.1-dev-Controlnet-Union", + description="A unified ControlNet for FLUX.1-dev model that supports 7 control modes, including canny (0), tile (1), depth (2), blur (3), pose (4), gray (5), low quality (6)", + type=ModelType.ControlNet, +) + +# endregion +# region Control LoRA +flux_canny_control_lora = StarterModel( + name="Hard Edge Detection (Canny)", + base=BaseModelType.Flux, + source="black-forest-labs/FLUX.1-Canny-dev-lora::flux1-canny-dev-lora.safetensors", + description="Uses detected edges in the image to control composition.", + type=ModelType.ControlLoRa, +) + +flux_depth_control_lora = StarterModel( + name="Depth Map", + base=BaseModelType.Flux, + source="black-forest-labs/FLUX.1-Depth-dev-lora::flux1-depth-dev-lora.safetensors", + description="Uses depth information in the image to control the depth in the generation.", + type=ModelType.ControlLoRa, +) + +# region FLUX Redux +flux_redux = StarterModel( + name="FLUX Redux", + base=BaseModelType.Flux, + source="black-forest-labs/FLUX.1-Redux-dev::flux1-redux-dev.safetensors", + description="FLUX Redux model (for image variation).", + type=ModelType.FluxRedux, + dependencies=[siglip], +) + +# region FLUX Fill +flux_fill = StarterModel( + name="FLUX Fill", + base=BaseModelType.Flux, + source="black-forest-labs/FLUX.1-Fill-dev::flux1-fill-dev.safetensors", + description="FLUX Fill model (for inpainting).", + type=ModelType.Main, +) diff --git a/invokeai/backend/model_manager/starter_models/flux2.py b/invokeai/backend/model_manager/starter_models/flux2.py new file mode 100644 index 00000000000..607a5abd97a --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/flux2.py @@ -0,0 +1,223 @@ +"""FLUX.2 starter models.""" + +from invokeai.backend.model_manager.starter_models.common import ( + flux2_dev_cow_mistral_q4, + flux2_dev_cow_mistral_q8, + flux2_klein_qwen3_4b_encoder, + flux2_klein_qwen3_8b_encoder, + gemma2_2b_encoder, +) +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, + PiDDecoderVariantType, +) + +# FLUX.2 Klein shares one 32-channel VAE across the 4B and 9B variants, so a single decoder per preset covers both. +# The 128-channel packed latent is unambiguous (unlike the 16ch FLUX/SD3 case), so no directory-name disambiguation +# is needed for the config probe. +pid_decoder_flux2_2k = StarterModel( + name="PiD Decoder FLUX.2 (2K)", + base=BaseModelType.Flux2, + source="nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_flux2_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for FLUX.2 Klein latents, 2K target preset (e.g. 512 -> 2048). ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +pid_decoder_flux2_2kto4k = StarterModel( + name="PiD Decoder FLUX.2 (2K to 4K)", + base=BaseModelType.Flux2, + source="nvidia/PiD::checkpoints_deprecated/PiD_res2kto4k_sr4x_official_flux2_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for FLUX.2 Klein latents, 2K-to-4K preset (legacy architecture; NVIDIA's newer v1.5 checkpoint uses a different network that is not yet supported). ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +# region FLUX.2 Klein +flux2_vae = StarterModel( + name="FLUX.2 VAE", + base=BaseModelType.Flux2, + source="black-forest-labs/FLUX.2-klein-4B::vae", + description="FLUX.2 VAE (16-channel, same architecture as FLUX.1 VAE). ~168MB", + type=ModelType.VAE, +) + +flux2_klein_4b = StarterModel( + name="FLUX.2 Klein 4B (Diffusers)", + base=BaseModelType.Flux2, + source="black-forest-labs/FLUX.2-klein-4B", + description="FLUX.2 Klein 4B in Diffusers format - includes transformer, VAE and Qwen3 encoder. ~16GB", + type=ModelType.Main, +) + +flux2_klein_4b_single = StarterModel( + name="FLUX.2 Klein 4B", + base=BaseModelType.Flux2, + source="https://huggingface.co/black-forest-labs/FLUX.2-klein-4B/resolve/main/flux-2-klein-4b.safetensors", + description="FLUX.2 Klein 4B standalone transformer. Installs with VAE and Qwen3 4B encoder. ~8GB", + type=ModelType.Main, + dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], +) + +flux2_klein_4b_fp8 = StarterModel( + name="FLUX.2 Klein 4B (FP8)", + base=BaseModelType.Flux2, + source="https://huggingface.co/black-forest-labs/FLUX.2-klein-4b-fp8/resolve/main/flux-2-klein-4b-fp8.safetensors", + description="FLUX.2 Klein 4B FP8 quantized - smaller and faster. Installs with VAE and Qwen3 4B encoder. ~4GB", + type=ModelType.Main, + dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], +) + +flux2_klein_9b = StarterModel( + name="FLUX.2 Klein 9B (Diffusers)", + base=BaseModelType.Flux2, + source="black-forest-labs/FLUX.2-klein-9B", + description="FLUX.2 Klein 9B in Diffusers format - includes transformer, VAE and Qwen3 encoder. ~35GB", + type=ModelType.Main, +) + +flux2_klein_9b_fp8 = StarterModel( + name="FLUX.2 Klein 9B (FP8)", + base=BaseModelType.Flux2, + source="https://huggingface.co/black-forest-labs/FLUX.2-klein-9b-fp8/resolve/main/flux-2-klein-9b-fp8.safetensors", + description="FLUX.2 Klein 9B FP8 quantized - more efficient than full precision. Installs with VAE and Qwen3 8B encoder. ~9.5GB", + type=ModelType.Main, + dependencies=[flux2_vae, flux2_klein_qwen3_8b_encoder], +) + +flux2_klein_4b_sdnq = StarterModel( + name="FLUX.2 Klein 4B (SDNQ dynamic 4-bit)", + base=BaseModelType.Flux2, + source="Disty0/FLUX.2-klein-4B-SDNQ-4bit-dynamic", + description="FLUX.2 Klein 4B quantized via SDNQ to dynamic uint4/int5 mixed precision. " + "Full self-contained Flux2KleinPipeline (transformer + Qwen3 4B + AutoencoderKLFlux2). ~5GB", + type=ModelType.Main, + format=ModelFormat.SDNQQuantized, +) + +flux2_klein_9b_sdnq = StarterModel( + name="FLUX.2 Klein 9B (SDNQ dynamic 4-bit + SVD)", + base=BaseModelType.Flux2, + source="Disty0/FLUX.2-klein-9B-SDNQ-4bit-dynamic-svd-r32", + description="FLUX.2 Klein 9B quantized via SDNQ to dynamic uint4/int5 + SVD rank 32. " + "Full self-contained Flux2KleinPipeline. ~13GB", + type=ModelType.Main, + format=ModelFormat.SDNQQuantized, +) + +flux2_klein_4b_gguf_q4 = StarterModel( + name="FLUX.2 Klein 4B (GGUF Q4)", + base=BaseModelType.Flux2, + source="https://huggingface.co/unsloth/FLUX.2-klein-4B-GGUF/resolve/main/flux-2-klein-4b-Q4_K_M.gguf", + description="FLUX.2 Klein 4B GGUF Q4_K_M quantized - runs on 6-8GB VRAM. Installs with VAE and Qwen3 4B encoder. ~2.6GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], +) + +flux2_klein_4b_gguf_q8 = StarterModel( + name="FLUX.2 Klein 4B (GGUF Q8)", + base=BaseModelType.Flux2, + source="https://huggingface.co/unsloth/FLUX.2-klein-4B-GGUF/resolve/main/flux-2-klein-4b-Q8_0.gguf", + description="FLUX.2 Klein 4B GGUF Q8_0 quantized - higher quality than Q4. Installs with VAE and Qwen3 4B encoder. ~4.3GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_klein_qwen3_4b_encoder], +) + +flux2_klein_9b_gguf_q4 = StarterModel( + name="FLUX.2 Klein 9B (GGUF Q4)", + base=BaseModelType.Flux2, + source="https://huggingface.co/unsloth/FLUX.2-klein-9B-GGUF/resolve/main/flux-2-klein-9b-Q4_K_M.gguf", + description="FLUX.2 Klein 9B GGUF Q4_K_M quantized - runs on 12GB+ VRAM. Installs with VAE and Qwen3 8B encoder. ~5.8GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_klein_qwen3_8b_encoder], +) + +flux2_klein_9b_gguf_q8 = StarterModel( + name="FLUX.2 Klein 9B (GGUF Q8)", + base=BaseModelType.Flux2, + source="https://huggingface.co/unsloth/FLUX.2-klein-9B-GGUF/resolve/main/flux-2-klein-9b-Q8_0.gguf", + description="FLUX.2 Klein 9B GGUF Q8_0 quantized - higher quality than Q4. Installs with VAE and Qwen3 8B encoder. ~10GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_klein_qwen3_8b_encoder], +) + +# --- Diffusers transformer --- +flux2_dev_diffusers = StarterModel( + name="FLUX.2 [dev] (Diffusers)", + base=BaseModelType.Flux2, + source="black-forest-labs/FLUX.2-dev", + description="FLUX.2 [dev] full Diffusers pipeline - includes transformer, VAE, and Mistral text encoder. ~80GB. Non-Commercial License.", + type=ModelType.Main, +) + +flux2_dev_diffusers_nf4 = StarterModel( + name="FLUX.2 [dev] (Diffusers, NF4)", + base=BaseModelType.Flux2, + source="diffusers/FLUX.2-dev-bnb-4bit", + description="FLUX.2 [dev] with NF4-quantized DiT and text encoder - runs on ~18GB VRAM with offload. Non-Commercial License.", + type=ModelType.Main, +) + +# --- GGUF transformers from gguf-org/flux2-dev-gguf (canonical repo) --- +# These are the GGUFs BFL/community curate for cow-paired inference. Default +# encoder dependency is cow Q4 to make starter installs work out of the box. +flux2_dev_gguf_q3_k_m = StarterModel( + name="FLUX.2 [dev] Transformer (GGUF Q3_K_M)", + base=BaseModelType.Flux2, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q3_k_m.gguf", + description="FLUX.2 [dev] transformer Q3_K_M — fits ~12GB VRAM with offload. ~15.9GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_dev_cow_mistral_q4], +) + +flux2_dev_gguf_q4_k_m = StarterModel( + name="FLUX.2 [dev] Transformer (GGUF Q4_K_M)", + base=BaseModelType.Flux2, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q4_k_m.gguf", + description="FLUX.2 [dev] transformer Q4_K_M — good quality / size tradeoff. ~20GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_dev_cow_mistral_q4], +) + +flux2_dev_gguf_q5_k_m = StarterModel( + name="FLUX.2 [dev] Transformer (GGUF Q5_K_M)", + base=BaseModelType.Flux2, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q5_k_m.gguf", + description="FLUX.2 [dev] transformer Q5_K_M — higher fidelity than Q4. ~24GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_dev_cow_mistral_q8], +) + +flux2_dev_gguf_q6_k = StarterModel( + name="FLUX.2 [dev] Transformer (GGUF Q6_K)", + base=BaseModelType.Flux2, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q6_k.gguf", + description="FLUX.2 [dev] transformer Q6_K — near-Q8 quality at lower size. ~27.9GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_dev_cow_mistral_q8], +) + +flux2_dev_gguf_q8_0 = StarterModel( + name="FLUX.2 [dev] Transformer (GGUF Q8_0)", + base=BaseModelType.Flux2, + source="https://huggingface.co/gguf-org/flux2-dev-gguf/resolve/main/flux2-dev-q8_0.gguf", + description="FLUX.2 [dev] transformer Q8_0 — highest GGUF fidelity. ~35.5GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[flux2_vae, flux2_dev_cow_mistral_q8], +) diff --git a/invokeai/backend/model_manager/starter_models/ideogram_4.py b/invokeai/backend/model_manager/starter_models/ideogram_4.py new file mode 100644 index 00000000000..152a0bfe2a4 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/ideogram_4.py @@ -0,0 +1,30 @@ +"""Ideogram 4 starter models.""" + +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelType, +) + +# region Ideogram 4 +# Self-contained diffusers pipelines (both transformers + Qwen3-VL text encoder + VAE in one folder), so +# no separate dependencies. Gated, non-commercial license: the license must be accepted on the +# HuggingFace model page and a HuggingFace token configured before the download will succeed — same as +# FLUX.1 dev. +ideogram_4_nf4 = StarterModel( + name="Ideogram 4 (nf4)", + base=BaseModelType.Ideogram4, + source="ideogram-ai/ideogram-4-nf4", + description="Ideogram 4 text-to-image in nf4-quantized Diffusers format (CUDA only). Structured JSON " + "prompting with regional layout control. Non-commercial license — accept it on HuggingFace first. ~16GB", + type=ModelType.Main, +) + +ideogram_4_fp8 = StarterModel( + name="Ideogram 4 (fp8)", + base=BaseModelType.Ideogram4, + source="ideogram-ai/ideogram-4-fp8", + description="Ideogram 4 text-to-image in fp8-quantized Diffusers format (runs on any device, higher " + "memory use). Non-commercial license — accept it on HuggingFace first. ~26GB", + type=ModelType.Main, +) diff --git a/invokeai/backend/model_manager/starter_models/krea_2.py b/invokeai/backend/model_manager/starter_models/krea_2.py new file mode 100644 index 00000000000..c45e934959b --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/krea_2.py @@ -0,0 +1,58 @@ +"""Krea 2 starter models.""" + +from invokeai.backend.model_manager.starter_models.common import qwen3_vl_encoder_4b +from invokeai.backend.model_manager.starter_models.qwen_image import qwen_image_vae +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + Krea2VariantType, + ModelFormat, + ModelType, +) + +krea2_turbo = StarterModel( + name="Krea-2 Turbo", + base=BaseModelType.Krea2, + source="krea/Krea-2-Turbo", + description="Krea-2 Turbo - distilled 12B parameter text-to-image model (8 steps, CFG disabled). " + "Full diffusers pipeline including the Qwen-Image VAE and Qwen3-VL text encoder. ~26GB", + type=ModelType.Main, + variant=Krea2VariantType.Turbo, +) + +krea2_raw = StarterModel( + name="Krea-2 Raw", + base=BaseModelType.Krea2, + source="krea/Krea-2-Raw", + description="Krea-2 Raw - undistilled 12B base model (28 steps, CFG enabled). Full diffusers pipeline " + "including the Qwen-Image VAE and Qwen3-VL text encoder. Primarily a base for finetuning / LoRA " + "training; Turbo is recommended for standard inference. ~26GB", + type=ModelType.Main, + variant=Krea2VariantType.Base, +) + +krea2_turbo_gguf_q4_k_m = StarterModel( + name="Krea-2 Turbo (Q4_K_M GGUF)", + base=BaseModelType.Krea2, + source="https://huggingface.co/vantagewithai/Krea-2-Turbo-GGUF/resolve/main/krea2_turbo-Q4_K_M.gguf", + description="Krea-2 Turbo transformer quantized to GGUF Q4_K_M for lower VRAM (~7GB transformer). " + "GGUF ships only the transformer, so the Qwen-Image VAE and Qwen3-VL encoder are installed as " + "dependencies.", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=Krea2VariantType.Turbo, + dependencies=[qwen_image_vae, qwen3_vl_encoder_4b], +) + +krea2_turbo_gguf_q8_0 = StarterModel( + name="Krea-2 Turbo (Q8_0 GGUF)", + base=BaseModelType.Krea2, + source="https://huggingface.co/vantagewithai/Krea-2-Turbo-GGUF/resolve/main/krea2_turbo-Q8_0.gguf", + description="Krea-2 Turbo transformer quantized to GGUF Q8_0 (near-full quality, ~13GB transformer). " + "GGUF ships only the transformer, so the Qwen-Image VAE and Qwen3-VL encoder are installed as " + "dependencies.", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=Krea2VariantType.Turbo, + dependencies=[qwen_image_vae, qwen3_vl_encoder_4b], +) diff --git a/invokeai/backend/model_manager/starter_models/minimax_h3.py b/invokeai/backend/model_manager/starter_models/minimax_h3.py new file mode 100644 index 00000000000..8f6cb13ebf3 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/minimax_h3.py @@ -0,0 +1,56 @@ +"""MiniMax H3 starter models.""" + +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, +) + +minimax_h3_components = StarterModel( + name="MiniMax H3 Components", + base=BaseModelType.MiniMaxH3, + source="MiniMaxAI/MiniMax-H3::modular_model_index.json+transformer/config.json+tokenizer+processor+vae+audio_vae", + description="MiniMax H3 shared components: tokenizer, processor and video/audio VAEs, without " + "transformer or text-encoder weights (~11 GB). Pair with the MiniMax H3 single-file transformer " + "and text encoder. NOTE: This model is distributed under a restrictive license that forbids its " + "use in certain territories. Please see https://huggingface.co/MiniMaxAI/MiniMax-H3 for details.", + type=ModelType.Main, + format=ModelFormat.Diffusers, +) + +minimax_h3_int8_text_encoder = StarterModel( + name="MiniMax H3 Text Encoder (int8)", + base=BaseModelType.MiniMaxH3, + source="Comfy-Org/MiniMax-H3::text_encoders/qwen3vl_32b_minimax_h3_int8_convrot.safetensors", + description="Truncated Qwen3-VL-32B conditioning encoder for MiniMax H3, int8 quantized (~27 GB). " + "Select it in the MiniMax H3 Model Loader's text encoder field. NOTE: This model is distributed " + "under a restrictive license that forbids its use in certain territories. Please see " + "https://huggingface.co/MiniMaxAI/MiniMax-H3 for details.", + type=ModelType.Qwen3VLEncoder, + format=ModelFormat.Checkpoint, +) + +minimax_h3_int8_transformer = StarterModel( + name="MiniMax H3 FL2VA Transformer (int8, pruned)", + base=BaseModelType.MiniMaxH3, + source="Comfy-Org/MiniMax-H3::diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors", + description="MiniMax H3 video+audio generation. AdaLN-pruned int8 single-file transformer (~21 GB); " + "select it in the MiniMax H3 Model Loader's transformer field. Total size with dependencies: ~59 GB. " + "NOTE: This model is distributed under a restrictive license that forbids its use in certain " + "territories. Please see https://huggingface.co/MiniMaxAI/MiniMax-H3 for details.", + type=ModelType.Main, + format=ModelFormat.Checkpoint, + dependencies=[minimax_h3_components, minimax_h3_int8_text_encoder], +) + +minimax_h3_turbo_lora = StarterModel( + name="MiniMax H3 Turbo LoRA", + base=BaseModelType.MiniMaxH3, + source="larryvrh/MiniMax-H3-Turbo-Lora::minimax_h3_turbo_v4_step600_ema.safetensors", + description="Step-distillation LoRA for MiniMax H3 (Apache 2.0): renders video+audio in 4-8 " + "denoising steps instead of ~50. Apply at strength 1.0 and lower Steps to 6-8. Works with the " + "full and the pruned int8 transformers.", + type=ModelType.LoRA, + format=ModelFormat.LyCORIS, +) diff --git a/invokeai/backend/model_manager/starter_models/qwen_image.py b/invokeai/backend/model_manager/starter_models/qwen_image.py new file mode 100644 index 00000000000..7516c83f249 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/qwen_image.py @@ -0,0 +1,176 @@ +"""Qwen-Image starter models.""" + +from invokeai.backend.model_manager.starter_models.common import gemma2_2b_encoder, qwen_vl_encoder_fp8 +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, + PiDDecoderVariantType, + QwenImageVariantType, +) + +# Qwen-Image uses a 16-channel latent (ambiguous with FLUX/SD3). The config probe disambiguates via the checkpoint's +# directory name (`…official_qwenimage_distill…`); if the HF single-file download drops it, the explicit +# base=QwenImage override the installer sends is trusted instead (see pid_decoder.py::_validate_base). Only the +# 2K-to-4K preset exists. +pid_decoder_qwenimage_2kto4k = StarterModel( + name="PiD Decoder Qwen-Image (2K to 4K)", + base=BaseModelType.QwenImage, + source="nvidia/PiD::checkpoints_deprecated/PiD_res2kto4k_sr4x_official_qwenimage_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for Qwen-Image latents, 2K-to-4K preset (legacy architecture; NVIDIA's newer v1.5 checkpoint uses a different network that is not yet supported). ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +# region Qwen Image components (shared between Edit and txt2img variants) +qwen_image_vae = StarterModel( + name="Qwen Image VAE", + base=BaseModelType.QwenImage, + source="Qwen/Qwen-Image-Edit-2511::vae/diffusion_pytorch_model.safetensors", + description="Qwen Image VAE (AutoencoderKLQwenImage), shared between the Edit and txt2img variants. " + "Use with GGUF transformers to avoid downloading the full ~40GB Diffusers pipeline. (~250MB)", + type=ModelType.VAE, + format=ModelFormat.Checkpoint, +) + +# region Qwen Image Edit +qwen_image_edit = StarterModel( + name="Qwen Image Edit 2511", + base=BaseModelType.QwenImage, + source="Qwen/Qwen-Image-Edit-2511", + description="Qwen Image Edit 2511 full diffusers model. Supports text-guided image editing with multiple reference images. (~40GB)", + type=ModelType.Main, + variant=QwenImageVariantType.Edit, +) + +qwen_image_edit_gguf_q4_k_m = StarterModel( + name="Qwen Image Edit 2511 (Q4_K_M)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q4_K_M.gguf", + description="Qwen Image Edit 2511 - Q4_K_M quantized transformer. Good quality/size balance. (~13GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=QwenImageVariantType.Edit, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_edit_gguf_q2_k = StarterModel( + name="Qwen Image Edit 2511 (Q2_K)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q2_K.gguf", + description="Qwen Image Edit 2511 - Q2_K heavily quantized transformer. Smallest size, lower quality. (~7.5GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=QwenImageVariantType.Edit, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_edit_gguf_q6_k = StarterModel( + name="Qwen Image Edit 2511 (Q6_K)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q6_K.gguf", + description="Qwen Image Edit 2511 - Q6_K quantized transformer. Near-lossless quality. (~17GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=QwenImageVariantType.Edit, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_edit_gguf_q8_0 = StarterModel( + name="Qwen Image Edit 2511 (Q8_0)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-Edit-2511-GGUF/resolve/main/qwen-image-edit-2511-Q8_0.gguf", + description="Qwen Image Edit 2511 - Q8_0 quantized transformer. Highest quality quantization. (~22GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=QwenImageVariantType.Edit, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_edit_lightning_4step = StarterModel( + name="Qwen Image Edit Lightning (4-step, bf16)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors", + description="Lightning distillation LoRA for Qwen Image Edit — enables generation in just 4 steps. " + "Settings: Steps=4, CFG=1, Shift Override=3.", + type=ModelType.LoRA, +) + +qwen_image_edit_lightning_8step = StarterModel( + name="Qwen Image Edit Lightning (8-step, bf16)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-8steps-V1.0-bf16.safetensors", + description="Lightning distillation LoRA for Qwen Image Edit — enables generation in 8 steps with better quality. " + "Settings: Steps=8, CFG=1, Shift Override=3.", + type=ModelType.LoRA, +) + +# Qwen Image (txt2img) +qwen_image = StarterModel( + name="Qwen Image 2512", + base=BaseModelType.QwenImage, + source="Qwen/Qwen-Image-2512", + description="Qwen Image 2512 full diffusers model. High-quality text-to-image generation. (~40GB)", + type=ModelType.Main, +) + +qwen_image_gguf_q4_k_m = StarterModel( + name="Qwen Image 2512 (Q4_K_M)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q4_K_M.gguf", + description="Qwen Image 2512 - Q4_K_M quantized transformer. Good quality/size balance. (~13GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_gguf_q2_k = StarterModel( + name="Qwen Image 2512 (Q2_K)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q2_K.gguf", + description="Qwen Image 2512 - Q2_K heavily quantized transformer. Smallest size, lower quality. (~7.5GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_gguf_q6_k = StarterModel( + name="Qwen Image 2512 (Q6_K)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q6_K.gguf", + description="Qwen Image 2512 - Q6_K quantized transformer. Near-lossless quality. (~17GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_gguf_q8_0 = StarterModel( + name="Qwen Image 2512 (Q8_0)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/unsloth/Qwen-Image-2512-GGUF/resolve/main/qwen-image-2512-Q8_0.gguf", + description="Qwen Image 2512 - Q8_0 quantized transformer. Highest quality quantization. (~22GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[qwen_image_vae, qwen_vl_encoder_fp8], +) + +qwen_image_lightning_4step = StarterModel( + name="Qwen Image Lightning (4-step, V2.0, bf16)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V2.0-bf16.safetensors", + description="Lightning distillation LoRA for Qwen Image — enables generation in just 4 steps. " + "Settings: Steps=4, CFG=1, Shift Override=3.", + type=ModelType.LoRA, +) + +qwen_image_lightning_8step = StarterModel( + name="Qwen Image Lightning (8-step, V2.0, bf16)", + base=BaseModelType.QwenImage, + source="https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V2.0-bf16.safetensors", + description="Lightning distillation LoRA for Qwen Image — enables generation in 8 steps with better quality. " + "Settings: Steps=8, CFG=1, Shift Override=3.", + type=ModelType.LoRA, +) diff --git a/invokeai/backend/model_manager/starter_models/sd_1.py b/invokeai/backend/model_manager/starter_models/sd_1.py new file mode 100644 index 00000000000..c8b7634130d --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/sd_1.py @@ -0,0 +1,263 @@ +"""Stable Diffusion 1.x starter models.""" + +from invokeai.backend.model_manager.starter_models.common import ip_adapter_sd_image_encoder +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelType, +) + +cyberrealistic_negative = StarterModel( + name="CyberRealistic Negative v3", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/cyberdelia/CyberRealistic_Negative/resolve/main/CyberRealistic_Negative_v3.pt", + description="Negative embedding specifically for use with CyberRealistic.", + type=ModelType.TextualInversion, +) + +cyberrealistic_sd1 = StarterModel( + name="CyberRealistic v4.1", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V4.1_FP16.safetensors", + description="Photorealistic model. See other variants in HF repo 'cyberdelia/CyberRealistic'.", + type=ModelType.Main, + dependencies=[cyberrealistic_negative], +) + +rev_animated_sd1 = StarterModel( + name="ReV Animated", + base=BaseModelType.StableDiffusion1, + source="stablediffusionapi/rev-animated", + description="Fantasy and anime style images.", + type=ModelType.Main, +) + +dreamshaper_8_sd1 = StarterModel( + name="Dreamshaper 8", + base=BaseModelType.StableDiffusion1, + source="Lykon/dreamshaper-8", + description="Popular versatile model.", + type=ModelType.Main, +) + +dreamshaper_8_inpainting_sd1 = StarterModel( + name="Dreamshaper 8 (inpainting)", + base=BaseModelType.StableDiffusion1, + source="Lykon/dreamshaper-8-inpainting", + description="Inpainting version of Dreamshaper 8.", + type=ModelType.Main, +) + +deliberate_sd1 = StarterModel( + name="Deliberate v5", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v5.safetensors", + description="Popular versatile model", + type=ModelType.Main, +) + +deliberate_inpainting_sd1 = StarterModel( + name="Deliberate v5 (inpainting)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v5-inpainting.safetensors", + description="Inpainting version of Deliberate v5.", + type=ModelType.Main, +) + +# endregion +# region TI +easy_neg_sd1 = StarterModel( + name="EasyNegative", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/embed/EasyNegative/resolve/main/EasyNegative.safetensors", + description="A textual inversion to use in the negative prompt to reduce bad anatomy", + type=ModelType.TextualInversion, +) + +# endregion +# region IP Adapter +ip_adapter_sd1 = StarterModel( + name="Standard Reference (IP Adapter)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/InvokeAI/ip_adapter_sd15/resolve/main/ip-adapter_sd15.safetensors", + description="References images with a more generalized/looser degree of precision.", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sd_image_encoder], + previous_names=["IP Adapter"], +) + +ip_adapter_plus_sd1 = StarterModel( + name="Precise Reference (IP Adapter Plus)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/InvokeAI/ip_adapter_plus_sd15/resolve/main/ip-adapter-plus_sd15.safetensors", + description="References images with a higher degree of precision.", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sd_image_encoder], + previous_names=["IP Adapter Plus"], +) + +ip_adapter_plus_face_sd1 = StarterModel( + name="Face Reference (IP Adapter Plus Face)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/InvokeAI/ip_adapter_plus_face_sd15/resolve/main/ip-adapter-plus-face_sd15.safetensors", + description="References images with a higher degree of precision, adapted for faces", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sd_image_encoder], + previous_names=["IP Adapter Plus Face"], +) + +# endregion +# region ControlNet +qr_code_cnet_sd1 = StarterModel( + name="QRCode Monster v2 (SD1.5)", + base=BaseModelType.StableDiffusion1, + source="monster-labs/control_v1p_sd15_qrcode_monster::v2", + description="ControlNet model that generates scannable creative QR codes", + type=ModelType.ControlNet, +) + +canny_sd1 = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_canny", + description="Uses detected edges in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["canny"], +) + +inpaint_cnet_sd1 = StarterModel( + name="Inpainting", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_inpaint", + description="ControlNet weights trained on sd-1.5 with canny conditioning, inpaint version", + type=ModelType.ControlNet, + previous_names=["inpaint"], +) + +mlsd_sd1 = StarterModel( + name="Line Drawing (mlsd)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_mlsd", + description="Uses straight line detection for controlling the generation.", + type=ModelType.ControlNet, + previous_names=["mlsd"], +) + +depth_sd1 = StarterModel( + name="Depth Map", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11f1p_sd15_depth", + description="Uses depth information in the image to control the depth in the generation.", + type=ModelType.ControlNet, + previous_names=["depth"], +) + +normal_bae_sd1 = StarterModel( + name="Lighting Detection (Normals)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_normalbae", + description="Uses detected lighting information to guide the lighting of the composition.", + type=ModelType.ControlNet, + previous_names=["normal_bae"], +) + +seg_sd1 = StarterModel( + name="Segmentation Map", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_seg", + description="Uses segmentation maps to guide the structure of the composition.", + type=ModelType.ControlNet, + previous_names=["seg"], +) + +lineart_sd1 = StarterModel( + name="Lineart", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_lineart", + description="Uses lineart detection to guide the lighting of the composition.", + type=ModelType.ControlNet, + previous_names=["lineart"], +) + +lineart_anime_sd1 = StarterModel( + name="Lineart Anime", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15s2_lineart_anime", + description="Uses anime lineart detection to guide the lighting of the composition.", + type=ModelType.ControlNet, + previous_names=["lineart_anime"], +) + +openpose_sd1 = StarterModel( + name="Pose Detection (openpose)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_openpose", + description="Uses pose information to control the pose of human characters in the generation.", + type=ModelType.ControlNet, + previous_names=["openpose"], +) + +scribble_sd1 = StarterModel( + name="Contour Detection (scribble)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_scribble", + description="Uses edges, contours, or line art in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["scribble"], +) + +softedge_sd1 = StarterModel( + name="Soft Edge Detection (softedge)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_softedge", + description="Uses a soft edge detection map to control composition.", + type=ModelType.ControlNet, + previous_names=["softedge"], +) + +shuffle_sd1 = StarterModel( + name="Remix (shuffle)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11e_sd15_shuffle", + description="ControlNet weights trained on sd-1.5 with shuffle image conditioning", + type=ModelType.ControlNet, + previous_names=["shuffle"], +) + +tile_sd1 = StarterModel( + name="Tile", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11f1e_sd15_tile", + description="Uses image data to replicate exact colors/structure in the resulting generation.", + type=ModelType.ControlNet, + previous_names=["tile"], +) + +# endregion +# region T2I Adapter +t2i_canny_sd1 = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusion1, + source="TencentARC/t2iadapter_canny_sd15v2", + description="Uses detected edges in the image to control composition", + type=ModelType.T2IAdapter, + previous_names=["canny-sd15"], +) + +t2i_sketch_sd1 = StarterModel( + name="Sketch", + base=BaseModelType.StableDiffusion1, + source="TencentARC/t2iadapter_sketch_sd15v2", + description="Uses a sketch to control composition", + type=ModelType.T2IAdapter, + previous_names=["sketch-sd15"], +) + +t2i_depth_sd1 = StarterModel( + name="Depth Map", + base=BaseModelType.StableDiffusion1, + source="TencentARC/t2iadapter_depth_sd15v2", + description="Uses depth information in the image to control the depth in the generation.", + type=ModelType.T2IAdapter, + previous_names=["depth-sd15"], +) diff --git a/invokeai/backend/model_manager/starter_models/sd_3.py b/invokeai/backend/model_manager/starter_models/sd_3.py new file mode 100644 index 00000000000..4fea9cbcdae --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/sd_3.py @@ -0,0 +1,53 @@ +"""Stable Diffusion 3.5 starter models.""" + +from invokeai.backend.model_manager.starter_models.common import gemma2_2b_encoder +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, + PiDDecoderVariantType, +) + +# SD3 uses a 16-channel latent, architecturally identical to FLUX.1. The config probe disambiguates via the +# checkpoint's directory name (`…official_sd3_distill…`); if the HF single-file download drops that name, the +# explicit base=StableDiffusion3 override the installer sends is trusted instead (see pid_decoder.py::_validate_base). +pid_decoder_sd3_2k = StarterModel( + name="PiD Decoder SD3 (2K)", + base=BaseModelType.StableDiffusion3, + source="nvidia/PiD::checkpoints/PiD_res2k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for SD3 latents, 2K target preset (e.g. 512 -> 2048). ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +pid_decoder_sd3_2kto4k = StarterModel( + name="PiD Decoder SD3 (2K to 4K)", + base=BaseModelType.StableDiffusion3, + source="nvidia/PiD::checkpoints/PiD_res2kto4k_sr4x_official_sd3_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for SD3 latents, 2K-to-4K preset for higher-resolution output. ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +sd35_medium = StarterModel( + name="SD3.5 Medium", + base=BaseModelType.StableDiffusion3, + source="stabilityai/stable-diffusion-3.5-medium", + description="Medium SD3.5 Model: ~16GB", + type=ModelType.Main, + dependencies=[], +) + +sd35_large = StarterModel( + name="SD3.5 Large", + base=BaseModelType.StableDiffusion3, + source="stabilityai/stable-diffusion-3.5-large", + description="Large SD3.5 Model: ~28GB", + type=ModelType.Main, + dependencies=[], +) diff --git a/invokeai/backend/model_manager/starter_models/sdxl.py b/invokeai/backend/model_manager/starter_models/sdxl.py new file mode 100644 index 00000000000..ab69fcb149a --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/sdxl.py @@ -0,0 +1,193 @@ +"""SDXL starter models.""" + +from invokeai.backend.model_manager.starter_models.common import gemma2_2b_encoder, ip_adapter_sdxl_image_encoder +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, + PiDDecoderVariantType, +) + +# region VAE +sdxl_fp16_vae_fix = StarterModel( + name="sdxl-vae-fp16-fix", + base=BaseModelType.StableDiffusionXL, + source="madebyollin/sdxl-vae-fp16-fix", + description="SDXL VAE that works with FP16.", + type=ModelType.VAE, +) + +# SDXL uses a 4-channel latent, which is unambiguous (no FLUX/SD3-style directory-name disambiguation needed). +# NVIDIA ships only the 2K-to-4K preset for SDXL (no plain 2K checkpoint). +pid_decoder_sdxl_2kto4k = StarterModel( + name="PiD Decoder SDXL (2K to 4K)", + base=BaseModelType.StableDiffusionXL, + source="nvidia/PiD::checkpoints/PiD_res2kto4k_sr4x_official_sdxl_distill_4step/model_ema_bf16.pth", + description="NVIDIA PiD 4x super-resolution decoder for SDXL latents, 2K-to-4K preset. ~5GB", + type=ModelType.PiDDecoder, + format=ModelFormat.Checkpoint, + variant=PiDDecoderVariantType.Res2kTo4k_Sr4x, + dependencies=[gemma2_2b_encoder], +) + +juggernaut_sdxl = StarterModel( + name="Juggernaut XL v9", + base=BaseModelType.StableDiffusionXL, + source="RunDiffusion/Juggernaut-XL-v9", + description="Photograph-focused model.", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) + +dreamshaper_sdxl = StarterModel( + name="Dreamshaper XL v2 Turbo", + base=BaseModelType.StableDiffusionXL, + source="Lykon/dreamshaper-xl-v2-turbo", + description="For turbo, use CFG Scale 2, 4-8 steps, DPM++ SDE Karras. For non-turbo, use CFG Scale 6, 20-40 steps, DPM++ 2M SDE Karras.", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) + +archvis_sdxl = StarterModel( + name="Architecture (RealVisXL5)", + base=BaseModelType.StableDiffusionXL, + source="SG161222/RealVisXL_V5.0", + description="A photorealistic model, with architecture among its many use cases", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) + +# region LoRA +alien_lora_sdxl = StarterModel( + name="Alien Style", + base=BaseModelType.StableDiffusionXL, + source="https://huggingface.co/RalFinger/alien-style-lora-sdxl/resolve/main/alienzkin-sdxl.safetensors", + description="Futuristic, intricate alien styles. Trigger with 'alienzkin'.", + type=ModelType.LoRA, +) + +noodle_lora_sdxl = StarterModel( + name="Noodles Style", + base=BaseModelType.StableDiffusionXL, + source="https://huggingface.co/RalFinger/noodles-lora-sdxl/resolve/main/noodlez-sdxl.safetensors", + description="Never-ending, no-holds-barred, noodle nightmare. Trigger with 'noodlez'.", + type=ModelType.LoRA, +) + +ip_adapter_sdxl = StarterModel( + name="Standard Reference (IP Adapter ViT-H)", + base=BaseModelType.StableDiffusionXL, + source="https://huggingface.co/InvokeAI/ip_adapter_sdxl_vit_h/resolve/main/ip-adapter_sdxl_vit-h.safetensors", + description="References images with a higher degree of precision.", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sdxl_image_encoder], + previous_names=["IP Adapter SDXL"], +) + +ip_adapter_plus_sdxl = StarterModel( + name="Precise Reference (IP Adapter Plus ViT-H)", + base=BaseModelType.StableDiffusionXL, + source="https://huggingface.co/InvokeAI/ip-adapter-plus_sdxl_vit-h/resolve/main/ip-adapter-plus_sdxl_vit-h.safetensors", + description="References images with a higher degree of precision.", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sdxl_image_encoder], + previous_names=["IP Adapter Plus SDXL"], +) + +qr_code_cnet_sdxl = StarterModel( + name="QRCode Monster (SDXL)", + base=BaseModelType.StableDiffusionXL, + source="monster-labs/control_v1p_sdxl_qrcode_monster", + description="ControlNet model that generates scannable creative QR codes", + type=ModelType.ControlNet, +) + +canny_sdxl = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-canny-sdxl-1.0", + description="Uses detected edges in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["canny-sdxl"], +) + +depth_sdxl = StarterModel( + name="Depth Map", + base=BaseModelType.StableDiffusionXL, + source="diffusers/controlNet-depth-sdxl-1.0", + description="Uses depth information in the image to control the depth in the generation.", + type=ModelType.ControlNet, + previous_names=["depth-sdxl"], +) + +softedge_sdxl = StarterModel( + name="Soft Edge Detection (softedge)", + base=BaseModelType.StableDiffusionXL, + source="SargeZT/controlNet-sd-xl-1.0-softedge-dexined", + description="Uses a soft edge detection map to control composition.", + type=ModelType.ControlNet, + previous_names=["softedge-dexined-sdxl"], +) + +openpose_sdxl = StarterModel( + name="Pose Detection (openpose)", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-openpose-sdxl-1.0", + description="Uses pose information to control the pose of human characters in the generation.", + type=ModelType.ControlNet, + previous_names=["openpose-sdxl", "controlnet-openpose-sdxl"], +) + +scribble_sdxl = StarterModel( + name="Contour Detection (scribble)", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-scribble-sdxl-1.0", + description="Uses edges, contours, or line art in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["scribble-sdxl", "controlnet-scribble-sdxl"], +) + +tile_sdxl = StarterModel( + name="Tile", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-tile-sdxl-1.0", + description="Uses image data to replicate exact colors/structure in the resulting generation.", + type=ModelType.ControlNet, + previous_names=["tile-sdxl"], +) + +union_cnet_sdxl = StarterModel( + name="Multi-Guidance Detection (Union Pro)", + base=BaseModelType.StableDiffusionXL, + source="InvokeAI/Xinsir-SDXL_Controlnet_Union", + description="A unified ControlNet for SDXL model that supports 10+ control types", + type=ModelType.ControlNet, +) + +t2i_canny_sdxl = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusionXL, + source="TencentARC/t2i-adapter-canny-sdxl-1.0", + description="Uses detected edges in the image to control composition", + type=ModelType.T2IAdapter, + previous_names=["canny-sdxl"], +) + +t2i_lineart_sdxl = StarterModel( + name="Lineart", + base=BaseModelType.StableDiffusionXL, + source="TencentARC/t2i-adapter-lineart-sdxl-1.0", + description="Uses lineart detection to guide the lighting of the composition.", + type=ModelType.T2IAdapter, + previous_names=["lineart-sdxl"], +) + +t2i_sketch_sdxl = StarterModel( + name="Sketch", + base=BaseModelType.StableDiffusionXL, + source="TencentARC/t2i-adapter-sketch-sdxl-1.0", + description="Uses a sketch to control composition", + type=ModelType.T2IAdapter, + previous_names=["sketch-sdxl"], +) diff --git a/invokeai/backend/model_manager/starter_models/sdxl_refiner.py b/invokeai/backend/model_manager/starter_models/sdxl_refiner.py new file mode 100644 index 00000000000..cab88482cc2 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/sdxl_refiner.py @@ -0,0 +1,17 @@ +"""The SDXL refiner.""" + +from invokeai.backend.model_manager.starter_models.sdxl import sdxl_fp16_vae_fix +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelType, +) + +sdxl_refiner = StarterModel( + name="SDXL Refiner", + base=BaseModelType.StableDiffusionXLRefiner, + source="stabilityai/stable-diffusion-xl-refiner-1.0", + description="The OG Stable Diffusion XL refiner model.", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) diff --git a/invokeai/backend/model_manager/starter_models/types.py b/invokeai/backend/model_manager/starter_models/types.py new file mode 100644 index 00000000000..b62d4c3792d --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/types.py @@ -0,0 +1,47 @@ +"""The shape of a starter model entry. + +Split out so the per-architecture modules can import it without importing each other. +""" + +from typing import Optional + +from pydantic import BaseModel + +from invokeai.backend.model_manager.configs.external_api import ( + ExternalApiModelDefaultSettings, + ExternalModelCapabilities, + ExternalModelPanelSchema, +) +from invokeai.backend.model_manager.taxonomy import ( + AnyVariant, + BaseModelType, + ModelFormat, + ModelType, +) + + +class StarterModelWithoutDependencies(BaseModel): + description: str + source: str + name: str + base: BaseModelType + type: ModelType + format: Optional[ModelFormat] = None + variant: Optional[AnyVariant] = None + is_installed: bool = False + capabilities: ExternalModelCapabilities | None = None + default_settings: ExternalApiModelDefaultSettings | None = None + panel_schema: ExternalModelPanelSchema | None = None + # allows us to track what models a user has installed across name changes within starter models + # if you update a starter model name, please add the old one to this list for that starter model + previous_names: list[str] = [] + + +class StarterModel(StarterModelWithoutDependencies): + # Optional list of model source dependencies that need to be installed before this model can be used + dependencies: Optional[list[StarterModelWithoutDependencies]] = None + + +class StarterModelBundle(BaseModel): + name: str + models: list[StarterModel] diff --git a/invokeai/backend/model_manager/starter_models/wan.py b/invokeai/backend/model_manager/starter_models/wan.py new file mode 100644 index 00000000000..c2fba2fae77 --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/wan.py @@ -0,0 +1,218 @@ +"""Wan starter models.""" + +from invokeai.backend.model_manager.starter_models.common import wan_22_t5_encoder +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, + WanVariantType, +) + +wan_22_a14b_vae = StarterModel( + name="Wan 2.2 A14B VAE", + base=BaseModelType.Wan, + source="Wan-AI/Wan2.2-T2V-A14B-Diffusers::vae/diffusion_pytorch_model.safetensors", + description="Wan 2.2 A14B VAE (16-channel). Shared between T2V and I2V A14B variants. " + "Not interchangeable with the TI2V-5B VAE. (~250MB)", + type=ModelType.VAE, + format=ModelFormat.Checkpoint, +) + +wan_22_5b_vae = StarterModel( + name="Wan 2.2 TI2V-5B VAE", + base=BaseModelType.Wan, + source="Wan-AI/Wan2.2-TI2V-5B-Diffusers::vae/diffusion_pytorch_model.safetensors", + description="Wan 2.2 TI2V-5B VAE (48-channel). Required for the TI2V-5B model family. " + "Not interchangeable with the A14B VAE. (~400MB)", + type=ModelType.VAE, + format=ModelFormat.Checkpoint, +) + +# T2V A14B — full Diffusers + GGUF expert pairs (Q4_K_M and Q8_0). +# The high-noise GGUF is the "main" entry the user picks; the low-noise GGUF +# is wired as the partner expert via the Advanced panel. Each high-noise entry +# lists its low-noise partner plus the shared VAE/encoder as dependencies so +# the bundle/dependency installer pulls everything together. +wan_22_t2v_a14b_diffusers = StarterModel( + name="Wan 2.2 T2V A14B (Diffusers)", + base=BaseModelType.Wan, + source="Wan-AI/Wan2.2-T2V-A14B-Diffusers", + description="Full Diffusers Wan 2.2 T2V A14B model — both expert transformers, VAE, and UMT5-XXL " + "encoder in a single folder. No additional components needed. (~80GB)", + type=ModelType.Main, + format=ModelFormat.Diffusers, + variant=WanVariantType.T2V_A14B, +) + +wan_22_t2v_a14b_low_gguf_q4_k_m = StarterModel( + name="Wan 2.2 T2V A14B Low Noise (Q4_K_M)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-T2V-A14B-LowNoise-Q4_K_M.gguf", + description="Wan 2.2 T2V A14B low-noise expert transformer (Q4_K_M). Paired with the high-noise " + "expert; selected via the Advanced 'Transformer (Low Noise)' field. (~9.7GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.T2V_A14B, +) + +wan_22_t2v_a14b_gguf_q4_k_m = StarterModel( + name="Wan 2.2 T2V A14B High Noise (Q4_K_M)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q4_K_M.gguf", + description="Wan 2.2 T2V A14B high-noise expert transformer (Q4_K_M). Pick this as the main model; " + "the low-noise partner is wired in Advanced. Good quality/size balance. (~9.7GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.T2V_A14B, + dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_t2v_a14b_low_gguf_q4_k_m], +) + +wan_22_t2v_a14b_low_gguf_q8_0 = StarterModel( + name="Wan 2.2 T2V A14B Low Noise (Q8_0)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-T2V-A14B-LowNoise-Q8_0.gguf", + description="Wan 2.2 T2V A14B low-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.T2V_A14B, +) + +wan_22_t2v_a14b_gguf_q8_0 = StarterModel( + name="Wan 2.2 T2V A14B High Noise (Q8_0)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-T2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-T2V-A14B-HighNoise-Q8_0.gguf", + description="Wan 2.2 T2V A14B high-noise expert transformer (Q8_0). Pick as the main; pair with the " + "low-noise Q8_0 partner in Advanced. Highest quality quantization. (~15.4GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.T2V_A14B, + dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_t2v_a14b_low_gguf_q8_0], +) + +# T2V Lightning LoRAs — V1.1 Seko rank-64 pair (4-step inference). +wan_22_t2v_lightning_high = StarterModel( + name="Wan 2.2 T2V Lightning High Noise (4-step, V1.1)", + base=BaseModelType.Wan, + source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V1.1/high_noise_model.safetensors", + description="Lightning distillation LoRA for the Wan 2.2 T2V A14B high-noise expert — enables " + "4-step generation. Use together with the low-noise variant. Settings: Steps=4, CFG=1.", + type=ModelType.LoRA, +) + +wan_22_t2v_lightning_low = StarterModel( + name="Wan 2.2 T2V Lightning Low Noise (4-step, V1.1)", + base=BaseModelType.Wan, + source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-T2V-A14B-4steps-lora-rank64-Seko-V1.1/low_noise_model.safetensors", + description="Lightning distillation LoRA for the Wan 2.2 T2V A14B low-noise expert — enables " + "4-step generation. Use together with the high-noise variant. Settings: Steps=4, CFG=1.", + type=ModelType.LoRA, +) + +# I2V A14B — full Diffusers + GGUF expert pairs (Q4_K_M and Q8_0). +wan_22_i2v_a14b_diffusers = StarterModel( + name="Wan 2.2 I2V A14B (Diffusers)", + base=BaseModelType.Wan, + source="Wan-AI/Wan2.2-I2V-A14B-Diffusers", + description="Full Diffusers Wan 2.2 I2V A14B model — both expert transformers, VAE, and UMT5-XXL " + "encoder. Use the Reference Images panel to provide the conditioning image. (~80GB)", + type=ModelType.Main, + format=ModelFormat.Diffusers, + variant=WanVariantType.I2V_A14B, +) + +wan_22_i2v_a14b_low_gguf_q4_k_m = StarterModel( + name="Wan 2.2 I2V A14B Low Noise (Q4_K_M)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-I2V-A14B-LowNoise-Q4_K_M.gguf", + description="Wan 2.2 I2V A14B low-noise expert transformer (Q4_K_M). (~9.7GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.I2V_A14B, +) + +wan_22_i2v_a14b_gguf_q4_k_m = StarterModel( + name="Wan 2.2 I2V A14B High Noise (Q4_K_M)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-I2V-A14B-HighNoise-Q4_K_M.gguf", + description="Wan 2.2 I2V A14B high-noise expert transformer (Q4_K_M). Pick as the main; pair with " + "the low-noise partner in Advanced. Use the Reference Images panel for the conditioning image. (~9.7GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.I2V_A14B, + dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_i2v_a14b_low_gguf_q4_k_m], +) + +wan_22_i2v_a14b_low_gguf_q8_0 = StarterModel( + name="Wan 2.2 I2V A14B Low Noise (Q8_0)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/LowNoise/Wan2.2-I2V-A14B-LowNoise-Q8_0.gguf", + description="Wan 2.2 I2V A14B low-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.I2V_A14B, +) + +wan_22_i2v_a14b_gguf_q8_0 = StarterModel( + name="Wan 2.2 I2V A14B High Noise (Q8_0)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-I2V-A14B-GGUF/resolve/main/HighNoise/Wan2.2-I2V-A14B-HighNoise-Q8_0.gguf", + description="Wan 2.2 I2V A14B high-noise expert transformer (Q8_0). Highest quality quantization. (~15.4GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.I2V_A14B, + dependencies=[wan_22_a14b_vae, wan_22_t5_encoder, wan_22_i2v_a14b_low_gguf_q8_0], +) + +# I2V Lightning LoRAs — Seko rank-64 pair (4-step inference). Currently only V1. +wan_22_i2v_lightning_high = StarterModel( + name="Wan 2.2 I2V Lightning High Noise (4-step, V1)", + base=BaseModelType.Wan, + source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/high_noise_model.safetensors", + description="Lightning distillation LoRA for the Wan 2.2 I2V A14B high-noise expert — enables " + "4-step image-to-image generation. Use together with the low-noise variant. Settings: Steps=4, CFG=1.", + type=ModelType.LoRA, +) + +wan_22_i2v_lightning_low = StarterModel( + name="Wan 2.2 I2V Lightning Low Noise (4-step, V1)", + base=BaseModelType.Wan, + source="https://huggingface.co/lightx2v/Wan2.2-Lightning/resolve/main/Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/low_noise_model.safetensors", + description="Lightning distillation LoRA for the Wan 2.2 I2V A14B low-noise expert — enables " + "4-step image-to-image generation. Use together with the high-noise variant. Settings: Steps=4, CFG=1.", + type=ModelType.LoRA, +) + +# TI2V-5B — single-transformer model (no expert pair). Uses its own 48-channel VAE. +wan_22_ti2v_5b_diffusers = StarterModel( + name="Wan 2.2 TI2V-5B (Diffusers)", + base=BaseModelType.Wan, + source="Wan-AI/Wan2.2-TI2V-5B-Diffusers", + description="Full Diffusers Wan 2.2 TI2V-5B model — single 5B transformer, 48-channel VAE, and " + "UMT5-XXL encoder. Smaller and faster than A14B; runs on consumer GPUs. (~20GB)", + type=ModelType.Main, + format=ModelFormat.Diffusers, + variant=WanVariantType.TI2V_5B, +) + +wan_22_ti2v_5b_gguf_q4_k_m = StarterModel( + name="Wan 2.2 TI2V-5B (Q4_K_M)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-TI2V-5B-GGUF/resolve/main/Wan2.2-TI2V-5B-Q4_K_M.gguf", + description="Wan 2.2 TI2V-5B transformer (Q4_K_M). Single-expert model — no low-noise partner needed. (~3.4GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.TI2V_5B, + dependencies=[wan_22_5b_vae, wan_22_t5_encoder], +) + +wan_22_ti2v_5b_gguf_q8_0 = StarterModel( + name="Wan 2.2 TI2V-5B (Q8_0)", + base=BaseModelType.Wan, + source="https://huggingface.co/QuantStack/Wan2.2-TI2V-5B-GGUF/resolve/main/Wan2.2-TI2V-5B-Q8_0.gguf", + description="Wan 2.2 TI2V-5B transformer (Q8_0). Highest quality quantization. (~5.4GB)", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + variant=WanVariantType.TI2V_5B, + dependencies=[wan_22_5b_vae, wan_22_t5_encoder], +) diff --git a/invokeai/backend/model_manager/starter_models/z_image.py b/invokeai/backend/model_manager/starter_models/z_image.py new file mode 100644 index 00000000000..8d9c1c1f68f --- /dev/null +++ b/invokeai/backend/model_manager/starter_models/z_image.py @@ -0,0 +1,64 @@ +"""Z-Image starter models.""" + +from invokeai.backend.model_manager.starter_models.common import z_image_qwen3_encoder_quantized +from invokeai.backend.model_manager.starter_models.flux import flux_vae +from invokeai.backend.model_manager.starter_models.types import StarterModel +from invokeai.backend.model_manager.taxonomy import ( + BaseModelType, + ModelFormat, + ModelType, +) + +z_image_turbo = StarterModel( + name="Z-Image Turbo", + base=BaseModelType.ZImage, + source="Tongyi-MAI/Z-Image-Turbo", + description="Z-Image Turbo - fast 6B parameter text-to-image model with 8 inference steps. Supports bilingual prompts (English & Chinese). ~33GB", + type=ModelType.Main, +) + +z_image_turbo_quantized = StarterModel( + name="Z-Image Turbo (quantized)", + base=BaseModelType.ZImage, + source="https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q4_K.gguf", + description="Z-Image Turbo quantized to GGUF Q4_K format. Requires standalone Qwen3 text encoder and Flux VAE. ~4GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[z_image_qwen3_encoder_quantized, flux_vae], +) + +z_image_turbo_q8 = StarterModel( + name="Z-Image Turbo (Q8)", + base=BaseModelType.ZImage, + source="https://huggingface.co/leejet/Z-Image-Turbo-GGUF/resolve/main/z_image_turbo-Q8_0.gguf", + description="Z-Image Turbo quantized to GGUF Q8_0 format. Higher quality, larger size. Requires standalone Qwen3 text encoder and Flux VAE. ~6.6GB", + type=ModelType.Main, + format=ModelFormat.GGUFQuantized, + dependencies=[z_image_qwen3_encoder_quantized, flux_vae], +) + +z_image_turbo_sdnq = StarterModel( + name="Z-Image Turbo (SDNQ uint4 + SVD)", + base=BaseModelType.ZImage, + source="Disty0/Z-Image-Turbo-SDNQ-uint4-svd-r32", + description="Z-Image Turbo quantized via SDNQ to uint4 + SVD rank 32. Full self-contained " + "ZImagePipeline (transformer + Qwen3 + VAE). ~5GB", + type=ModelType.Main, + format=ModelFormat.SDNQQuantized, +) + +z_image_controlnet_union = StarterModel( + name="Z-Image ControlNet Union", + base=BaseModelType.ZImage, + source="https://huggingface.co/alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union-2.1/resolve/main/Z-Image-Turbo-Fun-Controlnet-Union-2.1-8steps.safetensors", + description="Unified ControlNet for Z-Image Turbo supporting Canny, HED, Depth, Pose, MLSD, and Inpainting modes.", + type=ModelType.ControlNet, +) + +z_image_controlnet_tile = StarterModel( + name="Z-Image ControlNet Tile", + base=BaseModelType.ZImage, + source="https://huggingface.co/alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union-2.1/resolve/main/Z-Image-Turbo-Fun-Controlnet-Tile-2.1-8steps.safetensors", + description="Dedicated Tile ControlNet for Z-Image Turbo. Useful for upscaling and adding detail. ~6.7GB", + type=ModelType.ControlNet, +) diff --git a/tests/backend/model_manager/test_starter_models_package.py b/tests/backend/model_manager/test_starter_models_package.py new file mode 100644 index 00000000000..c66437970cf --- /dev/null +++ b/tests/backend/model_manager/test_starter_models_package.py @@ -0,0 +1,87 @@ +"""The starter catalogue is a package now; these pin what the split must not change. + +`STARTER_MODELS` is curated product data — the order the install dialog shows, decided by someone, +reconstructible from nothing. It stays written out in `__init__.py` rather than assembled from the +per-architecture modules, and the first test here is what stops a later tidy-up from sorting it. +""" + +import ast +import pkgutil +from pathlib import Path + +import invokeai.backend.model_manager.starter_models as starters +from invokeai.backend.model_manager.starter_models import STARTER_BUNDLES, STARTER_MODELS +from invokeai.backend.model_manager.taxonomy import BaseModelType + +PACKAGE_DIR = Path(starters.__file__).parent + +# One module per architecture, plus the three that are not architectures. +NOT_AN_ARCHITECTURE = {"types", "common", "external"} + + +def test_the_curated_order_is_not_a_sorted_one() -> None: + """If this ever passes by accident, the order has been replaced by a derivable one and the + curation is gone. It is the sequence of the install dialog, and nothing can rebuild it.""" + names = [m.name for m in STARTER_MODELS] + assert names != sorted(names), "STARTER_MODELS looks sorted — the curated order was lost" + + +def test_no_model_is_listed_twice() -> None: + sources = [m.source for m in STARTER_MODELS] + duplicates = sorted({s for s in sources if sources.count(s) > 1}) + assert duplicates == [] + + +def test_each_architecture_module_holds_one_architecture() -> None: + """A module that mixes bases means the split has drifted and the file names stop meaning + anything. `common` and `external` are exempt by definition. + + Read from the source rather than the module namespace: three modules legitimately *import* a + model from another architecture — Krea-2 shares Qwen-Image's VAE, Z-Image the FLUX one, and the + refiner SDXL's — and an imported name is not a name this module defines. + """ + mixed = [] + for info in pkgutil.iter_modules([str(PACKAGE_DIR)]): + if info.name in NOT_AN_ARCHITECTURE: + continue + tree = ast.parse((PACKAGE_DIR / f"{info.name}.py").read_text(encoding="utf-8")) + declared = set() + for node in tree.body: + if not isinstance(node, (ast.Assign, ast.AnnAssign)): + continue + for sub in ast.walk(node): + if ( + isinstance(sub, ast.Attribute) + and isinstance(sub.value, ast.Name) + and sub.value.id == "BaseModelType" + ): + declared.add(sub.attr) + declared -= {"Any"} + if len(declared) > 1: + mixed.append(f"{info.name}: {sorted(declared)}") + assert mixed == [] + + +def test_every_bundle_is_reachable_from_the_catalogue() -> None: + """A bundle listing a model that is not in STARTER_MODELS would offer an install the dialog + cannot show.""" + catalogue = {m.source for m in STARTER_MODELS} + orphaned = sorted( + f"{base.value}/{model.name}" + for base, bundle in STARTER_BUNDLES.items() + for model in bundle.models + if model.source not in catalogue + ) + assert orphaned == [] + + +def test_the_package_splits_along_the_lines_it_claims() -> None: + """Fifteen architecture modules plus types, common and external. Named so a contributor adding + an architecture knows which file to open without reading any of them.""" + modules = {info.name for info in pkgutil.iter_modules([str(PACKAGE_DIR)])} + assert NOT_AN_ARCHITECTURE < modules + architecture_modules = modules - NOT_AN_ARCHITECTURE + # Every architecture module is named for a base, using the same convention as + # `architectures/defs/`: the base value with `-` replaced by `_`. + known = {b.value.replace("-", "_") for b in BaseModelType} + assert architecture_modules <= known, sorted(architecture_modules - known) From 1ef3424ce5e2b0ad761ecbe92954edb6f0be8348 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 05:58:40 +0200 Subject: [PATCH 18/26] fix(invocations): compute Ideal Size from what the architecture declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `IdealSizeInvocation` dispatched on base over six architectures and ended in `raise ValueError(f"Unsupported model type: {unet_config.base}")`. Nine of the sixteen fell into that branch — CogView 4, Z-Image, ERNIE-Image, Ideogram 4, Qwen-Image, Anima, Krea-2, Wan and MiniMax H3 — and the failure landed at generation time, after the model had loaded, which is precisely the failure mode this whole series exists to remove. Both numbers it needed are already declared. The canvas is `DefaultSettingsFacet`'s width, and it matches the old hardcoded values exactly: 512 for SD 1.x, 768 for SD 2.x, 1024 for the rest. So the three architectures anyone actually used this node with are unchanged, byte for byte. The second number was wrong for more than half of them. `trim_to_multiple_of` defaulted to `LATENT_SCALE_FACTOR`, which is 8 — right for the SD family and wrong for everything with a 16 or 32 grid. A FLUX or CogView 4 ideal size could come back off-grid, and the denoise node would then reject the width this node had just computed. It now trims to `FeaturesFacet.dimension_grid`, which a test already pins against the `multiple_of` those nodes enforce. The node's title still reads "Ideal Size - SD1.5, SDXL". Left alone deliberately: titles are what users see in saved workflows, and renaming one is a UI decision rather than a consequence of this fix. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/invocations/ideal_size.py | 38 +++++----- tests/app/invocations/test_ideal_size.py | 95 ++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 tests/app/invocations/test_ideal_size.py diff --git a/invokeai/app/invocations/ideal_size.py b/invokeai/app/invocations/ideal_size.py index 5cfa9c04d01..2d362d6b150 100644 --- a/invokeai/app/invocations/ideal_size.py +++ b/invokeai/app/invocations/ideal_size.py @@ -2,11 +2,15 @@ from typing import Tuple from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output -from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField from invokeai.app.invocations.model import UNetField from invokeai.app.services.shared.invocation_context import InvocationContext -from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.architectures import ( + ArchitectureError, + FeaturesFacet, + require, + resolve_default_settings, +) @invocation_output("ideal_size_output") @@ -36,28 +40,25 @@ class IdealSizeInvocation(BaseInvocation): "initial generation artifacts if too large)", ) - def trim_to_multiple_of(self, *args: int, multiple_of: int = LATENT_SCALE_FACTOR) -> Tuple[int, ...]: + def trim_to_multiple_of(self, *args: int, multiple_of: int) -> Tuple[int, ...]: return tuple((x - x % multiple_of) for x in args) def invoke(self, context: InvocationContext) -> IdealSizeOutput: unet_config = context.models.get_config(self.unet.unet.key) aspect = self.width / self.height - if unet_config.base == BaseModelType.StableDiffusion1: - dimension = 512 - elif unet_config.base == BaseModelType.StableDiffusion2: - dimension = 768 - elif unet_config.base in ( - BaseModelType.StableDiffusionXL, - BaseModelType.Flux, - BaseModelType.Flux2, - BaseModelType.StableDiffusion3, - ): - dimension = 1024 - else: - raise ValueError(f"Unsupported model type: {unet_config.base}") - - dimension = dimension * self.multiplier + # Both numbers come from what the architecture declares. This was an if/elif over six bases + # ending in `raise ValueError(f"Unsupported model type: ...")`, which fired here — at + # generation time — for the nine architectures nobody had added to it. The grid was + # hardcoded to 8 besides, so a FLUX or CogView 4 size could come back off-grid. + settings = resolve_default_settings(unet_config.base) + if settings is None or settings.width is None: + raise ArchitectureError( + f"Architecture '{unet_config.base.value}' declares no default dimensions, so there is no " + "ideal size to compute from." + ) + dimension = settings.width * self.multiplier + grid = require(unet_config.base, FeaturesFacet).dimension_grid min_dimension = math.floor(dimension * 0.5) model_area = dimension * dimension # hardcoded for now since all models are trained on square images @@ -71,6 +72,7 @@ def invoke(self, context: InvocationContext) -> IdealSizeOutput: scaled_width, scaled_height = self.trim_to_multiple_of( math.floor(init_width), math.floor(init_height), + multiple_of=grid, ) return IdealSizeOutput(width=scaled_width, height=scaled_height) diff --git a/tests/app/invocations/test_ideal_size.py b/tests/app/invocations/test_ideal_size.py new file mode 100644 index 00000000000..730c2e4c7bc --- /dev/null +++ b/tests/app/invocations/test_ideal_size.py @@ -0,0 +1,95 @@ +"""Ideal Size computes from what the architecture declares, not from a list of six bases.""" + +import math +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from invokeai.app.invocations.ideal_size import IdealSizeInvocation +from invokeai.app.invocations.model import ModelIdentifierField, UNetField +from invokeai.backend.architectures import ArchitectureError, generative_bases, resolve_default_settings +from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelType + + +def _unet(base: BaseModelType) -> UNetField: + """A UNetField the node can hold. Only `unet.key` is read, and only to look up the config.""" + identifier = ModelIdentifierField(key="test", hash="test", name="test", base=base, type=ModelType.Main) + return UNetField(unet=identifier, scheduler=identifier, loras=[]) + + +def _invoke(base: BaseModelType, width: int = 1024, height: int = 576, multiplier: float = 1.0) -> Any: + node = IdealSizeInvocation(width=width, height=height, multiplier=multiplier, unet=_unet(base)) + context = MagicMock() + context.models.get_config.return_value = SimpleNamespace(base=base) + return node.invoke(context) + + +@pytest.mark.parametrize( + ("base", "expected"), + [ + # The six the old if/elif covered, at the aspect ratio of the node's own defaults. + (BaseModelType.StableDiffusion1, (680, 384)), + (BaseModelType.StableDiffusion2, (1024, 576)), + (BaseModelType.StableDiffusionXL, (1360, 768)), + ], +) +def test_the_previously_supported_bases_are_unchanged(base: BaseModelType, expected: tuple[int, int]) -> None: + """SD 1.x, 2.x and XL have an 8-pixel grid, which is what the old hardcoded value was.""" + output = _invoke(base) + assert (output.width, output.height) == expected + + +def test_flux_now_lands_on_its_own_grid() -> None: + """The old code trimmed to 8 for every architecture. FLUX needs 16, so a size could come back + off-grid — the node would hand the graph a width the denoise node then rejects.""" + output = _invoke(BaseModelType.Flux) + assert output.width % 16 == 0 and output.height % 16 == 0 + + +def test_every_architecture_gets_an_answer() -> None: + """The old dispatch raised `Unsupported model type` for nine of the sixteen — at generation + time, after the model had loaded.""" + failed = [] + for base in generative_bases(): + settings = resolve_default_settings(base) + if settings is None or settings.width is None: + continue # the refiner, which declares a canvas but is not run on its own + try: + _invoke(base) + except Exception as exc: # noqa: BLE001 - the point is that nothing raises + failed.append(f"{base.value}: {type(exc).__name__}") + assert failed == [] + + +def test_the_result_stays_on_the_declared_grid_for_every_architecture() -> None: + from invokeai.backend.architectures import FeaturesFacet, require + + off_grid = [] + for base in generative_bases(): + settings = resolve_default_settings(base) + if settings is None or settings.width is None: + continue + grid = require(base, FeaturesFacet).dimension_grid + output = _invoke(base) + if output.width % grid or output.height % grid: + off_grid.append(f"{base.value}: {output.width}x{output.height} not a multiple of {grid}") + assert off_grid == [] + + +def test_an_architecture_without_dimensions_says_so() -> None: + """The SDXL refiner declares a canvas; nothing declares none today, so this pins the message + rather than a current state.""" + with pytest.raises(ArchitectureError, match="no default dimensions"): + node = IdealSizeInvocation(width=1024, height=576, unet=_unet(BaseModelType.Any)) + context = MagicMock() + context.models.get_config.return_value = SimpleNamespace(base=BaseModelType.Any) + node.invoke(context) + + +def test_the_multiplier_still_scales_the_area() -> None: + single = _invoke(BaseModelType.StableDiffusionXL) + doubled = _invoke(BaseModelType.StableDiffusionXL, multiplier=2.0) + assert doubled.width > single.width + assert math.isclose(doubled.width / single.width, 2.0, rel_tol=0.02) From 75877289e4be139ad5bc768001984e810ec29f6e Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 06:01:43 +0200 Subject: [PATCH 19/26] feat(scripts): scaffold a new architecture, and derive what cannot be scaffolded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/new_architecture.py` writes the three files the registry made mechanical — the declaration under `architectures/defs/`, the invocation package, and the starter-model module — and then prints everything it cannot write. The second half is the more useful one. That list is derived on each run, not stored: any module naming five or more `BaseModelType` members is dispatching on base, so a new base has to be added to it. Twelve modules qualify today. A written-down list would already be wrong — `step_callback.py`, `dependencies.py` and, as of the previous commit, `ideal_size.py` have all dropped off it during this series, and a maintained list would still be naming them. The generated declaration deliberately does not work: every required facet is present with an obviously wrong value — a one-row black projection, a single mode, a placeholder canvas — and five TODOs saying what each one needs. The app refuses to boot until they are filled in. A stub that booted would let a half-integrated architecture reach a user, which is the failure this structure exists to prevent. Verified end to end: written, refused to import, refused to overwrite on a second run, removed cleanly. Tests pin the parts that would rot silently: that each stub parses, that the declaration carries all five required facets, that it still looks unfinished, and that the derived list contains `configs/main.py` while containing none of the three modules the registry has absorbed. That last assertion is what notices a facet being bypassed later. Co-Authored-By: Claude Opus 5 (1M context) --- .../contributing/new-model-integration.mdx | 18 ++ scripts/new_architecture.py | 204 ++++++++++++++++++ tests/test_new_architecture_script.py | 73 +++++++ 3 files changed, 295 insertions(+) create mode 100644 scripts/new_architecture.py create mode 100644 tests/test_new_architecture_script.py diff --git a/docs/src/content/docs/contributing/new-model-integration.mdx b/docs/src/content/docs/contributing/new-model-integration.mdx index f3d700bf48f..ce8d287de11 100644 --- a/docs/src/content/docs/contributing/new-model-integration.mdx +++ b/docs/src/content/docs/contributing/new-model-integration.mdx @@ -12,6 +12,24 @@ This guide describes all the steps required to integrate a new model type into I The code examples use a hypothetical `NewModel` architecture. The implementations of FLUX.1, FLUX.2 Klein, SD3, SDXL, and Z-Image in the InvokeAI codebase serve as excellent real-world references. ::: +:::tip[Start with the scaffolder] +Add the `BaseModelType` member first (step 1 below), then run: + +```sh +python scripts/new_architecture.py --base newmodel --name NewModel --write +``` + +It writes the three files that are mechanical — the architecture's declaration under +`invokeai/backend/architectures/defs/`, its invocation package, and its starter-model module — and +then prints everything it *cannot* write, which is most of this guide. That list is derived from the +codebase on each run rather than kept in the script, so it does not go stale. + +The generated declaration deliberately does not work. It carries every required facet with an +obviously wrong value, so the app refuses to boot until each one is filled in. A stub that booted +would let a half-integrated architecture reach a user, which is the failure this whole structure +exists to prevent. +::: + --- ## 1. Backend: Model Manager diff --git a/scripts/new_architecture.py b/scripts/new_architecture.py new file mode 100644 index 00000000000..a13dd2bd9f4 --- /dev/null +++ b/scripts/new_architecture.py @@ -0,0 +1,204 @@ +"""Scaffold the files a new model architecture needs, and list the ones it cannot scaffold. + + python scripts/new_architecture.py --base new-model --name NewModel # dry run + python scripts/new_architecture.py --base new-model --name NewModel --write + +Three files can be generated, because the registry made them mechanical: the architecture's +declaration under `architectures/defs/`, its invocation package, and its starter-model module. The +declaration is generated with every required facet present but obviously wrong, so it fails loudly +at boot until someone fills it in — a stub that boots would be worse than no stub. + +The rest cannot be generated, and the point of this script is as much to enumerate that rest as to +write the three. That list is *derived* on each run rather than written down here: any module naming +five or more `BaseModelType` members is dispatching on base, so a new one has to be added to it by +hand. A hardcoded list would be wrong the first time someone removed a dispatch — which is what the +last several changes have been doing. +""" + +import argparse +import ast +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = REPO_ROOT / "invokeai" + +# Excluded from the derived list: the registry is the mechanism, not the cost, and the starter +# package is scaffolded below. +NOT_A_COST = ( + "invokeai/frontend", + "invokeai/backend/architectures/", + "invokeai/backend/model_manager/starter_models/", +) + +# Files a derivation cannot find, because the work there is to *add* something rather than to extend +# an existing dispatch. Kept short and specific; each says what to do, not just where. +UNDERIVABLE = [ + ( + "invokeai/backend/model_manager/taxonomy.py", + "Add the `BaseModelType` member. If the architecture has variants, add its enum and list it " + "in `AnyVariant` and `variant_type_adapter`.", + ), + ( + "invokeai/backend/stable_diffusion/diffusion/conditioning_data.py", + "Define the `*ConditioningInfo` its text encoder produces, unless it reuses an existing one.", + ), + ( + "invokeai/app/invocations/metadata.py", + "Add the mode strings to `GENERATION_MODES`. They must match what the new `ModalityFacet` " + "declares — a test compares the two — and they are persisted in image metadata, so they " + "cannot be changed later.", + ), +] + + +def defs_module(base_value: str, enum_name: str) -> str: + """The architecture's declaration. Every required facet, none of them plausible.""" + return f'''"""What the {base_value} architecture declares.""" + +from invokeai.backend.architectures.facets.conditioning import ConditioningFacet +from invokeai.backend.architectures.facets.default_settings import DefaultSettingsFacet +from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt +from invokeai.backend.architectures.facets.latent_space import LatentSpace, LatentSpaceFacet +from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.registry import register +from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings +from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import BasicConditioningInfo + +# TODO: every value below is a placeholder. The registry refuses to boot without these facets, but +# it cannot tell whether they are true — replace each one before generating anything. + +# TODO: the latent -> RGB projection for this VAE, one row per latent channel. If the architecture +# reuses another's VAE, import that LatentSpace instead of declaring a second copy of its matrix. +{enum_name.upper()}_LATENT_RGB_FACTORS = [ + [0.0, 0.0, 0.0], +] + +{enum_name.upper()}_LATENT_SPACE = LatentSpace( + channels=1, # TODO: latent channels; must equal len(...LATENT_RGB_FACTORS) + spatial_compression=8, # TODO: how much smaller a latent is than the image, per side + rgb_factors={enum_name.upper()}_LATENT_RGB_FACTORS, +) + +register( + BaseModelType.{enum_name}, + LatentSpaceFacet({enum_name.upper()}_LATENT_SPACE), + # TODO: the class its text encoder produces. Reuse an existing one where the shape matches. + ConditioningFacet(BasicConditioningInfo), + # TODO: what the generation sliders should say. Cite the model card in a comment. + DefaultSettingsFacet({{None: MainModelDefaultSettings(width=1024, height=1024)}}), + # TODO: which of txt2img / img2img / inpaint / outpaint / t2v / i2v it can do, and the prefix + # its mode strings carry in image metadata. Both must match GENERATION_MODES. + ModalityFacet(frozenset({{"txt2img"}}), metadata_slug="{base_value.replace("-", "_")}"), + # TODO: dimension_grid must equal the `multiple_of` on this architecture's denoise node. + FeaturesFacet( + negative_prompt=NegativePrompt(visible=True, usage="always"), + dimension_grid=8, + ), +) +''' + + +def invocations_package(base_value: str, enum_name: str) -> str: + return f'''"""{enum_name} nodes. + +Node modules go here, keeping their architecture prefix: `{base_value.replace("-", "_")}_denoise.py`, +`{base_value.replace("-", "_")}_model_loader.py`. VAE, text-encoder and PiD nodes belong in +`invocations/vae/`, `invocations/text_encoder/` and `invocations/pid/` instead — they are shared +across architectures rather than owned by one. + +This package is discovered automatically; there is no list to add it to. The `__init__.py` is what +makes it a package, and without it every node in here would silently not exist. +""" +''' + + +def starter_models_module(base_value: str, enum_name: str) -> str: + return f'''"""{enum_name} starter models.""" + +from invokeai.backend.model_manager.starter_models.types import StarterModel # noqa: F401 + +# TODO: declare the starter models, then add them to STARTER_MODELS in this package's __init__.py. +# That list is a curated order, not a derived one — insert where it belongs rather than appending. +''' + + +def derive_residual_edits(source_root: Path, threshold: int = 5) -> list[tuple[str, int]]: + """Modules that dispatch on `BaseModelType`, worst first. + + The heuristic is deliberately crude and deliberately recomputed: a module naming many bases is + choosing behaviour per base, and a new base has to be added to it. + """ + found: list[tuple[str, int]] = [] + for path in sorted(source_root.rglob("*.py")): + relative = path.relative_to(source_root.parent).as_posix() + if relative.startswith(NOT_A_COST): + continue + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): + continue + members = { + node.attr + for node in ast.walk(tree) + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == "BaseModelType" + } + if len(members) >= threshold: + found.append((relative, len(members))) + return sorted(found, key=lambda row: (-row[1], row[0])) + + +def planned_files(base_value: str, enum_name: str) -> dict[str, str]: + slug = base_value.replace("-", "_") + return { + f"invokeai/backend/architectures/defs/{slug}.py": defs_module(base_value, enum_name), + f"invokeai/app/invocations/{slug}/__init__.py": invocations_package(base_value, enum_name), + f"invokeai/backend/model_manager/starter_models/{slug}.py": starter_models_module(base_value, enum_name), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--base", required=True, help="The BaseModelType value, e.g. 'new-model'") + parser.add_argument("--name", required=True, help="The BaseModelType member name, e.g. 'NewModel'") + parser.add_argument("--write", action="store_true", help="Write the files. Without this, print what would be.") + args = parser.parse_args() + + files = planned_files(args.base, args.name) + + existing = [path for path in files if (REPO_ROOT / path).exists()] + if existing: + print("Refusing to overwrite:", *existing, sep="\n ") + return 1 + + for path, content in files.items(): + if args.write: + target = REPO_ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + print(f"wrote {path} ({content.count(chr(10))} lines)") + else: + print(f"would write {path} ({content.count(chr(10))} lines)") + + print( + "\nThese cannot be scaffolded — the work is to add something, not to extend a dispatch:", + ) + for path, what in UNDERIVABLE: + print(f" {path}\n {what}") + + residual = derive_residual_edits(SOURCE_ROOT) + print(f"\nAnd these {len(residual)} modules dispatch on BaseModelType, so check each one:") + for path, count in residual: + print(f" {count:>3} bases {path}") + + print( + "\nThe app will not boot until the generated declaration is filled in — that is deliberate." + if args.write + else "\nNothing was written. Re-run with --write." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_new_architecture_script.py b/tests/test_new_architecture_script.py new file mode 100644 index 00000000000..deaf955c9ce --- /dev/null +++ b/tests/test_new_architecture_script.py @@ -0,0 +1,73 @@ +"""The scaffolder generates valid stubs, and its residual list stays honest.""" + +import ast +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "scripts")) + +from new_architecture import ( # noqa: E402 + defs_module, + derive_residual_edits, + invocations_package, + planned_files, + starter_models_module, +) + +REQUIRED_FACETS = {"LatentSpaceFacet", "ConditioningFacet", "DefaultSettingsFacet", "ModalityFacet", "FeaturesFacet"} + + +@pytest.mark.parametrize("render", [defs_module, invocations_package, starter_models_module], ids=lambda f: f.__name__) +def test_every_stub_is_valid_python(render) -> None: # type: ignore[no-untyped-def] + ast.parse(render("new-model", "NewModel")) + + +def test_the_declaration_carries_every_required_facet() -> None: + """A stub missing one would fail at boot with a message about that facet rather than about the + stub, which is a worse first experience than the TODOs.""" + tree = ast.parse(defs_module("new-model", "NewModel")) + called = {node.func.id for node in ast.walk(tree) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)} + assert REQUIRED_FACETS <= called, sorted(REQUIRED_FACETS - called) + + +def test_the_declaration_is_obviously_unfinished() -> None: + """It must not look plausible. Someone who runs the scaffolder and forgets to fill it in should + ship nothing — the placeholder projection is a single black row.""" + source = defs_module("new-model", "NewModel") + assert source.count("TODO") >= 5 + assert "[0.0, 0.0, 0.0]" in source + + +def test_the_slug_convention_matches_the_registry() -> None: + """`-` becomes `_`, the same rule `registry.defs_module_path` computes.""" + paths = set(planned_files("new-model", "NewModel")) + assert "invokeai/backend/architectures/defs/new_model.py" in paths + assert "invokeai/app/invocations/new_model/__init__.py" in paths + assert "invokeai/backend/model_manager/starter_models/new_model.py" in paths + + +def test_the_residual_list_is_derived_from_the_tree() -> None: + """Not a written-down list. It has already shrunk twice while this series ran, and a hardcoded + one would still be naming files that no longer dispatch on base.""" + residual = dict(derive_residual_edits(REPO_ROOT / "invokeai")) + + # Still dispatching: one config class per architecture is inherent to how configs work. + assert "invokeai/backend/model_manager/configs/main.py" in residual + + # No longer dispatching — each of these was a chain the registry absorbed. If one reappears + # here, a facet has been bypassed. + for absorbed in ( + "invokeai/app/util/step_callback.py", + "invokeai/app/api/dependencies.py", + "invokeai/app/invocations/ideal_size.py", + ): + assert absorbed not in residual, f"{absorbed} dispatches on base again" + + +def test_the_registry_itself_is_never_listed_as_a_cost() -> None: + """`architectures/` names every base by construction; listing it would drown the real entries.""" + residual = dict(derive_residual_edits(REPO_ROOT / "invokeai")) + assert not [path for path in residual if path.startswith("invokeai/backend/architectures/")] From 7c03da6d581dfceba75cced4be034ae056d6c4be Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 06:22:59 +0200 Subject: [PATCH 20/26] fix(api): authenticate the capabilities route, and correct two stale expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions the full suite caught, both mine. `test_every_route_is_authenticated_or_explicitly_public` failed: the new `GET /api/v2/models/capabilities` had no auth dependency. I had reasoned that the response holds nothing user- or install-specific, which is true and is not the question the test asks. Its message says the allowlist is for routes that *must* be public, and this one does not have to be. It now takes `CurrentUserOrDefault` like every other route in the router. That falsifies a claim the route's own tests made — "it needs no services". `CurrentUserOrDefault` resolves through `ApiDependencies.invoker`, so the route does need one now. The test that asserted otherwise is replaced by one that asserts what actually matters and remains true: the model manager service is never touched, so the response cannot have become per-model. `test_default_settings_main[sdxl-refiner-None]` failed because the refiner now declares SDXL's canvas. The expectation was correct until it did; updated, with a note on why the refiner has dimensions but no steps or CFG. `openapi.json` and `schema.ts` regenerated: the delta is this route's description and its `security` block, nothing else. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/app/api/routers/model_manager.py | 8 +-- invokeai/frontend/web/openapi.json | 9 +++- .../frontend/web/src/services/api/schema.ts | 6 ++- .../routers/test_architecture_capabilities.py | 51 +++++++++++++++---- .../test_identification.py | 5 +- 5 files changed, 60 insertions(+), 19 deletions(-) diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py index 7870595d5eb..60857d6bfed 100644 --- a/invokeai/app/api/routers/model_manager.py +++ b/invokeai/app/api/routers/model_manager.py @@ -163,7 +163,7 @@ def prepare_model_config_for_response(config: AnyModelConfig, dependencies: Type operation_id="list_architecture_capabilities", responses={200: {"description": "What each model architecture supports"}}, ) -async def list_architecture_capabilities() -> list[ArchitectureCapabilities]: +async def list_architecture_capabilities(current_user: CurrentUserOrDefault) -> list[ArchitectureCapabilities]: """What each model architecture can generate, and which generation features it supports. A static table, the same for every install and every user, derived from what the architectures @@ -171,8 +171,10 @@ async def list_architecture_capabilities() -> list[ArchitectureCapabilities]: records locally: look up `(base, variant)`, fall back to `(base, null)`. Deliberately not a field on the model records themselves — it is the same for every model of an - architecture, and putting it there would add these fields to all 115 config schemas. No auth - dependency for the same reason: there is nothing user- or install-specific in it. + architecture, and putting it there would add these fields to all 115 config schemas. + + Authenticated like every other route here even though the response holds nothing user-specific: + the allowlist for public routes is short and deliberate, and this is not a reason to lengthen it. """ return architecture_capabilities() diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index a111b8f1d0e..eb8cc5e4a24 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -608,7 +608,7 @@ "get": { "tags": ["model_manager"], "summary": "List Architecture Capabilities", - "description": "What each model architecture can generate, and which generation features it supports.\n\nA static table, the same for every install and every user, derived from what the architectures\ndeclare under `invokeai/backend/architectures/defs/`. Fetch it once and join it against model\nrecords locally: look up `(base, variant)`, fall back to `(base, null)`.\n\nDeliberately not a field on the model records themselves \u2014 it is the same for every model of an\narchitecture, and putting it there would add these fields to all 115 config schemas. No auth\ndependency for the same reason: there is nothing user- or install-specific in it.", + "description": "What each model architecture can generate, and which generation features it supports.\n\nA static table, the same for every install and every user, derived from what the architectures\ndeclare under `invokeai/backend/architectures/defs/`. Fetch it once and join it against model\nrecords locally: look up `(base, variant)`, fall back to `(base, null)`.\n\nDeliberately not a field on the model records themselves \u2014 it is the same for every model of an\narchitecture, and putting it there would add these fields to all 115 config schemas.\n\nAuthenticated like every other route here even though the response holds nothing user-specific:\nthe allowlist for public routes is short and deliberate, and this is not a reason to lengthen it.", "operationId": "list_architecture_capabilities", "responses": { "200": { @@ -625,7 +625,12 @@ } } } - } + }, + "security": [ + { + "HTTPBearer": [] + } + ] } }, "/api/v2/models/": { diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index aeb89c6b668..2c428fc03e8 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -400,8 +400,10 @@ export type paths = { * records locally: look up `(base, variant)`, fall back to `(base, null)`. * * Deliberately not a field on the model records themselves — it is the same for every model of an - * architecture, and putting it there would add these fields to all 115 config schemas. No auth - * dependency for the same reason: there is nothing user- or install-specific in it. + * architecture, and putting it there would add these fields to all 115 config schemas. + * + * Authenticated like every other route here even though the response holds nothing user-specific: + * the allowlist for public routes is short and deliberate, and this is not a reason to lengthen it. */ get: operations["list_architecture_capabilities"]; put?: never; diff --git a/tests/app/routers/test_architecture_capabilities.py b/tests/app/routers/test_architecture_capabilities.py index 372f14f177f..fcd79e3837e 100644 --- a/tests/app/routers/test_architecture_capabilities.py +++ b/tests/app/routers/test_architecture_capabilities.py @@ -1,20 +1,43 @@ """GET /api/v2/models/capabilities — the static architecture table. -It touches no service and no database: the rows come from what the architectures declare at import -time. That is what lets this test use a bare client, and it is also the property worth pinning — -a later version that reaches for `ApiDependencies.invoker` would fail here rather than in production. +The rows come from what the architectures declare at import time — no service, no database, the same +response for every install. The route is still authenticated like every other one in this router, +which is the only reason these tests need `ApiDependencies` patched at all. """ +from typing import Any, Iterator +from unittest.mock import MagicMock + +import pytest from fastapi.testclient import TestClient +from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api_app import app +from invokeai.app.services.invoker import Invoker from invokeai.backend.architectures import architecture_capabilities, generative_bases -client = TestClient(app) URL = "/api/v2/models/capabilities" -def test_it_serves_a_row_for_every_architecture() -> None: +class _MockApiDependencies(ApiDependencies): + def __init__(self, invoker: Invoker) -> None: + self.invoker = invoker # type: ignore[misc] + + +@pytest.fixture +def client(monkeypatch: Any, mock_invoker: Invoker) -> Iterator[TestClient]: + """A client whose auth dependency can resolve a default user. + + The route itself needs nothing from the invoker; `CurrentUserOrDefault` does. + """ + mock_invoker.services.users = MagicMock() + mock_deps = _MockApiDependencies(mock_invoker) + for module in ("invokeai.app.api_app", "invokeai.app.api.auth_dependencies"): + monkeypatch.setattr(f"{module}.ApiDependencies", mock_deps) + yield TestClient(app) + + +def test_it_serves_a_row_for_every_architecture(client: TestClient) -> None: response = client.get(URL) assert response.status_code == 200 @@ -24,7 +47,7 @@ def test_it_serves_a_row_for_every_architecture() -> None: assert len(base_rows) == len(generative_bases()), "one row per architecture, no duplicates" -def test_variant_rows_override_their_base_row() -> None: +def test_variant_rows_override_their_base_row(client: TestClient) -> None: """FLUX is the clearest case: three variants, three genuinely different answers.""" rows = client.get(URL).json() flux = {r["variant"]: r for r in rows if r["base"] == "flux"} @@ -35,26 +58,32 @@ def test_variant_rows_override_their_base_row() -> None: assert flux[None]["defaults"]["steps"] == 28, "the base row is dev" -def test_the_variant_is_the_value_a_client_holds() -> None: +def test_the_variant_is_the_value_a_client_holds(client: TestClient) -> None: """Not `str(enum)`, which would serialize as `FluxVariantType.DevFill`.""" variants = {r["variant"] for r in client.get(URL).json() if r["variant"] is not None} assert all("." not in v for v in variants), variants assert "dev_fill" in variants -def test_it_needs_no_services() -> None: - """No auth, no invoker, no database — the same table for every install and every user.""" +def test_the_rows_come_from_the_registry_not_a_service(client: TestClient, mock_invoker: Invoker) -> None: + """The response is the same table for every install. Nothing about it is looked up. + + Asserted by the model manager service never being touched: if a later version resolved + capabilities per model record, this is where it would show. + """ + mock_invoker.services.model_manager = MagicMock() assert client.get(URL).status_code == 200 + mock_invoker.services.model_manager.assert_not_called() -def test_the_response_matches_what_the_registry_renders() -> None: +def test_the_response_matches_what_the_registry_renders(client: TestClient) -> None: """The route is a pass-through; anything it added would be a second source of truth.""" served = client.get(URL).json() rendered = [row.model_dump(mode="json") for row in architecture_capabilities()] assert served == rendered -def test_the_rows_are_ordered_stably() -> None: +def test_the_rows_are_ordered_stably(client: TestClient) -> None: """Sorted by base, so a client diffing two responses sees only real changes.""" rows = client.get(URL).json() bases = [r["base"] for r in rows] diff --git a/tests/model_identification/test_identification.py b/tests/model_identification/test_identification.py index 8a4d220f5c0..75038c95eb8 100644 --- a/tests/model_identification/test_identification.py +++ b/tests/model_identification/test_identification.py @@ -40,7 +40,10 @@ def test_controlnet_t2i_default_settings(model_name: str, preprocessor: str | No (BaseModelType.StableDiffusion1, {"width": 512, "height": 512}), (BaseModelType.StableDiffusion2, {"width": 768, "height": 768}), (BaseModelType.StableDiffusionXL, {"width": 1024, "height": 1024}), - (BaseModelType.StableDiffusionXLRefiner, None), + # The refiner refines an SDXL latent, so it shares SDXL's canvas — but not its steps or CFG, + # which the UI drives with its own refiner parameters. + (BaseModelType.StableDiffusionXLRefiner, {"width": 1024, "height": 1024}), + # A sentinel, not an architecture: it is never registered, so there is nothing to resolve. (BaseModelType.Any, None), ], ) From 4d6d7047c0d520fbf7f2d9596f8a5a8da4204ded Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 09:54:05 +0200 Subject: [PATCH 21/26] feat(architectures): declare how far each UNet downscales internally `get_max_unet_downscale` replaces two dispatches that were duplicated verbatim -- identical down to the comment above them and the error string below them -- in `denoise_latents.run_t2i_adapters` and `T2IAdapterExt.__init__`. SD1's UNet downscales the latent image 8x internally, SDXL's 4x; every other architecture raised. Kept apart from `LatentSpaceFacet` deliberately. Both are small integers about downscaling, which is exactly why they must not share a field: this one is a property of the UNet, the other of the VAE latent geometry, and an architecture can have one without the other. The facet is not `REQUIRED`. Fourteen of sixteen architectures legitimately have no UNet in the sense T2I-Adapter conditioning means, so the accessor carries the error rather than `require()` -- which also lets the message stay byte-identical to the one the two dispatches raised. It is user-facing, and a test pins it including how the enum renders (`BaseModelType.Flux`, not `flux`, because the enum is a `str, Enum` mixin rather than a `StrEnum`). Also records, in a NOTE and not in code, that a third copy of the SDXL BGR rule sits ~300 lines below the first: it decides the swap from the *UNet's* base while `run_t2i_adapters` decides it from each *adapter's* base, so the two disagree for an SD1 adapter on an SDXL UNet. That is a behaviour bug, fixing it changes output, and this is a refactor. The natural fix is to fold `bgr_input` into this facet so both paths read one declaration. Ported from the abandoned refactor/arch-latent-space-facet branch (#28), the one piece of that PR the rebuilt series had not carried over. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/invocations/sd/denoise_latents.py | 16 ++++---- invokeai/backend/architectures/__init__.py | 6 +++ invokeai/backend/architectures/defs/sd_1.py | 2 + invokeai/backend/architectures/defs/sdxl.py | 2 + invokeai/backend/architectures/facets/unet.py | 38 +++++++++++++++++++ .../extensions/t2i_adapter.py | 10 +---- .../architectures/test_unet_downscale.py | 34 +++++++++++++++++ 7 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 invokeai/backend/architectures/facets/unet.py create mode 100644 tests/backend/architectures/test_unet_downscale.py diff --git a/invokeai/app/invocations/sd/denoise_latents.py b/invokeai/app/invocations/sd/denoise_latents.py index 1aaa3499c83..3ed82f57826 100644 --- a/invokeai/app/invocations/sd/denoise_latents.py +++ b/invokeai/app/invocations/sd/denoise_latents.py @@ -38,6 +38,7 @@ from invokeai.app.invocations.sd.t2i_adapter import T2IAdapterField from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.controlnet_utils import prepare_control_image +from invokeai.backend.architectures import get_max_unet_downscale from invokeai.backend.ip_adapter.ip_adapter import IPAdapter from invokeai.backend.model_manager.configs.factory import AnyModelConfig from invokeai.backend.model_manager.taxonomy import BaseModelType, ModelVariantType @@ -677,17 +678,13 @@ def run_t2i_adapters( t2i_adapter_model_config = context.models.get_config(t2i_adapter_field.t2i_adapter_model.key) image = context.images.get_pil(t2i_adapter_field.image.image_name, mode="RGB") - # The max_unet_downscale is the maximum amount that the UNet model downscales the latent image internally. - if t2i_adapter_model_config.base == BaseModelType.StableDiffusion1: - max_unet_downscale = 8 - elif t2i_adapter_model_config.base == BaseModelType.StableDiffusionXL: - max_unet_downscale = 4 + # Raises for a base without a UNet, before the BGR swap below -- same order as before. + max_unet_downscale = get_max_unet_downscale(t2i_adapter_model_config.base) + if t2i_adapter_model_config.base == BaseModelType.StableDiffusionXL: # SDXL adapters are trained on cv2's BGR outputs r, g, b = image.split() image = Image.merge("RGB", (b, g, r)) - else: - raise ValueError(f"Unexpected T2I-Adapter base model type: '{t2i_adapter_model_config.base}'.") t2i_adapter_model: T2IAdapter with context.models.load(t2i_adapter_field.t2i_adapter_model) as t2i_adapter_model: @@ -988,6 +985,11 @@ def step_callback(state: PipelineIntermediateState) -> None: # ext = extension_field.to_extension(exit_stack, context, ext_manager) # ext_manager.add_extension(ext) self.parse_controlnet_field(exit_stack, context, self.control, ext_manager) + # NOTE: this decides the BGR swap from the *UNet's* base, while run_t2i_adapters above + # decides it from each *adapter's* base. The two disagree for an SD1 adapter on an SDXL + # UNet. Left as-is deliberately: fixing it changes behaviour, and this refactor does + # not. The natural fix is to fold `bgr_input` into UNetDownscaleFacet so both paths read + # one declaration. bgr_mode = self.unet.unet.base == BaseModelType.StableDiffusionXL self.parse_t2i_adapter_field(exit_stack, context, self.t2i_adapter, ext_manager, bgr_mode) diff --git a/invokeai/backend/architectures/__init__.py b/invokeai/backend/architectures/__init__.py index 6e5e1f11d79..adc690e2897 100644 --- a/invokeai/backend/architectures/__init__.py +++ b/invokeai/backend/architectures/__init__.py @@ -34,6 +34,10 @@ ModalityFacet, generation_modes, ) +from invokeai.backend.architectures.facets.unet import ( + UNetDownscaleFacet, + get_max_unet_downscale, +) from invokeai.backend.architectures.registry import ( ArchitectureError, defs_module_path, @@ -60,10 +64,12 @@ "NegativePrompt", "GenerationModeKind", "ModalityFacet", + "UNetDownscaleFacet", "LatentSpace", "LatentSpaceFacet", "conditioning_infos", "generation_modes", + "get_max_unet_downscale", "resolve_default_settings", "resolve_latent_space", "defs_module_path", diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index 083195a1e4e..96b295aecca 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -5,6 +5,7 @@ from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import SD15_4, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.facets.unet import UNetDownscaleFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -13,6 +14,7 @@ register( BaseModelType.StableDiffusion1, LatentSpaceFacet(SD15_4), + UNetDownscaleFacet(max_unet_downscale=8), ConditioningFacet(BasicConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=512, height=512)}), # SD 1.x and 2.x share the unprefixed mode strings: a bare `txt2img`. diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index 9615fecdeba..ab6302d0189 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -5,6 +5,7 @@ from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import SDXL_4, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.facets.unet import UNetDownscaleFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -13,6 +14,7 @@ register( BaseModelType.StableDiffusionXL, LatentSpaceFacet(SDXL_4), + UNetDownscaleFacet(max_unet_downscale=4), ConditioningFacet(SDXLConditioningInfo), DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=1024, height=1024)}), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="sdxl"), diff --git a/invokeai/backend/architectures/facets/unet.py b/invokeai/backend/architectures/facets/unet.py new file mode 100644 index 00000000000..7af269f18de --- /dev/null +++ b/invokeai/backend/architectures/facets/unet.py @@ -0,0 +1,38 @@ +"""How far a UNet-based architecture downscales the latent image internally. + +Kept apart from `LatentSpaceFacet` on purpose: this is a property of the *UNet* (SD1 downscales 8x +internally, SDXL 4x), unrelated to the VAE latent geometry next door. The two happen to both be +small integers about downscaling, which is exactly why they should not share a field. +""" + +from dataclasses import dataclass + +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.taxonomy import BaseModelType + + +@dataclass(frozen=True) +class UNetDownscaleFacet(Facet): + """Optional: only SD1 and SDXL have a UNet in the sense T2I-Adapter conditioning needs. + + Every other architecture legitimately does not declare it, so this facet is not `REQUIRED` and + the accessor -- not `require()` -- carries the error, preserving the message the two duplicated + dispatches it replaces raised. + """ + + max_unet_downscale: int + + +def get_max_unet_downscale(base: BaseModelType) -> int: + """The maximum amount the UNet downscales the latent image internally. + + Raises for architectures without a UNet, which is what the T2I-Adapter call sites did before + this facet existed. + """ + facet = get(base, UNetDownscaleFacet) + if facet is None: + # The message is reproduced verbatim, including how the enum renders: BaseModelType is a + # `str, Enum` mixin rather than a StrEnum, so this interpolates as "BaseModelType.Flux". + raise ValueError(f"Unexpected T2I-Adapter base model type: '{base}'.") + return facet.max_unet_downscale diff --git a/invokeai/backend/stable_diffusion/extensions/t2i_adapter.py b/invokeai/backend/stable_diffusion/extensions/t2i_adapter.py index 63778953d5f..6cdb20cccf4 100644 --- a/invokeai/backend/stable_diffusion/extensions/t2i_adapter.py +++ b/invokeai/backend/stable_diffusion/extensions/t2i_adapter.py @@ -8,7 +8,7 @@ from PIL.Image import Image from invokeai.app.util.controlnet_utils import prepare_control_image -from invokeai.backend.model_manager.taxonomy import BaseModelType +from invokeai.backend.architectures import get_max_unet_downscale from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningMode from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase, callback @@ -44,14 +44,8 @@ def __init__( self._adapter_state: Optional[List[torch.Tensor]] = None - # The max_unet_downscale is the maximum amount that the UNet model downscales the latent image internally. model_config = self._node_context.models.get_config(self._model_id.key) - if model_config.base == BaseModelType.StableDiffusion1: - self._max_unet_downscale = 8 - elif model_config.base == BaseModelType.StableDiffusionXL: - self._max_unet_downscale = 4 - else: - raise ValueError(f"Unexpected T2I-Adapter base model type: '{model_config.base}'.") + self._max_unet_downscale = get_max_unet_downscale(model_config.base) @callback(ExtensionCallbackType.SETUP) def setup(self, ctx: DenoiseContext): diff --git a/tests/backend/architectures/test_unet_downscale.py b/tests/backend/architectures/test_unet_downscale.py new file mode 100644 index 00000000000..7642f93159c --- /dev/null +++ b/tests/backend/architectures/test_unet_downscale.py @@ -0,0 +1,34 @@ +"""`get_max_unet_downscale` replaces two verbatim-duplicated dispatches. + +They lived in `denoise_latents.run_t2i_adapters` and `T2IAdapterExt.__init__`, identical down to +the comment and the error string. The message is reproduced exactly, because it is user-facing. +""" + +import pytest + +from invokeai.backend.architectures import generative_bases, get_max_unet_downscale +from invokeai.backend.model_manager.taxonomy import BaseModelType + +HAS_UNET = {BaseModelType.StableDiffusion1: 8, BaseModelType.StableDiffusionXL: 4} + + +@pytest.mark.parametrize(("base", "expected"), sorted(HAS_UNET.items(), key=lambda item: item[0].value)) +def test_returns_the_declared_downscale(base: BaseModelType, expected: int) -> None: + assert get_max_unet_downscale(base) == expected + + +@pytest.mark.parametrize("base", sorted(set(generative_bases()) - set(HAS_UNET), key=lambda b: b.value)) +def test_raises_for_architectures_without_a_unet(base: BaseModelType) -> None: + # Verbatim, including the quoting and how the enum renders. BaseModelType is a `str, Enum` + # mixin rather than a StrEnum, so it interpolates as "BaseModelType.Flux", not "flux". + with pytest.raises(ValueError) as exc_info: + get_max_unet_downscale(base) + + assert str(exc_info.value) == f"Unexpected T2I-Adapter base model type: '{base}'." + + +def test_the_sd1_and_sdxl_values_are_the_pre_registry_ones() -> None: + # SD1's UNet downscales 8x internally, SDXL's 4x. Pinned separately from the parametrized test + # so that an edit to HAS_UNET cannot silently redefine what is being asserted. + assert get_max_unet_downscale(BaseModelType.StableDiffusion1) == 8 + assert get_max_unet_downscale(BaseModelType.StableDiffusionXL) == 4 From 19d727bf9476f5595611accd71688d6fbb0de86e Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Thu, 20 Aug 2026 09:56:27 +0200 Subject: [PATCH 22/26] feat(invocation_api): export every architecture's conditioning type Custom node authors construct `*ConditioningInfo` objects, but only three of the thirteen were reachable through the public `invocation_api` surface -- `BasicConditioningInfo`, `ConditioningFieldData` and `SDXLConditioningInfo`. Anyone writing a node for FLUX, Wan, Qwen-Image or the other nine had to reach into `backend.stable_diffusion.diffusion.conditioning_data` directly, which is not a supported import path. The list is static, because `__all__` is a real re-export rather than a runtime lookup, and both star-imports and editors need the names to exist at module level. What keeps it honest is a test that compares the exported set against `conditioning_infos()` from the registry: declaring a new architecture's conditioning type now also makes it public, or the test says so. That guard is not hypothetical. This change was ported from the abandoned refactor/arch-conditioning-facet branch (#30), where the same list was written out by hand -- and in the weeks since, MiniMax H3 arrived and the hand-written version had silently gone stale. The list is derived-and-asserted for exactly the failure it had already suffered. Co-Authored-By: Claude Opus 5 (1M context) --- invokeai/invocation_api/__init__.py | 24 +++++++++++++++++++ .../architectures/test_conditioning.py | 18 ++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/invokeai/invocation_api/__init__.py b/invokeai/invocation_api/__init__.py index 7cc5e065fd6..ba98ea9d4fa 100644 --- a/invokeai/invocation_api/__init__.py +++ b/invokeai/invocation_api/__init__.py @@ -135,9 +135,20 @@ ) from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( + AnimaConditioningInfo, BasicConditioningInfo, + CogView4ConditioningInfo, ConditioningFieldData, + ErnieImageConditioningInfo, + FLUXConditioningInfo, + Ideogram4ConditioningInfo, + Krea2ConditioningInfo, + MiniMaxH3ConditioningInfo, + QwenImageConditioningInfo, + SD3ConditioningInfo, SDXLConditioningInfo, + WanConditioningInfo, + ZImageConditioningInfo, ) from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES from invokeai.backend.util.devices import CPU_DEVICE, CUDA_DEVICE, MPS_DEVICE, choose_precision, choose_torch_device @@ -221,9 +232,22 @@ # invokeai.app.services.boards.boards_common "BoardDTO", # invokeai.backend.stable_diffusion.diffusion.conditioning_data + # Every architecture's conditioning type, kept in step with the registry by + # tests/backend/architectures/test_conditioning.py. + "AnimaConditioningInfo", "BasicConditioningInfo", + "CogView4ConditioningInfo", "ConditioningFieldData", + "ErnieImageConditioningInfo", + "FLUXConditioningInfo", + "Ideogram4ConditioningInfo", + "Krea2ConditioningInfo", + "MiniMaxH3ConditioningInfo", + "QwenImageConditioningInfo", + "SD3ConditioningInfo", "SDXLConditioningInfo", + "WanConditioningInfo", + "ZImageConditioningInfo", # invokeai.backend.stable_diffusion.diffusers_pipeline "PipelineIntermediateState", # invokeai.app.services.workflow_records.workflow_records_common diff --git a/tests/backend/architectures/test_conditioning.py b/tests/backend/architectures/test_conditioning.py index 57f3d9df669..9c60ee9756b 100644 --- a/tests/backend/architectures/test_conditioning.py +++ b/tests/backend/architectures/test_conditioning.py @@ -62,3 +62,21 @@ def test_it_matches_what_dependencies_installs() -> None: assert len(safe_globals) == 14 assert safe_globals[0] is ConditioningFieldData assert len(set(safe_globals)) == len(safe_globals), "a class appears twice" + + +def test_the_node_api_exports_every_declared_conditioning_type() -> None: + """Custom node authors build these; the public surface must offer all of them. + + Derived from the registry rather than written down, because a hand-kept list is exactly what + goes stale: the version of this list on the abandoned branch already omitted MiniMax H3 by the + time it was ported. `invocation_api` still needs the static imports -- `__all__` is a real + re-export, not a runtime lookup -- so this test is what keeps the two in step. + """ + import invokeai.invocation_api as node_api + + declared = {info.__name__ for info in conditioning_infos()} + exported = set(node_api.__all__) + + assert declared <= exported, sorted(declared - exported) + for name in sorted(declared): + assert getattr(node_api, name, None) is not None, f"{name} is in __all__ but not importable" From 47ffa9d30c79ad324576f59c45f408b3150c3d97 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 02:09:53 +0200 Subject: [PATCH 23/26] feat: add support for 'ernie-image' model in generation framework - Introduced 'ernie-image' model in various components including base generation policies, contracts, and graph builders. - Updated tests to ensure coverage for the new model, including graph structure and node types. - Added a snapshot for node types emitted by the 'ernie-image' model to maintain contract with backend invocation registry. - Enhanced documentation and comments for clarity on model behavior and requirements. --- .../__snapshots__/generateGraphNodeTypes.json | 791 ++++++++++++++++++ .../core/baseGenerationPolicies.test.ts | 1 + .../generation/core/baseGenerationPolicies.ts | 15 + .../src/features/generation/core/contracts.ts | 1 + .../src/features/generation/core/graph.ts | 56 ++ .../generation/core/graphCoverage.test.ts | 275 ++++++ .../features/models/core/baseIdentity.test.ts | 1 + .../src/features/models/core/baseIdentity.ts | 5 + .../webv2/src/features/models/core/types.ts | 1 + .../workflow/core/modelRequirements.ts | 1 + .../src/workbench/widgets/canvas/bboxGrid.ts | 3 +- .../test_frontend_graph_node_types.py | 92 ++ 12 files changed, 1241 insertions(+), 1 deletion(-) create mode 100644 invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json create mode 100644 invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts create mode 100644 tests/app/invocations/test_frontend_graph_node_types.py diff --git a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json new file mode 100644 index 00000000000..3c1c73027c1 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json @@ -0,0 +1,791 @@ +{ + "_comment": "Generated by src/features/generation/core/graphCoverage.test.ts. Update with `vitest -u`. Consumed by tests/app/invocations/test_frontend_graph_node_types.py, which checks every node type and field below against the backend invocation registry.", + "byBase": { + "sd-1": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [] + }, + "nodeTypes": [ + "clip_skip", + "collect", + "compel", + "core_metadata", + "denoise_latents", + "integer", + "l2i", + "main_model_loader", + "noise", + "string" + ] + }, + "sd-2": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [] + }, + "nodeTypes": [ + "clip_skip", + "collect", + "compel", + "core_metadata", + "denoise_latents", + "integer", + "l2i", + "main_model_loader", + "noise", + "string" + ] + }, + "sdxl": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [] + }, + "nodeTypes": [ + "collect", + "core_metadata", + "denoise_latents", + "integer", + "l2i", + "noise", + "sdxl_compel_prompt", + "sdxl_model_loader", + "string" + ] + }, + "sd-3": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [] + }, + "nodeTypes": [ + "core_metadata", + "integer", + "sd3_denoise", + "sd3_l2i", + "sd3_model_loader", + "sd3_text_encoder", + "string" + ] + }, + "flux": { + "componentsFilled": { + "diffusers": [ + "clipEmbedModel", + "t5EncoderModel", + "vae" + ], + "standalone-components": [ + "clipEmbedModel", + "t5EncoderModel", + "vae" + ] + }, + "nodeTypes": [ + "collect", + "core_metadata", + "flux_denoise", + "flux_model_loader", + "flux_text_encoder", + "flux_vae_decode", + "integer", + "string" + ] + }, + "flux2": { + "componentsFilled": { + "dev-diffusers": [], + "dev-standalone": [ + "mistralEncoderModel", + "vae" + ], + "klein-9b-standalone": [ + "qwen3EncoderModel", + "vae" + ] + }, + "nodeTypes": [ + "collect", + "core_metadata", + "flux2_denoise", + "flux2_dev_model_loader", + "flux2_dev_text_encoder", + "flux2_klein_model_loader", + "flux2_klein_text_encoder", + "flux2_vae_decode", + "integer", + "string" + ] + }, + "cogview4": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [] + }, + "nodeTypes": [ + "cogview4_denoise", + "cogview4_l2i", + "cogview4_model_loader", + "cogview4_text_encoder", + "core_metadata", + "integer", + "string" + ] + }, + "ernie-image": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [] + }, + "nodeTypes": [ + "core_metadata", + "ernie_image_denoise", + "ernie_image_model_loader", + "ernie_image_text_encoder", + "ernie_image_vae_decode", + "integer", + "string" + ] + }, + "qwen-image": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [ + "qwenVLEncoderModel", + "vae" + ] + }, + "nodeTypes": [ + "core_metadata", + "integer", + "qwen_image_denoise", + "qwen_image_l2i", + "qwen_image_model_loader", + "qwen_image_text_encoder", + "string" + ] + }, + "z-image": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [ + "qwen3EncoderModel", + "vae" + ] + }, + "nodeTypes": [ + "collect", + "core_metadata", + "integer", + "string", + "z_image_denoise", + "z_image_l2i", + "z_image_model_loader", + "z_image_text_encoder" + ] + }, + "ideogram-4": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [] + }, + "nodeTypes": [ + "core_metadata", + "ideogram4_caption_builder", + "ideogram4_denoise", + "ideogram4_l2i", + "ideogram4_model_loader", + "ideogram4_text_encoder", + "integer", + "string" + ] + }, + "krea-2": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [ + "qwen3VLEncoderModel", + "vae" + ] + }, + "nodeTypes": [ + "collect", + "core_metadata", + "integer", + "krea2_denoise", + "krea2_model_loader", + "krea2_text_encoder", + "qwen_image_l2i", + "string" + ] + }, + "wan": { + "componentsFilled": { + "diffusers": [], + "standalone-components": [ + "vae", + "wanT5EncoderModel" + ] + }, + "nodeTypes": [ + "core_metadata", + "integer", + "string", + "wan_denoise", + "wan_l2i", + "wan_model_loader", + "wan_text_encoder" + ] + }, + "anima": { + "componentsFilled": { + "diffusers": [ + "qwen3EncoderModel", + "vae" + ], + "standalone-components": [ + "qwen3EncoderModel", + "vae" + ] + }, + "nodeTypes": [ + "anima_denoise", + "anima_l2i", + "anima_model_loader", + "anima_text_encoder", + "collect", + "core_metadata", + "integer", + "string" + ] + } + }, + "fieldsByNodeType": { + "anima_denoise": { + "inputs": [ + "negative_conditioning", + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "anima_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "anima_model_loader": { + "inputs": [], + "outputs": [ + "qwen3_encoder", + "transformer", + "vae" + ] + }, + "anima_text_encoder": { + "inputs": [ + "prompt", + "qwen3_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "clip_skip": { + "inputs": [ + "clip" + ], + "outputs": [ + "clip" + ] + }, + "cogview4_denoise": { + "inputs": [ + "negative_conditioning", + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "cogview4_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "cogview4_model_loader": { + "inputs": [], + "outputs": [ + "glm_encoder", + "transformer", + "vae" + ] + }, + "cogview4_text_encoder": { + "inputs": [ + "glm_encoder", + "prompt" + ], + "outputs": [ + "conditioning" + ] + }, + "collect": { + "inputs": [ + "item" + ], + "outputs": [ + "collection" + ] + }, + "compel": { + "inputs": [ + "clip", + "prompt" + ], + "outputs": [ + "conditioning" + ] + }, + "core_metadata": { + "inputs": [ + "negative_prompt", + "positive_prompt", + "seed" + ], + "outputs": [ + "metadata" + ] + }, + "denoise_latents": { + "inputs": [ + "negative_conditioning", + "noise", + "positive_conditioning", + "unet" + ], + "outputs": [ + "latents" + ] + }, + "ernie_image_denoise": { + "inputs": [ + "negative_conditioning", + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "ernie_image_model_loader": { + "inputs": [], + "outputs": [ + "text_encoder", + "transformer", + "vae" + ] + }, + "ernie_image_text_encoder": { + "inputs": [ + "prompt", + "text_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "ernie_image_vae_decode": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "flux_denoise": { + "inputs": [ + "controlnet_vae", + "positive_text_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "flux_model_loader": { + "inputs": [], + "outputs": [ + "clip", + "max_seq_len", + "t5_encoder", + "transformer", + "vae" + ] + }, + "flux_text_encoder": { + "inputs": [ + "clip", + "prompt", + "t5_encoder", + "t5_max_seq_len" + ], + "outputs": [ + "conditioning" + ] + }, + "flux_vae_decode": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "flux2_denoise": { + "inputs": [ + "positive_text_conditioning", + "seed", + "transformer", + "vae" + ], + "outputs": [ + "latents" + ] + }, + "flux2_dev_model_loader": { + "inputs": [], + "outputs": [ + "max_seq_len", + "mistral_encoder", + "transformer", + "vae" + ] + }, + "flux2_dev_text_encoder": { + "inputs": [ + "max_seq_len", + "mistral_encoder", + "prompt" + ], + "outputs": [ + "conditioning" + ] + }, + "flux2_klein_model_loader": { + "inputs": [], + "outputs": [ + "max_seq_len", + "qwen3_encoder", + "transformer", + "vae" + ] + }, + "flux2_klein_text_encoder": { + "inputs": [ + "max_seq_len", + "prompt", + "qwen3_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "flux2_vae_decode": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "ideogram4_caption_builder": { + "inputs": [ + "prompt" + ], + "outputs": [ + "value" + ] + }, + "ideogram4_denoise": { + "inputs": [ + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "ideogram4_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "ideogram4_model_loader": { + "inputs": [], + "outputs": [ + "qwen3_encoder", + "transformer", + "vae" + ] + }, + "ideogram4_text_encoder": { + "inputs": [ + "prompt", + "qwen3_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "integer": { + "inputs": [], + "outputs": [ + "value" + ] + }, + "krea2_denoise": { + "inputs": [ + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "krea2_model_loader": { + "inputs": [], + "outputs": [ + "qwen3_vl_encoder", + "transformer", + "vae" + ] + }, + "krea2_text_encoder": { + "inputs": [ + "prompt", + "qwen3_vl_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "main_model_loader": { + "inputs": [], + "outputs": [ + "clip", + "unet", + "vae" + ] + }, + "noise": { + "inputs": [ + "seed" + ], + "outputs": [ + "noise" + ] + }, + "qwen_image_denoise": { + "inputs": [ + "negative_conditioning", + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "qwen_image_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "qwen_image_model_loader": { + "inputs": [], + "outputs": [ + "qwen_vl_encoder", + "transformer", + "vae" + ] + }, + "qwen_image_text_encoder": { + "inputs": [ + "prompt", + "qwen_vl_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "sd3_denoise": { + "inputs": [ + "negative_conditioning", + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "sd3_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "sd3_model_loader": { + "inputs": [], + "outputs": [ + "clip_g", + "clip_l", + "t5_encoder", + "transformer", + "vae" + ] + }, + "sd3_text_encoder": { + "inputs": [ + "clip_g", + "clip_l", + "prompt", + "t5_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "sdxl_compel_prompt": { + "inputs": [ + "clip", + "clip2", + "prompt", + "style" + ], + "outputs": [ + "conditioning" + ] + }, + "sdxl_model_loader": { + "inputs": [], + "outputs": [ + "clip", + "clip2", + "unet", + "vae" + ] + }, + "string": { + "inputs": [], + "outputs": [ + "value" + ] + }, + "wan_denoise": { + "inputs": [ + "negative_conditioning", + "positive_conditioning", + "seed", + "transformer" + ], + "outputs": [ + "latents" + ] + }, + "wan_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "wan_model_loader": { + "inputs": [], + "outputs": [ + "transformer", + "vae", + "wan_t5_encoder" + ] + }, + "wan_text_encoder": { + "inputs": [ + "prompt", + "wan_t5_encoder" + ], + "outputs": [ + "conditioning" + ] + }, + "z_image_denoise": { + "inputs": [ + "positive_conditioning", + "seed", + "transformer", + "vae" + ], + "outputs": [ + "latents" + ] + }, + "z_image_l2i": { + "inputs": [ + "latents", + "metadata", + "vae" + ], + "outputs": [] + }, + "z_image_model_loader": { + "inputs": [], + "outputs": [ + "qwen3_encoder", + "transformer", + "vae" + ] + }, + "z_image_text_encoder": { + "inputs": [ + "prompt", + "qwen3_encoder" + ], + "outputs": [ + "conditioning" + ] + } + } +} diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts index 73bc15c6819..bfafee58105 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts @@ -242,6 +242,7 @@ describe('BASE_GENERATION', () => { 'flux', 'flux2', 'cogview4', + 'ernie-image', 'qwen-image', 'z-image', 'ideogram-4', diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts index f7155cd1c43..f15fddb332b 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts @@ -258,6 +258,21 @@ export const BASE_GENERATION = { negativePrompt: { visible: true, usage: 'always' }, ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, }, + 'ernie-image': { + // ernie_image_denoise carries multiple_of=16 on width/height. + dimensions: { grid: 16, optimalSide: 1024 }, + // The base model's numbers. ERNIE-Image-Turbo wants 8 steps at guidance 1.0, which arrives + // through the model's own default_settings rather than a second entry here: Turbo and the + // base model share an architecture and a config, so no variant discriminates them. + defaults: { steps: 50, cfgScale: 4, scheduler: 'euler' }, + // ERNIE_IMAGE_SCHEDULER_MAP is euler/heun/lcm, and the denoise node takes the choice. + schedulerSet: 'flow', + schedulerAppliesToGraph: true, + guidanceLabel: 'CFG', + // negative_conditioning is 'required when guidance_scale != 1.0'. + negativePrompt: { visible: true, usage: 'cfg-gated' }, + ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, + }, 'qwen-image': { dimensions: { grid: 16, optimalSide: 1024 }, defaults: { steps: 40, cfgScale: 4, scheduler: 'euler_a' }, diff --git a/invokeai/frontend/webv2/src/features/generation/core/contracts.ts b/invokeai/frontend/webv2/src/features/generation/core/contracts.ts index 7eb00f48610..a35dd0a01a5 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/contracts.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/contracts.ts @@ -61,6 +61,7 @@ export type KnownGenerationModelBase = | 'flux' | 'flux2' | 'cogview4' + | 'ernie-image' | 'qwen-image' | 'z-image' | 'ideogram-4' diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.ts index 35e9980e8c7..83cafce0d63 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.ts @@ -837,6 +837,61 @@ const buildCogView4Graph = ( return graph; }; +const buildErnieImageGraph = ( + settings: GenerateSettings, + model: MainModelConfig, + outputIsIntermediate: boolean, + projectSettings: GenerationProjectSettings +): BackendGraphContract => { + // No component slots: ernie_image_model_loader reads the transformer, VAE, text encoder and + // optional prompt enhancer out of one diffusers pipeline directory, so there is nothing for the + // user to supply separately and nothing to validate here. + const graph: BackendGraphContract = { edges: [], id: createId('ernie_image_graph'), nodes: {} }; + const { negativePrompt, positivePrompt, seed } = addPromptAndSeedNodes(graph); + const scheduler = coerceSchedulerForGraph(model, settings.scheduler); + const useCfg = settings.cfgScale > 1; + const modelLoader = addNode(graph, { + id: 'model_loader', + model, + type: 'ernie_image_model_loader', + // The enhancer is a separate node with its own prompt rewriting and cannot be idle-offloaded; + // Generate does not surface it, so the loader is told not to hold it resident. + use_prompt_enhancer: false, + }); + const posCond = addNode(graph, { id: 'pos_cond', type: 'ernie_image_text_encoder' }); + const negCond = useCfg ? addNode(graph, { id: 'neg_cond', type: 'ernie_image_text_encoder' }) : null; + const denoise = addNode(graph, { + denoising_end: 1, + denoising_start: 0, + guidance_scale: settings.cfgScale, + height: settings.height, + id: 'denoise_latents', + scheduler, + steps: settings.steps, + type: 'ernie_image_denoise', + width: settings.width, + }); + const output = addImageOutputNode(graph, 'ernie_image_vae_decode', outputIsIntermediate); + + addEdge(graph, modelLoader, 'transformer', denoise, 'transformer'); + addEdge(graph, modelLoader, 'text_encoder', posCond, 'text_encoder'); + addEdge(graph, modelLoader, 'vae', output, 'vae'); + addEdge(graph, positivePrompt, 'value', posCond, 'prompt'); + addEdge(graph, posCond, 'conditioning', denoise, 'positive_conditioning'); + + if (negCond) { + addEdge(graph, modelLoader, 'text_encoder', negCond, 'text_encoder'); + addEdge(graph, negativePrompt, 'value', negCond, 'prompt'); + addEdge(graph, negCond, 'conditioning', denoise, 'negative_conditioning'); + } + + addEdge(graph, seed, 'value', denoise, 'seed'); + addEdge(graph, denoise, 'latents', output, 'latents'); + addMetadata(graph, output, settings, model, 'ernie_image_txt2img', projectSettings); + + return graph; +}; + const buildQwenImageGraph = ( settings: GenerateSettings, model: MainModelConfig, @@ -1408,6 +1463,7 @@ export const GRAPH_BUILDERS = { flux: buildFluxGraph, flux2: buildFlux2Graph, cogview4: buildCogView4Graph, + 'ernie-image': buildErnieImageGraph, 'qwen-image': buildQwenImageGraph, 'z-image': buildZImageGraph, 'ideogram-4': buildIdeogram4Graph, diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts new file mode 100644 index 00000000000..a7ad5376d75 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -0,0 +1,275 @@ +/** + * Every supported base compiles a graph — the systematic counterpart to `graph.test.ts`. + * + * `graph.test.ts` asserts *what* individual families wire up, one hand-written case at a time. That + * leaves a base added to `BASE_GENERATION` and `GRAPH_BUILDERS` but never given a case silently + * untested. This file instead iterates `SUPPORTED_GENERATE_BASES`, so a new architecture is covered + * the moment it is registered, and asserts the properties that hold for *all* of them: the + * component policy is satisfiable, the builder runs, and the resulting graph is structurally sound. + * + * It also writes the node types each base emits to a file snapshot. That file is the frontend half + * of a cross-stack contract — `tests/app/invocations/test_frontend_graph_node_types.py` reads it and + * checks every type against the backend's `InvocationRegistry`. A node moved between modules and + * accidentally renamed shows up there, which no frontend-only assertion can see. + */ + +import type { BackendGraphContract } from '@features/generation/core/contracts'; + +import { describe, expect, it } from 'vitest'; + +import type { + ComponentPolicyContext, + ComponentSlotPolicy, + GenerateComponentValueKey, + SupportedGenerateBase, +} from './baseGenerationPolicies'; +import type { GenerateSettings, MainModelConfig, ModelIdentifierConfig } from './types'; + +import { + getComponentSectionPolicy, + getDefaultGenerateSettings, + getGenerationValidationReasons, + SUPPORTED_GENERATE_BASES, +} from './baseGenerationPolicies'; +import { compileGenerateGraph, GRAPH_BUILDERS } from './graph'; + +/** + * Main-model shapes to compile per base. + * + * `diffusers` bundles its submodels, so the component slots go optional and the builder takes the + * bundled path; a quantized single-file main carries only the transformer and forces the standalone + * -component path. Both paths produce different graphs, so both are worth compiling. + */ +interface ModelShape { + label: string; + overrides: Partial; +} + +const DEFAULT_SHAPES: readonly ModelShape[] = [ + { label: 'diffusers', overrides: { format: 'diffusers' } }, + { label: 'standalone-components', overrides: { format: 'gguf_quantized' } }, +]; + +/** + * Bases whose builder needs more than a format to pick a path. Keys are checked against + * `SUPPORTED_GENERATE_BASES` below, so a renamed or removed base cannot leave a stale entry here. + */ +const SHAPE_OVERRIDES: Partial> = { + // The two FLUX.2 lines take different encoders: [dev] wants Mistral, Klein wants a Qwen3 whose + // variant is pinned to the Klein size by KLEIN_TO_QWEN3_VARIANT. + flux2: [ + { label: 'dev-diffusers', overrides: { format: 'diffusers', variant: 'dev' } }, + { label: 'dev-standalone', overrides: { format: 'gguf_quantized', variant: 'dev' } }, + { label: 'klein-9b-standalone', overrides: { format: 'gguf_quantized', variant: 'klein_9b' } }, + ], +}; + +const shapesForBase = (base: SupportedGenerateBase): readonly ModelShape[] => SHAPE_OVERRIDES[base] ?? DEFAULT_SHAPES; + +/** + * Candidate components the slot filters get to choose from. + * + * Deliberately a search over a pool rather than a hand-written model per slot: the filters + * (`isAnimaQwen3Encoder`, `isKrea2Vae`, `isFlux2Qwen3EncoderForModel`, ...) encode which base and + * variant a component must carry, and duplicating that knowledge here would make the test agree + * with itself instead of with the policy. + */ +const CANDIDATE_BASES = ['any', ...SUPPORTED_GENERATE_BASES] as const; +const CANDIDATE_VARIANTS = [ + undefined, + 'qwen3_06b', + 'qwen3_4b', + 'qwen3_8b', + 'large', + 'gigantic', + 'dev', + 'klein_4b', + 'klein_9b', +] as const; + +const candidatesForSlot = (slot: ComponentSlotPolicy): ModelIdentifierConfig[] => { + const candidates: ModelIdentifierConfig[] = []; + + for (const type of slot.modelTypes) { + for (const base of CANDIDATE_BASES) { + for (const variant of CANDIDATE_VARIANTS) { + candidates.push({ + base, + // A component source is a main model, and only a bundled one can stand in for the slots + // it satisfies — `isDiffusersMainForBase` and `isBundledMainForBase` both demand it. + format: slot.valueKind === 'main' ? 'diffusers' : undefined, + key: `${base}-${type}-${variant ?? 'novariant'}`, + name: `${base} ${type} ${variant ?? ''}`.trim(), + type, + variant: variant ?? null, + }); + } + } + } + + return candidates; +}; + +const buildContext = ( + model: MainModelConfig, + settings: GenerateSettings, + slots: readonly ComponentSlotPolicy[] +): ComponentPolicyContext => { + // Derived from the slots rather than from a hand-kept key list: a new slot key is picked up here + // automatically, and a key that no slot uses cannot go stale. + const keys = new Set(slots.map((slot) => slot.key)); + const selectedComponents = {} as ComponentPolicyContext['selectedComponents']; + + for (const key of keys) { + selectedComponents[key] = settings[key] as never; + } + + return { model, settings, selectedComponents }; +}; + +/** + * Fill every required component slot with something the slot's own filter accepts. + * + * Iterated to a fixpoint because slots are interdependent: selecting a component source can make + * the VAE and encoder slots stop being required, so one pass is not enough to reach a stable answer. + */ +const satisfyRequiredComponents = ( + model: MainModelConfig, + initial: GenerateSettings +): { settings: GenerateSettings; filled: GenerateComponentValueKey[] } => { + let settings = initial; + const filled: GenerateComponentValueKey[] = []; + + for (let pass = 0; pass < 5; pass++) { + const { slots } = getComponentSectionPolicy(model, settings); + const context = buildContext(model, settings, slots); + let changed = false; + + for (const slot of slots) { + if (!slot.required?.(context) || settings[slot.key]) { + continue; + } + + const candidate = candidatesForSlot(slot).find((c) => !slot.filter || slot.filter(c, context)); + + if (candidate) { + settings = { ...settings, [slot.key]: candidate }; + filled.push(slot.key); + changed = true; + } + } + + if (!changed) { + break; + } + } + + return { filled: filled.sort(), settings }; +}; + +const createModel = (base: SupportedGenerateBase, shape: ModelShape): MainModelConfig => ({ + base, + key: `${base}-${shape.label}`, + name: `${base} (${shape.label})`, + type: 'main', + ...shape.overrides, +}); + +const compileForShape = ( + base: SupportedGenerateBase, + shape: ModelShape +): { filled: GenerateComponentValueKey[]; graph: BackendGraphContract } => { + const model = createModel(base, shape); + const { filled, settings } = satisfyRequiredComponents(model, { + ...getDefaultGenerateSettings(model), + positivePrompt: 'a test prompt', + seed: 1, + shouldRandomizeSeed: false, + }); + + // Compiling an invalid selection throws the first reason, which makes for a poor failure message. + // Asserting here reports every unmet requirement at once, and doubles as the check that the + // base's component policy is satisfiable at all. + expect(getGenerationValidationReasons(model, settings), `${base}/${shape.label} is not satisfiable`).toEqual([]); + + return { filled, graph: compileGenerateGraph(settings, model, 'gallery', { useCpuNoise: true }).backendGraph }; +}; + +const cases = SUPPORTED_GENERATE_BASES.flatMap((base) => + shapesForBase(base).map((shape) => ({ base, label: `${base} / ${shape.label}`, shape })) +); + +describe('generate graph coverage', () => { + it('has a builder for every supported base and no builder for anything else', () => { + expect(Object.keys(GRAPH_BUILDERS).sort()).toEqual([...SUPPORTED_GENERATE_BASES].sort()); + }); + + it('declares shape overrides only for bases that exist', () => { + expect(Object.keys(SHAPE_OVERRIDES).filter((base) => !SUPPORTED_GENERATE_BASES.includes(base as never))).toEqual( + [] + ); + }); + + it.each(cases)('compiles a structurally sound graph for $label', ({ base, shape }) => { + const { graph } = compileForShape(base, shape); + const nodeIds = new Set(Object.keys(graph.nodes)); + + expect(nodeIds.size).toBeGreaterThan(0); + + for (const [id, node] of Object.entries(graph.nodes)) { + expect(node.id, `node keyed '${id}' carries a mismatched id`).toBe(id); + expect(node.type, `node '${id}' has no type`).toBeTruthy(); + } + + for (const edge of graph.edges) { + const description = `${edge.source.node_id}.${edge.source.field} -> ${edge.destination.node_id}.${edge.destination.field}`; + + expect(nodeIds.has(edge.source.node_id), `dangling source in edge ${description}`).toBe(true); + expect(nodeIds.has(edge.destination.node_id), `dangling destination in edge ${description}`).toBe(true); + expect(edge.source.field, `edge ${description} has no source field`).toBeTruthy(); + expect(edge.destination.field, `edge ${description} has no destination field`).toBeTruthy(); + } + }); + + it('emits the node types and fields the backend has to provide', async () => { + const byBase: Record; nodeTypes: string[] }> = {}; + const fields: Record; outputs: Set }> = {}; + + const fieldsFor = (nodeType: string) => (fields[nodeType] ??= { inputs: new Set(), outputs: new Set() }); + + for (const { base, shape } of cases) { + const { filled, graph } = compileForShape(base, shape); + const entry = (byBase[base] ??= { componentsFilled: {}, nodeTypes: [] }); + + entry.componentsFilled[shape.label] = filled; + entry.nodeTypes = [ + ...new Set([...entry.nodeTypes, ...Object.values(graph.nodes).map((node) => node.type)]), + ].sort(); + + for (const edge of graph.edges) { + fieldsFor(graph.nodes[edge.source.node_id]!.type).outputs.add(edge.source.field); + fieldsFor(graph.nodes[edge.destination.node_id]!.type).inputs.add(edge.destination.field); + } + } + + const contract = { + _comment: + 'Generated by src/features/generation/core/graphCoverage.test.ts. Update with `vitest -u`. ' + + 'Consumed by tests/app/invocations/test_frontend_graph_node_types.py, which checks every ' + + 'node type and field below against the backend invocation registry.', + byBase, + fieldsByNodeType: Object.fromEntries( + Object.entries(fields) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([nodeType, { inputs, outputs }]) => [ + nodeType, + { inputs: [...inputs].sort(), outputs: [...outputs].sort() }, + ]) + ), + }; + + await expect(`${JSON.stringify(contract, null, 2)}\n`).toMatchFileSnapshot( + './__snapshots__/generateGraphNodeTypes.json' + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/models/core/baseIdentity.test.ts b/invokeai/frontend/webv2/src/features/models/core/baseIdentity.test.ts index 6795e28c2ae..ca098991560 100644 --- a/invokeai/frontend/webv2/src/features/models/core/baseIdentity.test.ts +++ b/invokeai/frontend/webv2/src/features/models/core/baseIdentity.test.ts @@ -75,6 +75,7 @@ describe('MODEL_BASES', () => { 'flux', 'flux2', 'cogview4', + 'ernie-image', 'qwen-image', 'z-image', 'ideogram-4', diff --git a/invokeai/frontend/webv2/src/features/models/core/baseIdentity.ts b/invokeai/frontend/webv2/src/features/models/core/baseIdentity.ts index fc30aecb62e..44932406fb0 100644 --- a/invokeai/frontend/webv2/src/features/models/core/baseIdentity.ts +++ b/invokeai/frontend/webv2/src/features/models/core/baseIdentity.ts @@ -69,6 +69,11 @@ export const MODEL_BASES = { label: 'CogView4', colorPalette: 'red', }, + 'ernie-image': { + base: 'ernie-image', + label: 'ERNIE-Image', + colorPalette: 'orange', + }, 'qwen-image': { base: 'qwen-image', label: 'Qwen Image', diff --git a/invokeai/frontend/webv2/src/features/models/core/types.ts b/invokeai/frontend/webv2/src/features/models/core/types.ts index ded0992f278..8fb39556b7a 100644 --- a/invokeai/frontend/webv2/src/features/models/core/types.ts +++ b/invokeai/frontend/webv2/src/features/models/core/types.ts @@ -17,6 +17,7 @@ export type ModelBase = | 'flux' | 'flux2' | 'cogview4' + | 'ernie-image' | 'qwen-image' | 'z-image' | 'ideogram-4' diff --git a/invokeai/frontend/webv2/src/features/workflow/core/modelRequirements.ts b/invokeai/frontend/webv2/src/features/workflow/core/modelRequirements.ts index 1233c60167b..7b3f55ce876 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/modelRequirements.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/modelRequirements.ts @@ -37,6 +37,7 @@ const BASE_LABELS: Record = { any: 'Any', anima: 'Anima', cogview4: 'CogView4', + 'ernie-image': 'ERNIE-Image', external: 'External', flux: 'FLUX', flux2: 'FLUX.2', diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts index fd01aabc8ec..7d0513024bc 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts @@ -11,7 +11,7 @@ export const DEFAULT_MODEL_GRID = 8; /** * The bbox grid size for a model base: * - `cogview4` → 32 - * - `flux` / `flux2` / `sd-3` / `qwen-image` / `z-image` → 16 + * - `flux` / `flux2` / `sd-3` / `qwen-image` / `z-image` / `ernie-image` → 16 * - everything else (sd-1/sd-2/sdxl/anima/unknown) → 8 */ export const gridSizeForModelBase = (base: string | null | undefined): number => { @@ -23,6 +23,7 @@ export const gridSizeForModelBase = (base: string | null | undefined): number => case 'sd-3': case 'qwen-image': case 'z-image': + case 'ernie-image': return 16; default: return DEFAULT_MODEL_GRID; diff --git a/tests/app/invocations/test_frontend_graph_node_types.py b/tests/app/invocations/test_frontend_graph_node_types.py new file mode 100644 index 00000000000..87101c92e49 --- /dev/null +++ b/tests/app/invocations/test_frontend_graph_node_types.py @@ -0,0 +1,92 @@ +"""The graphs webv2 compiles must be buildable from the invocations this backend registers. + +The frontend builds generation graphs from its own per-base tables and never imports a backend +schema, so a node type or field that moves, is renamed, or loses its module import fails at +*enqueue* time with a validation error against a graph the user cannot edit. Nothing in either test +suite sees that on its own: the frontend only knows the strings it emits, and the backend only knows +the invocations it has. + +The contract in between is `generateGraphNodeTypes.json`, written by +`src/features/generation/core/graphCoverage.test.ts` (regenerate with `vitest -u`). It records every +node type and every edge field that webv2's `compileGenerateGraph` produces for every supported +architecture. This module checks all of it against the real registry. +""" + +import json +from pathlib import Path +from typing import Any + +import pytest + +from invokeai.app.invocations.baseinvocation import BaseInvocation, InvocationRegistry +from invokeai.app.services.shared.graph import * # noqa: F401 F403 -- imports all invocations, populating the registry + +CONTRACT_PATH = ( + Path(__file__).parents[3] + / "invokeai" + / "frontend" + / "webv2" + / "src" + / "features" + / "generation" + / "core" + / "__snapshots__" + / "generateGraphNodeTypes.json" +) + + +def _contract() -> dict[str, Any]: + return json.loads(CONTRACT_PATH.read_text(encoding="utf-8")) + + +CONTRACT = _contract() +BY_BASE: dict[str, dict[str, Any]] = CONTRACT["byBase"] +FIELDS_BY_NODE_TYPE: dict[str, dict[str, list[str]]] = CONTRACT["fieldsByNodeType"] + + +def _invocation(node_type: str) -> type[BaseInvocation]: + cls = InvocationRegistry.get_invocations_map().get(node_type) + assert cls is not None, ( + f"webv2 compiles '{node_type}' into a generation graph but no invocation is registered under " + f"that type. Either the node was renamed, or its module is no longer imported — see " + f"tests/app/invocations/test_node_discovery.py." + ) + return cls + + +def test_the_contract_covers_every_supported_base() -> None: + """Guard against a contract that silently stopped being regenerated. + + An empty or truncated file would make every assertion below vacuous. 14 is the current length of + webv2's `SUPPORTED_GENERATE_BASES`; a new architecture should update this number and the file in + the same commit. + """ + assert len(BY_BASE) == 14 + assert all(entry["nodeTypes"] for entry in BY_BASE.values()) + + +@pytest.mark.parametrize("base", sorted(BY_BASE), ids=lambda base: base) +def test_every_node_type_a_base_compiles_is_registered(base: str) -> None: + missing = [ + node_type + for node_type in BY_BASE[base]["nodeTypes"] + if node_type not in InvocationRegistry.get_invocations_map() + ] + assert missing == [], f"webv2's '{base}' graph uses unregistered node types: {missing}" + + +@pytest.mark.parametrize("node_type", sorted(FIELDS_BY_NODE_TYPE), ids=lambda node_type: node_type) +def test_edge_destination_fields_are_real_invocation_fields(node_type: str) -> None: + """An edge into a field the invocation does not have is rejected when the graph is enqueued.""" + cls = _invocation(node_type) + unknown = sorted(set(FIELDS_BY_NODE_TYPE[node_type]["inputs"]) - set(cls.model_fields)) + assert unknown == [], f"webv2 wires edges into unknown inputs on '{node_type}': {unknown}" + + +@pytest.mark.parametrize("node_type", sorted(FIELDS_BY_NODE_TYPE), ids=lambda node_type: node_type) +def test_edge_source_fields_are_real_output_fields(node_type: str) -> None: + """The other half: an edge out of a field the invocation's output does not expose.""" + cls = _invocation(node_type) + output_fields = set(cls.get_output_annotation().model_fields) + unknown = sorted(set(FIELDS_BY_NODE_TYPE[node_type]["outputs"]) - output_fields) + assert unknown == [], f"webv2 wires edges out of unknown outputs on '{node_type}': {unknown}" From 692ad1e60df7cf5155e23248f7f7f6fb79d0dace Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 02:46:10 +0200 Subject: [PATCH 24/26] fix(architectures): declare the Wan video modes upstream added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `main` gained `wan_t2v`, `wan_interpolate` and `wan_extend_video` in `GENERATION_MODES`. Both sides merged without a conflict — the literal and the declaration live in different files and neither was touched by the other — and `test_the_declarations_reconstruct_generation_modes` caught the drift. `interpolate` (fill between two given frames) is a new kind, so `GenerationModeKind` grows too; `extend_video` already existed from MiniMax H3. Since that literal is served through the capabilities endpoint, `openapi.json` and `schema.ts` are regenerated — the delta is one added enum value in `ArchitectureModality.modes`, purely additive for any client. Verified the three modes are genuinely new upstream rather than a gap this branch had all along: they are absent from `metadata.py` at the merge base. fix(webv2): exclude the generated graph contract from oxfmt format:check and the test that writes generateGraphNodeTypes.json both formatted the same bytes, so each undid the other's layout and CI failed on whichever ran last. The file is generated, so the test owns it alone. --- invokeai/backend/architectures/defs/wan.py | 8 ++++++-- .../backend/architectures/facets/modality.py | 10 ++++++---- invokeai/frontend/web/openapi.json | 13 +++++++++++- .../frontend/web/src/services/api/schema.ts | 2 +- invokeai/frontend/webv2/.oxfmtrc.json | 7 ++++++- .../__snapshots__/generateGraphNodeTypes.json | 2 +- .../generation/core/graphCoverage.test.ts | 20 ++++++++++++------- 7 files changed, 45 insertions(+), 17 deletions(-) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index 1cea381e21c..0a14b36d4e5 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -24,8 +24,12 @@ None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024), } ), - # Plus image-to-video. Wan generates images at num_frames=1 and video above that. - ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint", "i2v"}), metadata_slug="wan"), + # Wan generates images at num_frames=1 and video above that, from text or from one or two + # given frames -- `interpolate` fills between two, `extend_video` continues an existing clip. + ModalityFacet( + frozenset({"txt2img", "img2img", "inpaint", "outpaint", "t2v", "i2v", "interpolate", "extend_video"}), + metadata_slug="wan", + ), FeaturesFacet( negative_prompt=NegativePrompt(visible=True, usage="always"), dimension_grid=16, diff --git a/invokeai/backend/architectures/facets/modality.py b/invokeai/backend/architectures/facets/modality.py index 28836a80f6f..aedef2f6014 100644 --- a/invokeai/backend/architectures/facets/modality.py +++ b/invokeai/backend/architectures/facets/modality.py @@ -18,12 +18,14 @@ from invokeai.backend.architectures.facet import Facet from invokeai.backend.architectures.registry import generative_bases, get -GenerationModeKind = Literal["txt2img", "img2img", "inpaint", "outpaint", "t2v", "i2v", "lf2v", "flf2v", "extend_video"] +GenerationModeKind = Literal[ + "txt2img", "img2img", "inpaint", "outpaint", "t2v", "i2v", "lf2v", "flf2v", "interpolate", "extend_video" +] """The kinds of generation a mode string names. -`t2v`/`i2v` produce video, as do the three MiniMax H3 conditioning variants: `lf2v` (last frame to -video), `flf2v` (first and last frame to video) and `extend_video` (continue an existing clip). The -rest produce images.""" +`t2v`/`i2v` produce video, as do the conditioning variants: `lf2v` (last frame to video), `flf2v` +(first and last frame to video), `interpolate` (between two given images) and `extend_video` +(continue an existing clip). The rest produce images.""" @dataclass(frozen=True) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 494a3127a3e..e9dc358f0ae 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -16530,7 +16530,18 @@ "modes": { "items": { "type": "string", - "enum": ["txt2img", "img2img", "inpaint", "outpaint", "t2v", "i2v", "lf2v", "flf2v", "extend_video"] + "enum": [ + "txt2img", + "img2img", + "inpaint", + "outpaint", + "t2v", + "i2v", + "lf2v", + "flf2v", + "interpolate", + "extend_video" + ] }, "type": "array", "title": "Modes", diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 8715a278439..875b37fa74c 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -4851,7 +4851,7 @@ export type components = { * Modes * @description Sorted. Empty means it generates nothing on its own. */ - modes: ("txt2img" | "img2img" | "inpaint" | "outpaint" | "t2v" | "i2v" | "lf2v" | "flf2v" | "extend_video")[]; + modes: ("txt2img" | "img2img" | "inpaint" | "outpaint" | "t2v" | "i2v" | "lf2v" | "flf2v" | "interpolate" | "extend_video")[]; /** * Metadata Slug * @description Prefix its mode strings carry in image metadata; null means unprefixed. diff --git a/invokeai/frontend/webv2/.oxfmtrc.json b/invokeai/frontend/webv2/.oxfmtrc.json index a261d816261..d53c3753615 100644 --- a/invokeai/frontend/webv2/.oxfmtrc.json +++ b/invokeai/frontend/webv2/.oxfmtrc.json @@ -4,7 +4,12 @@ "bracketSameLine": false, "bracketSpacing": true, "endOfLine": "lf", - "ignorePatterns": ["dist/**", "node_modules/**", "stats.html"], + "ignorePatterns": [ + "dist/**", + "node_modules/**", + "stats.html", + "src/features/generation/core/__snapshots__/generateGraphNodeTypes.json" + ], "printWidth": 120, "semi": true, "singleQuote": true, diff --git a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json index 3c1c73027c1..1942f85a468 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json +++ b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json @@ -1,5 +1,5 @@ { - "_comment": "Generated by src/features/generation/core/graphCoverage.test.ts. Update with `vitest -u`. Consumed by tests/app/invocations/test_frontend_graph_node_types.py, which checks every node type and field below against the backend invocation registry.", + "_comment": "Generated by src/features/generation/core/graphCoverage.test.ts; regenerate with `vitest -u`. Excluded from oxfmt so the test alone owns its layout. Consumed by tests/app/invocations/test_frontend_graph_node_types.py, which checks every node type and field below against the backend invocation registry.", "byBase": { "sd-1": { "componentsFilled": { diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts index a7ad5376d75..9357826fda9 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -7,10 +7,15 @@ * the moment it is registered, and asserts the properties that hold for *all* of them: the * component policy is satisfiable, the builder runs, and the resulting graph is structurally sound. * - * It also writes the node types each base emits to a file snapshot. That file is the frontend half - * of a cross-stack contract — `tests/app/invocations/test_frontend_graph_node_types.py` reads it and - * checks every type against the backend's `InvocationRegistry`. A node moved between modules and - * accidentally renamed shows up there, which no frontend-only assertion can see. + * It also checks the node types and edge fields each base emits against a committed fixture. That + * fixture is the frontend half of a cross-stack contract — `tests/app/invocations/ + * test_frontend_graph_node_types.py` reads it and checks every type and field against the backend's + * `InvocationRegistry`. A node moved between modules and accidentally renamed shows up there, which + * no frontend-only assertion can see. + * + * The snapshot file is written by this test and by nothing else — `.oxfmtrc.json` excludes it for + * that reason. Letting the formatter reflow a generated file too would leave both in charge of the + * same bytes, and `format:check` would fail on whichever layout the other one wrote last. */ import type { BackendGraphContract } from '@features/generation/core/contracts'; @@ -254,9 +259,10 @@ describe('generate graph coverage', () => { const contract = { _comment: - 'Generated by src/features/generation/core/graphCoverage.test.ts. Update with `vitest -u`. ' + - 'Consumed by tests/app/invocations/test_frontend_graph_node_types.py, which checks every ' + - 'node type and field below against the backend invocation registry.', + 'Generated by src/features/generation/core/graphCoverage.test.ts; regenerate with ' + + '`vitest -u`. Excluded from oxfmt so the test alone owns its layout. Consumed by ' + + 'tests/app/invocations/test_frontend_graph_node_types.py, which checks every node type and ' + + 'field below against the backend invocation registry.', byBase, fieldsByNodeType: Object.fromEntries( Object.entries(fields) From c1a507e608f4f7317fdf570dfd92a7902e8748d3 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 03:50:50 +0200 Subject: [PATCH 25/26] refactor(invocations): replace star import with explicit module loading to prevent import cycles --- invokeai/app/invocations/__init__.py | 31 +++++++----- invokeai/app/services/shared/graph.py | 8 ++- .../test_invocation_api_imports_alone.py | 49 +++++++++++++++++++ tests/app/invocations/test_node_discovery.py | 8 ++- 4 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 tests/app/invocations/test_invocation_api_imports_alone.py diff --git a/invokeai/app/invocations/__init__.py b/invokeai/app/invocations/__init__.py index d71ed35319b..0b813fd7b05 100644 --- a/invokeai/app/invocations/__init__.py +++ b/invokeai/app/invocations/__init__.py @@ -1,12 +1,19 @@ -"""Core invocation modules, imported for their side effects. +"""Core invocation modules. -Every module below registers its `@invocation`-decorated classes with `InvocationRegistry` as it is -imported, so the app is only correct once *all* of them have been imported. That import is triggered -by `from invokeai.app.invocations import *` in `invokeai.app.services.shared.graph`. +Every module in this package registers its `@invocation`-decorated classes with +`InvocationRegistry` as it is imported, so the app is only correct once *all* of them have been +imported. `load_all_modules()` does that, and `invokeai.app.services.shared.graph` calls it. + +Importing them here, in the package body, would be the obvious shortcut and is wrong: it makes +`import invokeai.app.invocations.anything` -- including the `baseinvocation` import that +`invokeai.invocation_api` starts with -- pull in the whole tree. A node module that imports +`invocation_api` back (`composition-nodes.py` does) then closes a cycle, and +`import invokeai.invocation_api` fails outright with a partially initialized module. That is the +first import in the documented node-pack guide, so it has to stay cheap. Discovery walks the whole package tree rather than globbing `*.py` in this directory. Node modules are grouped into per-architecture subpackages (`flux/`, `wan/`, ...) and cross-cutting ones (`vae/`, -`text_encoder/`, `pid/`), and a flat glob would skip every one of them silently — the failure would +`text_encoder/`, `pid/`), and a flat glob would skip every one of them silently -- the failure would not surface at boot but later, as an "unknown node type" when a user opens a workflow that uses one. """ @@ -16,11 +23,11 @@ from invokeai.backend.util.module_discovery import discover_modules -_MODULES: dict[str, ModuleType] = { - name: import_module(name) for name in discover_modules(Path(__file__).parent, f"{__name__}.") -} -# `import *` binds names, and a dotted name is not one. Only the top component of each module path -# is an attribute of this package, so a node in a subpackage contributes that subpackage's name. -# Binding is incidental here anyway — the registration this module exists for already happened above. -__all__ = sorted({name.removeprefix(f"{__name__}.").split(".", 1)[0] for name in _MODULES}) +def load_all_modules() -> dict[str, ModuleType]: + """Import every node module in this package, registering the invocations it declares. + + Idempotent: `import_module` returns the cached module on later calls, so callers do not have to + coordinate who invokes it first. + """ + return {name: import_module(name) for name in discover_modules(Path(__file__).parent, f"{__name__}.")} diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index 1d233e028a9..c267f82fbfb 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -36,8 +36,7 @@ from pydantic.json_schema import JsonSchemaValue from pydantic_core import core_schema -# Importing * is bad karma but needed here for node detection -from invokeai.app.invocations import * # noqa: F401 F403 +from invokeai.app.invocations import load_all_modules from invokeai.app.invocations.baseinvocation import ( BaseInvocation, BaseInvocationOutput, @@ -54,6 +53,11 @@ from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.misc import uuid_string +# Node detection: every core invocation must be registered before the unions below are built from +# `InvocationRegistry`. This is a call rather than a star-import so that importing a single node +# module -- or `invokeai.invocation_api`, which imports one -- does not drag in the whole tree. +load_all_modules() + if TYPE_CHECKING: import networkx as nx else: diff --git a/tests/app/invocations/test_invocation_api_imports_alone.py b/tests/app/invocations/test_invocation_api_imports_alone.py new file mode 100644 index 00000000000..7aca72c1bd3 --- /dev/null +++ b/tests/app/invocations/test_invocation_api_imports_alone.py @@ -0,0 +1,49 @@ +"""`import invokeai.invocation_api` must work as the first invokeai import in a process. + +It is the first import in the node-pack guide, so every custom node pack starts here. It broke once: +the node modules were imported in `invokeai/app/invocations/__init__.py`, which made touching *any* +submodule -- including the `baseinvocation` import that `invocation_api` itself begins with -- pull +in the whole tree. `composition-nodes.py` imports `invocation_api` back, and the cycle closed: +`ImportError: cannot import name 'BaseInvocation' from partially initialized module`. + +The app never noticed, because by the time it loads custom nodes everything is already imported. +Only a fresh process starting at `invocation_api` sees it, which is why this runs in a subprocess. +""" + +import subprocess +import sys + +PROGRAM = """ +import sys + +import invokeai.invocation_api as api + +assert "BaseInvocation" in api.__all__, "invocation_api did not export its own surface" +assert api.BaseInvocation is not None + +# The point of the split: importing the public surface must not drag in every node module. If this +# ever has to change, the cycle above is what will break. +loaded = [m for m in sys.modules if m.startswith("invokeai.app.invocations.") and "flux" in m] +assert not loaded, f"invocation_api pulled in node modules: {sorted(loaded)[:5]}" + +print("OK") +""" + + +def test_invocation_api_is_importable_on_its_own() -> None: + result = subprocess.run([sys.executable, "-c", PROGRAM], capture_output=True, text=True, timeout=300) + assert result.returncode == 0, f"stdout:\n{result.stdout}\nstderr:\n{result.stderr[-3000:]}" + assert "OK" in result.stdout + + +def test_the_documented_node_pack_imports_work_on_their_own() -> None: + """What `docs/development/Guides/creating-node-pack.mdx` tells authors to write, verbatim.""" + program = ( + "from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation\n" + "from invokeai.app.invocations.fields import InputField, OutputField\n" + "from invokeai.invocation_api import BaseInvocationOutput, invocation_output\n" + "print('OK')\n" + ) + result = subprocess.run([sys.executable, "-c", program], capture_output=True, text=True, timeout=300) + assert result.returncode == 0, f"stderr:\n{result.stderr[-3000:]}" + assert "OK" in result.stdout diff --git a/tests/app/invocations/test_node_discovery.py b/tests/app/invocations/test_node_discovery.py index a19d8e9f50f..69b6f8267b5 100644 --- a/tests/app/invocations/test_node_discovery.py +++ b/tests/app/invocations/test_node_discovery.py @@ -4,11 +4,15 @@ `tests/backend/util/test_module_discovery.py`. What is left to check here is that this package's real layout agrees with it — the realistic mistake being a new architecture folder that never got an `__init__.py`, which is not a package and therefore contributes no nodes at all. + +Calling `load_all_modules()` here rather than reading a module-level dict is the point of the +split: the package body must stay cheap so that `import invokeai.invocation_api` -- the first +import in the node-pack guide -- does not pull in this tree and close an import cycle. """ from pathlib import Path -from invokeai.app.invocations import _MODULES +from invokeai.app.invocations import load_all_modules PACKAGE = "invokeai.app.invocations" @@ -20,4 +24,4 @@ def test_every_node_module_on_disk_was_imported() -> None: for p in root.rglob("*.py") if not any(part.startswith("_") for part in p.relative_to(root).parts) } - assert on_disk == set(_MODULES), "walker and filesystem disagree" + assert on_disk == set(load_all_modules()), "walker and filesystem disagree" From 89288323c0d95b445360393e3da4f6b0590bf9a9 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 04:19:22 +0200 Subject: [PATCH 26/26] refactor(architectures): update default settings to include scheduler for various models --- invokeai/backend/architectures/defs/anima.py | 4 +- .../backend/architectures/defs/cogview4.py | 4 +- .../backend/architectures/defs/ernie_image.py | 6 ++- invokeai/backend/architectures/defs/flux.py | 10 +++-- invokeai/backend/architectures/defs/flux2.py | 12 ++++-- .../backend/architectures/defs/ideogram_4.py | 11 ++++- invokeai/backend/architectures/defs/krea_2.py | 6 ++- .../backend/architectures/defs/qwen_image.py | 4 +- invokeai/backend/architectures/defs/sd_1.py | 4 +- invokeai/backend/architectures/defs/sd_2.py | 4 +- invokeai/backend/architectures/defs/sd_3.py | 4 +- invokeai/backend/architectures/defs/sdxl.py | 4 +- invokeai/backend/architectures/defs/wan.py | 6 ++- .../backend/architectures/defs/z_image.py | 6 ++- .../backend/architectures/facets/features.py | 10 ++++- .../architectures/test_default_settings.py | 43 ++++++++++++++++++- tests/backend/architectures/test_features.py | 22 ++++++++++ 17 files changed, 133 insertions(+), 27 deletions(-) diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py index d6ca61575d6..0953d44fb34 100644 --- a/invokeai/backend/architectures/defs/anima.py +++ b/invokeai/backend/architectures/defs/anima.py @@ -15,7 +15,9 @@ BaseModelType.Anima, LatentSpaceFacet(WAN21_16), ConditioningFacet(AnimaConditioningInfo), - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=35, cfg_scale=4.5, width=1024, height=1024)}), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler", steps=35, cfg_scale=4.5, width=1024, height=1024)} + ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="anima"), FeaturesFacet( negative_prompt=NegativePrompt(visible=True, usage="cfg-gated"), diff --git a/invokeai/backend/architectures/defs/cogview4.py b/invokeai/backend/architectures/defs/cogview4.py index a6e35ad62c3..68552b92d2c 100644 --- a/invokeai/backend/architectures/defs/cogview4.py +++ b/invokeai/backend/architectures/defs/cogview4.py @@ -17,7 +17,9 @@ # THUDM/CogView4-6B's own example: 50 steps at guidance 3.5, 1024x1024. This is true # classifier-free guidance, so it belongs in cfg_scale — and the denoise node already # defaults to 3.5, which nothing was propagating to the sliders. - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=50, cfg_scale=3.5, width=1024, height=1024)}), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler_a", steps=50, cfg_scale=3.5, width=1024, height=1024)} + ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="cogview4"), FeaturesFacet( negative_prompt=NegativePrompt(visible=True, usage="always"), diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py index 6431e7226a3..7e9ca997f36 100644 --- a/invokeai/backend/architectures/defs/ernie_image.py +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -18,10 +18,12 @@ LatentSpaceFacet(FLUX2_32), ConditioningFacet(ErnieImageConditioningInfo), DefaultSettingsFacet( - {None: MainModelDefaultSettings(steps=50, cfg_scale=4.0, width=1024, height=1024)}, + {None: MainModelDefaultSettings(scheduler="euler", steps=50, cfg_scale=4.0, width=1024, height=1024)}, # Turbo and the base model share an architecture and a config, so there is nothing on # disk to discriminate on and no variant is modeled. The name is the only signal. - by_name_hint={"turbo": MainModelDefaultSettings(steps=8, cfg_scale=1.0, width=1024, height=1024)}, + by_name_hint={ + "turbo": MainModelDefaultSettings(scheduler="euler", steps=8, cfg_scale=1.0, width=1024, height=1024) + }, ), # Text-to-image only. ModalityFacet(frozenset({"txt2img"}), metadata_slug="ernie_image"), diff --git a/invokeai/backend/architectures/defs/flux.py b/invokeai/backend/architectures/defs/flux.py index 614456ff740..f9bcef9ee34 100644 --- a/invokeai/backend/architectures/defs/flux.py +++ b/invokeai/backend/architectures/defs/flux.py @@ -20,13 +20,17 @@ DefaultSettingsFacet( { # schnell is timestep-distilled: 4 steps, and it ignores guidance entirely. - FluxVariantType.Schnell: MainModelDefaultSettings(steps=4, cfg_scale=1.0, width=1024, height=1024), + FluxVariantType.Schnell: MainModelDefaultSettings( + scheduler="euler", steps=4, cfg_scale=1.0, width=1024, height=1024 + ), FluxVariantType.DevFill: MainModelDefaultSettings( - steps=50, cfg_scale=1.0, guidance=30.0, width=1024, height=1024 + scheduler="euler", steps=50, cfg_scale=1.0, guidance=30.0, width=1024, height=1024 ), # dev. The card's example uses 50 steps; 28 is the de-facto standard and what FLUX.2 # [dev] already declares here, so the two stay consistent. - None: MainModelDefaultSettings(steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024), + None: MainModelDefaultSettings( + scheduler="euler", steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024 + ), } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="flux"), diff --git a/invokeai/backend/architectures/defs/flux2.py b/invokeai/backend/architectures/defs/flux2.py index 75c7ac53588..7dfa5e84247 100644 --- a/invokeai/backend/architectures/defs/flux2.py +++ b/invokeai/backend/architectures/defs/flux2.py @@ -19,13 +19,17 @@ { # [dev] is guidance-distilled: guidance 3.5, 28 steps, CFG off. Flux2VariantType.Dev: MainModelDefaultSettings( - steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024 + scheduler="euler", steps=28, cfg_scale=1.0, guidance=3.5, width=1024, height=1024 ), # The undistilled Klein bases need the steps but not the guidance. - Flux2VariantType.Klein4BBase: MainModelDefaultSettings(steps=28, cfg_scale=1.0, width=1024, height=1024), - Flux2VariantType.Klein9BBase: MainModelDefaultSettings(steps=28, cfg_scale=1.0, width=1024, height=1024), + Flux2VariantType.Klein4BBase: MainModelDefaultSettings( + scheduler="euler", steps=28, cfg_scale=1.0, width=1024, height=1024 + ), + Flux2VariantType.Klein9BBase: MainModelDefaultSettings( + scheduler="euler", steps=28, cfg_scale=1.0, width=1024, height=1024 + ), # Distilled Klein 4B / 9B. - None: MainModelDefaultSettings(steps=4, cfg_scale=1.0, width=1024, height=1024), + None: MainModelDefaultSettings(scheduler="euler", steps=4, cfg_scale=1.0, width=1024, height=1024), } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="flux2"), diff --git a/invokeai/backend/architectures/defs/ideogram_4.py b/invokeai/backend/architectures/defs/ideogram_4.py index ad05b3ecc4c..20594012406 100644 --- a/invokeai/backend/architectures/defs/ideogram_4.py +++ b/invokeai/backend/architectures/defs/ideogram_4.py @@ -17,8 +17,15 @@ LatentSpaceFacet(FLUX2_32), ConditioningFacet(Ideogram4ConditioningInfo), # Ideogram 4 samples from presets (V4_QUALITY_48 by default) with a dual-branch guidance - # schedule; these are sensible UI defaults rather than the sampler's own numbers. - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=48, cfg_scale=7.0, width=1024, height=1024)}), + # schedule; the step count is a sensible UI default rather than the sampler's own number. + # + # cfg_scale is 1.0 because the model is CFG-distilled: `ideogram4_denoise` has no `cfg_scale` + # input at all, only `guidance_scale`, and the FeaturesFacet below already says so with + # `negative_prompt: never` and `guidance_label: "Guidance"`. Any other value would be a number + # the UI shows for a control the sampler does not have. + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler", steps=48, cfg_scale=1.0, width=1024, height=1024)} + ), # Text-to-image only. ModalityFacet(frozenset({"txt2img"}), metadata_slug="ideogram4"), FeaturesFacet( diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py index a69f643bf5d..0b1987da6e2 100644 --- a/invokeai/backend/architectures/defs/krea_2.py +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -19,9 +19,11 @@ { # Diffusers' Krea-2 guidance 4.5 uses cond + 4.5 * (cond - uncond), equivalent to # InvokeAI's CFG convention at 5.5. - Krea2VariantType.Base: MainModelDefaultSettings(steps=28, cfg_scale=5.5, width=1024, height=1024), + Krea2VariantType.Base: MainModelDefaultSettings( + scheduler="euler", steps=28, cfg_scale=5.5, width=1024, height=1024 + ), # Turbo (distilled). cfg_scale has a floor of 1; 1.0 means no guidance. - None: MainModelDefaultSettings(steps=8, cfg_scale=1.0, width=1024, height=1024), + None: MainModelDefaultSettings(scheduler="euler", steps=8, cfg_scale=1.0, width=1024, height=1024), } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="krea2"), diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py index 2527adc40c7..c8ff84447a4 100644 --- a/invokeai/backend/architectures/defs/qwen_image.py +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -15,7 +15,9 @@ BaseModelType.QwenImage, LatentSpaceFacet(WAN21_16), ConditioningFacet(QwenImageConditioningInfo), - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024)}), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler_a", steps=40, cfg_scale=4.0, width=1024, height=1024)} + ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="qwen_image"), FeaturesFacet( negative_prompt=NegativePrompt(visible=True, usage="cfg-gated"), diff --git a/invokeai/backend/architectures/defs/sd_1.py b/invokeai/backend/architectures/defs/sd_1.py index 96b295aecca..17298de3783 100644 --- a/invokeai/backend/architectures/defs/sd_1.py +++ b/invokeai/backend/architectures/defs/sd_1.py @@ -16,7 +16,9 @@ LatentSpaceFacet(SD15_4), UNetDownscaleFacet(max_unet_downscale=8), ConditioningFacet(BasicConditioningInfo), - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=512, height=512)}), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler_a", steps=30, cfg_scale=7.0, width=512, height=512)} + ), # SD 1.x and 2.x share the unprefixed mode strings: a bare `txt2img`. ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"})), FeaturesFacet( diff --git a/invokeai/backend/architectures/defs/sd_2.py b/invokeai/backend/architectures/defs/sd_2.py index fd3f545fc8d..d59a29cdbe3 100644 --- a/invokeai/backend/architectures/defs/sd_2.py +++ b/invokeai/backend/architectures/defs/sd_2.py @@ -18,7 +18,9 @@ # 768 is right for the v-prediction checkpoints and wrong for the 512 `-base` ones, and # nothing here distinguishes them — SD 2.x has no variant modeled and we ship no starter # model for it. 768 is the deliberate choice of the two. - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=768, height=768)}), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler_a", steps=30, cfg_scale=7.0, width=768, height=768)} + ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"})), FeaturesFacet( negative_prompt=NegativePrompt(visible=True, usage="always"), diff --git a/invokeai/backend/architectures/defs/sd_3.py b/invokeai/backend/architectures/defs/sd_3.py index bcdb4ccb819..f16ec059597 100644 --- a/invokeai/backend/architectures/defs/sd_3.py +++ b/invokeai/backend/architectures/defs/sd_3.py @@ -17,7 +17,9 @@ # stable-diffusion-3.5-medium's example: 40 steps at guidance 4.5. Medium rather than Large # (28/3.5) because there is one `sd-3` row and no variant to tell them apart, and Medium is the # smaller, more commonly run model. - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=40, cfg_scale=4.5, width=1024, height=1024)}), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler_a", steps=40, cfg_scale=4.5, width=1024, height=1024)} + ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="sd3"), FeaturesFacet( negative_prompt=NegativePrompt(visible=True, usage="always"), diff --git a/invokeai/backend/architectures/defs/sdxl.py b/invokeai/backend/architectures/defs/sdxl.py index ab6302d0189..dffe1c1f2d4 100644 --- a/invokeai/backend/architectures/defs/sdxl.py +++ b/invokeai/backend/architectures/defs/sdxl.py @@ -16,7 +16,9 @@ LatentSpaceFacet(SDXL_4), UNetDownscaleFacet(max_unet_downscale=4), ConditioningFacet(SDXLConditioningInfo), - DefaultSettingsFacet({None: MainModelDefaultSettings(steps=30, cfg_scale=7.0, width=1024, height=1024)}), + DefaultSettingsFacet( + {None: MainModelDefaultSettings(scheduler="euler_a", steps=30, cfg_scale=7.0, width=1024, height=1024)} + ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="sdxl"), FeaturesFacet( negative_prompt=NegativePrompt(visible=True, usage="always"), diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index 0a14b36d4e5..86ef2864a6e 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -19,9 +19,11 @@ ConditioningFacet(WanConditioningInfo), DefaultSettingsFacet( { - WanVariantType.TI2V_5B: MainModelDefaultSettings(steps=30, cfg_scale=5.0, width=1024, height=1024), + WanVariantType.TI2V_5B: MainModelDefaultSettings( + scheduler="euler", steps=30, cfg_scale=5.0, width=1024, height=1024 + ), # A14B, and whatever an unknown variant turns out to be. - None: MainModelDefaultSettings(steps=40, cfg_scale=4.0, width=1024, height=1024), + None: MainModelDefaultSettings(scheduler="euler", steps=40, cfg_scale=4.0, width=1024, height=1024), } ), # Wan generates images at num_frames=1 and video above that, from text or from one or two diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py index e917feed5fc..20297203365 100644 --- a/invokeai/backend/architectures/defs/z_image.py +++ b/invokeai/backend/architectures/defs/z_image.py @@ -18,9 +18,11 @@ DefaultSettingsFacet( { # The undistilled base needs more steps and supports CFG. - ZImageVariantType.ZBase: MainModelDefaultSettings(steps=50, cfg_scale=4.0, width=1024, height=1024), + ZImageVariantType.ZBase: MainModelDefaultSettings( + scheduler="euler", steps=50, cfg_scale=4.0, width=1024, height=1024 + ), # Turbo (distilled): fewer steps, no CFG. - None: MainModelDefaultSettings(steps=9, cfg_scale=1.0, width=1024, height=1024), + None: MainModelDefaultSettings(scheduler="euler", steps=9, cfg_scale=1.0, width=1024, height=1024), } ), ModalityFacet(frozenset({"txt2img", "img2img", "inpaint", "outpaint"}), metadata_slug="z_image"), diff --git a/invokeai/backend/architectures/facets/features.py b/invokeai/backend/architectures/facets/features.py index 5ebe7566314..f2180b50671 100644 --- a/invokeai/backend/architectures/facets/features.py +++ b/invokeai/backend/architectures/facets/features.py @@ -49,7 +49,15 @@ class FeaturesFacet(Facet): guidance_label: Literal["CFG", "Guidance"] = "CFG" """What to call the slider. FLUX-family models expose a distilled guidance embedding rather than - classifier-free guidance, and calling it CFG has confused users into expecting CFG behaviour.""" + classifier-free guidance, and calling it CFG has confused users into expecting CFG behaviour. + + There is only ever *one* slider. No architecture offers both knobs, so the UI shows a single + control and this label is the whole difference between them -- see `GenerateModelFields.tsx`, + where the field is labelled from here and valued from `cfgScale`. `MainModelDefaultSettings` has + both a `cfg_scale` and a `guidance` field, so a declaration must not fill in both and expect the + UI to distinguish: the value the slider takes is `guidance` where it is set and `cfg_scale` + otherwise. Today only FLUX sets both (cfg_scale 1.0 meaning "off", guidance 3.5 meaning the + distilled embedding).""" scheduler_set: SchedulerSet | None = None scheduler_applies_to_graph: bool = False diff --git a/tests/backend/architectures/test_default_settings.py b/tests/backend/architectures/test_default_settings.py index bb3bd3fdc08..68194d909bd 100644 --- a/tests/backend/architectures/test_default_settings.py +++ b/tests/backend/architectures/test_default_settings.py @@ -113,13 +113,19 @@ def test_the_researched_values_are_what_the_model_cards_say() -> None: sd-3: stable-diffusion-3.5-medium, 40 steps at guidance 4.5. Medium, not Large (28/3.5): there is one `sd-3` row and no variant to tell them apart. z-image: Tongyi-MAI/Z-Image-Turbo, `num_inference_steps=9`, guidance 0 -> cfg_scale 1.0. - ideogram: not from a card but from our own PRESETS — every preset runs main guidance 7.0. + ideogram: 1.0, because the model is CFG-distilled and cannot do CFG at all -- `ideogram4_denoise` + has no `cfg_scale` input, only `guidance_scale`, and the FeaturesFacet says + `negative_prompt: never`. This field previously held 7.0, taken from the main weight in + our own PRESETS. That number is the sampler's internal guidance schedule, not a default + anyone sets: the node reads `guidance_scale=None` as "use the preset", and webv2 sends + nothing unless the user overrides it through Ideogram's own dedicated fields. Declaring + 7.0 advertised a CFG default for a model that has no CFG. """ expected = { BaseModelType.CogView4: (50, 3.5), BaseModelType.StableDiffusion3: (40, 4.5), BaseModelType.ZImage: (9, 1.0), - BaseModelType.Ideogram4: (48, 7.0), + BaseModelType.Ideogram4: (48, 1.0), BaseModelType.ErnieImage: (50, 4.0), # The classic Stable Diffusion defaults, which every SD generation is built around. BaseModelType.StableDiffusion1: (30, 7.0), @@ -147,3 +153,36 @@ def test_the_sd_family_keeps_its_native_sizes() -> None: settings = resolve_default_settings(base) assert settings is not None, base.value assert (settings.width, settings.height) == (width, height), base.value + + +def test_every_architecture_with_a_scheduler_declares_which_one() -> None: + """The last piece webv2 still hardcodes. + + `BASE_GENERATION` in `baseGenerationPolicies.ts` carries a `defaults.scheduler` per base, and it + was the one field the capabilities endpoint could not supply -- so adding an architecture still + meant editing the frontend even when nothing about it was special. The values mirror what that + table ships, deliberately: which scheduler to prefer is a product decision, not a model-card + fact, and mirroring means nothing changes for users when webv2 switches over. + + The converse matters too. An architecture with no `scheduler_set` has no scheduler to choose -- + MiniMax H3 steps video and audio down two hardcoded flow schedules -- and declaring a default for + it would put a control in the UI that reaches nothing. + """ + from invokeai.backend.architectures import FeaturesFacet, get + + missing, spurious = [], [] + for base in generative_bases(): + features = get(base, FeaturesFacet) + settings = resolve_default_settings(base) + if features is None or settings is None: + continue + # The refiner declares a canvas but no generation settings; it is not run on its own. + if settings.steps is None: + continue + if features.scheduler_set is not None and settings.scheduler is None: + missing.append(base.value) + if features.scheduler_set is None and settings.scheduler is not None: + spurious.append(base.value) + + assert missing == [], f"scheduler_set declared but no default scheduler: {missing}" + assert spurious == [], f"default scheduler but no scheduler_set: {spurious}" diff --git a/tests/backend/architectures/test_features.py b/tests/backend/architectures/test_features.py index 38dbf2ad414..fe418d0decd 100644 --- a/tests/backend/architectures/test_features.py +++ b/tests/backend/architectures/test_features.py @@ -143,3 +143,25 @@ def test_clip_skip_is_an_sd_1_and_2_feature_only() -> None: if (f := get(b, FeaturesFacet)) is not None and f.clip_skip_max is not None } assert with_clip_skip == {"sd-1": 12, "sd-2": 24} + + +def test_an_architecture_that_cannot_do_cfg_declares_no_cfg() -> None: + """`negative_prompt: never` means CFG-distilled, and a CFG-distilled model has one honest + cfg_scale: 1.0, meaning "off". + + The two facts live in different facets, so nothing stopped them from disagreeing -- and they + did. Ideogram 4 shipped `cfg_scale=7.0` while its own FeaturesFacet said `never` and its denoise + node had no `cfg_scale` input at all, only `guidance_scale`. The UI would have offered a slider + for a control the sampler does not read. + """ + from invokeai.backend.architectures import resolve_default_settings + + contradictory = [] + for base in generative_bases(): + features = get(base, FeaturesFacet) + settings = resolve_default_settings(base) + if features is None or settings is None or settings.cfg_scale is None: + continue + if features.negative_prompt.usage == "never" and settings.cfg_scale != 1.0: + contradictory.append(f"{base.value}: cfg_scale={settings.cfg_scale} but negative prompt is 'never'") + assert contradictory == []