From 10e5b96e3774b240409733515fabfea3eb29429d Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 7 Aug 2026 09:58:28 -0400 Subject: [PATCH 1/3] feat(installer): explicit-file entries in multi-subfolder HF sources; components-only MiniMax H3 installs A slim MiniMax H3 install (tokenizer/processor/VAEs + the two config JSONs identification needs, ~11 GB) was previously inexpressible: '::a+b' subfolder sources kept only files inside the listed folders, so the root modular_model_index.json was dropped and the download landed as an unknown model. - filter_files(): an entry in a multi-entry subfolder list that names a repo file exactly is included verbatim, bypassing the extension prefilter, variant scoring and config-only-folder pruning. Single-entry sources and pure-folder lists behave exactly as before. - _multifile_download(): explicit file entries keep their repo-relative paths (relative_to() would have flattened transformer/config.json to the root) and contribute their stems to the synthesized folder name. - Main_Diffusers_MiniMaxH3_Config: new components_only field, true when the transformer folder has no weight shards, so the UI can require the single-file transformer/text-encoder selections up front. - MiniMax H3 diffusers loader: requesting the Transformer or TextEncoder submodel from a components-only install fails with an actionable message instead of a cryptic missing-shard error. - Linear UI readiness (Generate + Canvas): a components-only H3 main blocks enqueue until both single-file overrides are selected. Co-Authored-By: Claude Fable 5 --- .../model_install/model_install_default.py | 14 +- .../backend/model_manager/configs/main.py | 18 +++ .../load/model_loaders/minimax_h3.py | 20 +++ .../model_manager/util/select_hf_files.py | 22 ++- invokeai/frontend/web/openapi.json | 9 +- invokeai/frontend/web/public/locales/en.json | 2 + .../web/src/features/queue/store/readiness.ts | 24 +++ .../frontend/web/src/services/api/schema.ts | 6 + .../model_install/test_model_install.py | 30 ++++ .../load/test_minimax_h3_loader_guards.py | 65 ++++++++ .../util/test_hf_model_select.py | 149 ++++++++++++++++++ .../__test_metadata__.json | 4 +- .../__test_metadata__.json | 3 + .../audio_vae/config.json | 3 + .../audio_vae/model.safetensors | 3 + .../modular_model_index.json | 3 + .../processor/preprocessor_config.json | 3 + .../processor/video_preprocessor_config.json | 3 + .../tokenizer/tokenizer_config.json | 3 + .../transformer/config.json | 3 + .../vae/config.json | 3 + ...n_pytorch_model-00001-of-00003.safetensors | 3 + 22 files changed, 385 insertions(+), 8 deletions(-) create mode 100644 tests/backend/model_manager/load/test_minimax_h3_loader_guards.py create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/__test_metadata__.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/config.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/model.safetensors create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/modular_model_index.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/preprocessor_config.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/video_preprocessor_config.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/tokenizer/tokenizer_config.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/transformer/config.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/config.json create mode 100644 tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/diffusion_pytorch_model-00001-of-00003.safetensors diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py index 53eb6da1688..4c3e5867021 100644 --- a/invokeai/app/services/model_install/model_install_default.py +++ b/invokeai/app/services/model_install/model_install_default.py @@ -1377,9 +1377,13 @@ def _multifile_download( # subdirectory within the model folder. if subfolders and len(subfolders) > 1: - # Multiple subfolders: create combined name and keep subfolder structure + # Multiple subfolders: create combined name and keep subfolder structure. Entries may + # also be explicit files (e.g. "modular_model_index.json" or "transformer/config.json"); + # use their stems in the combined name so it stays a sane directory name. top = Path(remote_files[0].path.parts[0]) # e.g. "Z-Image-Turbo/" - subfolder_names = [sf.name.replace("/", "_").replace("\\", "_") for sf in subfolders] + subfolder_names = [ + (sf.stem if sf.suffix else sf.name).replace("/", "_").replace("\\", "_") for sf in subfolders + ] combined_name = "_".join(subfolder_names) path_to_add = Path(f"{top}_{combined_name}") @@ -1390,6 +1394,12 @@ def _multifile_download( file_path = model_file.path new_path: Optional[Path] = None for sf in subfolders: + if file_path == top / sf: + # An explicit file entry: keep its repo-relative path so e.g. + # transformer/config.json stays inside transformer/. (relative_to() below + # would return "." here and flatten the file to the model root.) + new_path = path_to_add / sf + break try: # Try to get relative path from this subfolder relative = file_path.relative_to(top / sf) diff --git a/invokeai/backend/model_manager/configs/main.py b/invokeai/backend/model_manager/configs/main.py index 83453062619..7ef1be77491 100644 --- a/invokeai/backend/model_manager/configs/main.py +++ b/invokeai/backend/model_manager/configs/main.py @@ -1487,6 +1487,12 @@ class Main_Diffusers_MiniMaxH3_Config(Diffusers_Config_Base, Main_Config_Base, C base: Literal[BaseModelType.MiniMaxH3] = Field(BaseModelType.MiniMaxH3) variant: MiniMaxH3VariantType = Field() + components_only: bool = Field( + default=False, + description="Whether the folder holds only the shared components (tokenizer, processor, VAEs) " + "without transformer weights - a slim install whose transformer and text encoder must be " + "supplied as single-file overrides at generation time.", + ) @classmethod def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) -> Self: @@ -1515,10 +1521,22 @@ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) - repo_variant = override_fields.pop("repo_variant", None) or cls._get_repo_variant_or_raise(mod) + # A slim ("components-only") install carries the transformer's config.json for variant + # identification but no weight shards - the transformer and text encoder come from + # single-file installs selected in the model loader instead. Record that here so the UI + # can require those selections up front rather than failing mid-generation. + components_only = override_fields.pop("components_only", None) + if components_only is None: + transformer_dir = mod.path / "transformer" + components_only = not any( + any(transformer_dir.glob(pattern)) for pattern in ("*.safetensors", "*.bin", "*.pt", "*.ckpt") + ) + return cls( **override_fields, variant=variant, repo_variant=repo_variant, + components_only=components_only, ) @classmethod diff --git a/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py b/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py index 273c7d2bf41..5ce263b8262 100644 --- a/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py +++ b/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py @@ -45,6 +45,22 @@ from invokeai.backend.util.devices import TorchDevice +def _raise_if_no_weight_shards(submodel_path: Path, submodel_label: str) -> None: + """Fail with an actionable message when a components-only (slim) install is asked for a + submodel whose weights it does not carry. + + A slim MiniMax H3 install ships the shared components (tokenizer, processor, VAEs) plus bare + config JSONs, so a generic from_pretrained() here would otherwise die on a cryptic + missing-shard error deep inside diffusers/transformers. + """ + if not any(any(submodel_path.glob(pattern)) for pattern in ("*.safetensors", "*.bin", "*.pt", "*.ckpt")): + raise ValueError( + f"This MiniMax H3 model folder has no {submodel_label} weights - it is a components-only " + f"(slim) install. In the MiniMax H3 Model Loader, select a single-file {submodel_label} " + f"(for example the Comfy-Org int8 release) to use with it." + ) + + @ModelLoaderRegistry.register(base=BaseModelType.MiniMaxH3, type=ModelType.Main, format=ModelFormat.Diffusers) class MiniMaxH3DiffusersModel(ModelLoader): """Loader for MiniMax H3 diffusers-format models (FL2VA).""" @@ -67,12 +83,16 @@ def _load_model( match submodel_type: case SubModelType.Transformer: + _raise_if_no_weight_shards(submodel_path, "transformer") + from invokeai.backend.minimax_h3 import MiniMaxH3Transformer3DModel return MiniMaxH3Transformer3DModel.from_pretrained( submodel_path, torch_dtype=dtype, local_files_only=True ) case SubModelType.TextEncoder: + _raise_if_no_weight_shards(submodel_path, "text encoder") + from transformers import AutoConfig, Qwen3VLForConditionalGeneration te_config = normalize_qwen3vl_rope_config( diff --git a/invokeai/backend/model_manager/util/select_hf_files.py b/invokeai/backend/model_manager/util/select_hf_files.py index a8428f4edcd..d4323cf486a 100644 --- a/invokeai/backend/model_manager/util/select_hf_files.py +++ b/invokeai/backend/model_manager/util/select_hf_files.py @@ -32,6 +32,10 @@ def filter_files( :param files: List of files relative to the repo root. :param subfolder: Filter by the indicated subfolder (deprecated, use subfolders instead). :param subfolders: Filter by multiple subfolders. Files from any of these subfolders will be included. + An entry that names a repo file exactly (e.g. "modular_model_index.json" or + "transformer/config.json") is an explicit file request: it is included verbatim, bypassing + the extension prefilter, variant scoring and config-only-folder pruning that apply to + subfolder contents. :param variant: Filter by files belonging to a particular variant, such as fp16. The file list can be obtained from the `files` field of HuggingFaceMetadata, @@ -58,6 +62,17 @@ def filter_files( if sf.suffix in [".safetensors", ".bin", ".onnx", ".xml", ".pth", ".pt", ".ckpt", ".msgpack"]: return [root / sf] + # In a multi-entry list, entries that name a repo file exactly are explicit requests for that + # file. Split them out so they skip every filter below - a slim install can then combine + # component subfolders with the root pipeline index and a bare config.json (whose folder the + # config-only pruning at the end would otherwise drop). + subfolder_filter_requested = bool(filter_subfolders) + explicit_files: List[Path] = [] + if len(filter_subfolders) > 1: + file_set = set(files) + explicit_files = [sf for sf in filter_subfolders if (root / sf) in file_set] + filter_subfolders = [sf for sf in filter_subfolders if sf not in explicit_files] + # Start by filtering on model file extensions, discarding images, docs, etc for file in files: if file.name.endswith((".json", ".txt", ".jinja")): # .jinja for chat templates @@ -81,13 +96,14 @@ def filter_files( elif re.search(r"model.*\.(safetensors|bin|onnx|xml|pth|pt|ckpt|msgpack)$", file.name): paths.append(file) - # limit search to subfolder(s) if requested - if filter_subfolders: + # limit search to subfolder(s) if requested. When every entry was an explicit file, this + # correctly reduces the subfolder-derived selection to nothing rather than the whole repo. + if subfolder_filter_requested: absolute_subfolders = [root / sf for sf in filter_subfolders] paths = [x for x in paths if any(Path(sf) in x.parents for sf in absolute_subfolders)] # _filter_by_variant uniquifies the paths and returns a set - return sorted(_filter_by_variant(paths, variant)) + return sorted(_filter_by_variant(paths, variant) | {root / sf for sf in explicit_files}) @dataclass diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 16fc1725ec6..6b3edecc750 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -59080,6 +59080,12 @@ }, "variant": { "$ref": "#/components/schemas/MiniMaxH3VariantType" + }, + "components_only": { + "type": "boolean", + "title": "Components Only", + "description": "Whether the folder holds only the shared components (tokenizer, processor, VAEs) without transformer weights - a slim install whose transformer and text encoder must be supplied as single-file overrides at generation time.", + "default": false } }, "type": "object", @@ -59101,7 +59107,8 @@ "format", "repo_variant", "base", - "variant" + "variant", + "components_only" ], "title": "Main_Diffusers_MiniMaxH3_Config", "description": "Model config for MiniMax H3 (Hailuo 3.0) diffusers-format models." diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index dd977dc5d4e..6d4b4acbcba 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1788,6 +1788,8 @@ "noQwenImageComponentSourceSelected": "GGUF Qwen Image models require a Diffusers Component Source for VAE/encoder", "noWanComponentSourceSelected": "GGUF Wan 2.2 models require a Diffusers Component Source for VAE/encoder", "minimaxH3VideoOnGenerateTab": "MiniMax H3 video generation runs on the Generate tab (switch Output to Image to use MiniMax H3 on canvas)", + "noMiniMaxH3TransformerModelSelected": "Components-only MiniMax H3 install: select a single-file Transformer in Advanced settings", + "noMiniMaxH3TextEncoderModelSelected": "Components-only MiniMax H3 install: select a single-file Text Encoder in Advanced settings", "noZImageVaeSourceSelected": "No VAE source: Select VAE (FLUX) or Qwen3 Source model", "noZImageQwen3EncoderSourceSelected": "No Qwen3 Encoder source: Select Qwen3 Encoder or Qwen3 Source model", "noKrea2VaeModelSelected": "Non-diffusers Krea-2: select a VAE in Advanced settings", diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index fd8a74e2c79..6d12b367ed4 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -431,6 +431,18 @@ export const getReasonsWhyCannotEnqueueGenerateTab = (arg: { } } + if (model?.base === 'minimax-h3' && model.format === 'diffusers' && model.components_only) { + // A slim ("components-only") install ships only the shared components (tokenizer, processor, + // VAEs) - the transformer and text encoder must come from single-file installs selected in + // Advanced settings, or generation fails at model-load time. + if (!params.minimaxH3TransformerModel) { + reasons.push({ content: i18n.t('parameters.invoke.noMiniMaxH3TransformerModelSelected') }); + } + if (!params.minimaxH3TextEncoderModel) { + reasons.push({ content: i18n.t('parameters.invoke.noMiniMaxH3TextEncoderModelSelected') }); + } + } + if (model) { for (const lora of loras.filter(({ isEnabled }) => isEnabled === true)) { if (model.base !== lora.model.base) { @@ -1175,6 +1187,18 @@ export const getReasonsWhyCannotEnqueueCanvasTab = (arg: { reasons.push({ content: i18n.t('parameters.invoke.minimaxH3VideoOnGenerateTab') }); } + if (model?.base === 'minimax-h3' && model.format === 'diffusers' && model.components_only) { + // A slim ("components-only") install ships only the shared components (tokenizer, processor, + // VAEs) - the transformer and text encoder must come from single-file installs selected in + // Advanced settings, or generation fails at model-load time. + if (!params.minimaxH3TransformerModel) { + reasons.push({ content: i18n.t('parameters.invoke.noMiniMaxH3TransformerModelSelected') }); + } + if (!params.minimaxH3TextEncoderModel) { + reasons.push({ content: i18n.t('parameters.invoke.noMiniMaxH3TextEncoderModelSelected') }); + } + } + if (model) { for (const lora of loras.filter(({ isEnabled }) => isEnabled === true)) { if (model.base !== lora.model.base) { diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 7f882e8dae4..a7999952b67 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -24519,6 +24519,12 @@ export type components = { */ base: "minimax-h3"; variant: components["schemas"]["MiniMaxH3VariantType"]; + /** + * Components Only + * @description Whether the folder holds only the shared components (tokenizer, processor, VAEs) without transformer weights - a slim install whose transformer and text encoder must be supplied as single-file overrides at generation time. + * @default false + */ + components_only: boolean; }; /** * Main_Diffusers_QwenImage_Config diff --git a/tests/app/services/model_install/test_model_install.py b/tests/app/services/model_install/test_model_install.py index 8d94205237e..62d9648d1e9 100644 --- a/tests/app/services/model_install/test_model_install.py +++ b/tests/app/services/model_install/test_model_install.py @@ -1027,3 +1027,33 @@ def test_heuristic_import_with_type(mm2_installer: ModelInstallServiceBase, mode mm2_installer.wait_for_job(install_job2, timeout=10) assert install_job2.complete assert install_job2.config_out if model_params["type"] == "embedding" else not install_job2.config_out + + +def test_multifile_download_layout_with_explicit_files(mm2_installer: ModelInstallServiceBase, tmp_path: Path) -> None: + """Explicit file entries in a multi-subfolder source keep their repo-relative paths: the root + pipeline index lands at the model root and transformer/config.json stays inside transformer/ + (naive relative_to() matching would flatten it to the root), while plain subfolder entries keep + the pre-existing one-directory-per-subfolder layout.""" + from invokeai.backend.model_manager.metadata.metadata_base import RemoteModelFile + + remote_files = [ + RemoteModelFile(url="https://example.com/root_index", path=Path("MiniMax-H3/modular_model_index.json")), + RemoteModelFile(url="https://example.com/transformer_config", path=Path("MiniMax-H3/transformer/config.json")), + RemoteModelFile(url="https://example.com/vae_config", path=Path("MiniMax-H3/vae/config.json")), + RemoteModelFile( + url="https://example.com/vae_weights", path=Path("MiniMax-H3/vae/diffusion_pytorch_model.safetensors") + ), + ] + job = mm2_installer._multifile_download( # pyright: ignore[reportAttributeAccessIssue] + remote_files=remote_files, + dest=tmp_path, + subfolders=[Path("modular_model_index.json"), Path("transformer/config.json"), Path("vae")], + submit_job=False, + ) + top = Path("MiniMax-H3_modular_model_index_config_vae") + assert {part.dest.relative_to(tmp_path.resolve()) for part in job.download_parts} == { + top / "modular_model_index.json", + top / "transformer" / "config.json", + top / "vae" / "config.json", + top / "vae" / "diffusion_pytorch_model.safetensors", + } diff --git a/tests/backend/model_manager/load/test_minimax_h3_loader_guards.py b/tests/backend/model_manager/load/test_minimax_h3_loader_guards.py new file mode 100644 index 00000000000..ec5ccd2f3e3 --- /dev/null +++ b/tests/backend/model_manager/load/test_minimax_h3_loader_guards.py @@ -0,0 +1,65 @@ +"""Boundary tests for the MiniMax H3 diffusers loader's components-only guards. + +A slim ("components-only") install carries tokenizer/processor/VAEs plus bare config JSONs, but no +transformer or text-encoder weights - those come from single-file installs selected in the model +loader node. Requesting the missing submodels must fail with an actionable message instead of a +cryptic missing-shard error from inside diffusers/transformers. +""" + +from pathlib import Path + +import pytest +import torch + +from invokeai.backend.model_manager.configs.main import Main_Diffusers_MiniMaxH3_Config +from invokeai.backend.model_manager.load.model_loaders.minimax_h3 import MiniMaxH3DiffusersModel +from invokeai.backend.model_manager.taxonomy import SubModelType + + +@pytest.fixture +def slim_model_dir(tmp_path: Path) -> Path: + (tmp_path / "transformer").mkdir() + (tmp_path / "transformer" / "config.json").write_text("{}") + # No text_encoder directory at all - the slim source does not download one. + return tmp_path + + +@pytest.fixture +def loader(monkeypatch) -> MiniMaxH3DiffusersModel: + monkeypatch.setattr( + "invokeai.backend.model_manager.load.model_loaders.minimax_h3.TorchDevice.choose_torch_device", + lambda: torch.device("cpu"), + ) + monkeypatch.setattr( + "invokeai.backend.model_manager.load.model_loaders.minimax_h3.TorchDevice.choose_bfloat16_safe_dtype", + lambda _device: torch.float32, + ) + return object.__new__(MiniMaxH3DiffusersModel) + + +@pytest.mark.parametrize("submodel_type", [SubModelType.Transformer, SubModelType.TextEncoder]) +def test_components_only_install_raises_actionable_error( + loader: MiniMaxH3DiffusersModel, slim_model_dir: Path, submodel_type: SubModelType +) -> None: + config = Main_Diffusers_MiniMaxH3_Config.model_construct(path=str(slim_model_dir), components_only=True) + + with pytest.raises(ValueError, match="components-only"): + loader._load_model(config, submodel_type) + + +def test_transformer_with_weight_shards_passes_guard( + loader: MiniMaxH3DiffusersModel, slim_model_dir: Path, monkeypatch +) -> None: + (slim_model_dir / "transformer" / "diffusion_pytorch_model-00001-of-00002.safetensors").write_bytes(b"") + + import invokeai.backend.minimax_h3 as minimax_h3_module + + sentinel = object() + monkeypatch.setattr( + minimax_h3_module.MiniMaxH3Transformer3DModel, + "from_pretrained", + classmethod(lambda _cls, *_args, **_kwargs: sentinel), + ) + + config = Main_Diffusers_MiniMaxH3_Config.model_construct(path=str(slim_model_dir)) + assert loader._load_model(config, SubModelType.Transformer) is sentinel diff --git a/tests/backend/model_manager/util/test_hf_model_select.py b/tests/backend/model_manager/util/test_hf_model_select.py index 5530fa0c90e..65ad8d42c57 100644 --- a/tests/backend/model_manager/util/test_hf_model_select.py +++ b/tests/backend/model_manager/util/test_hf_model_select.py @@ -403,3 +403,152 @@ def test_select_flux_schnell_files( ) -> None: filtered_files = filter_files(flux_schnell_test_files, variant) assert set(filtered_files) == {Path(f) for f in expected_files} + + +# A subset of huggingface.co/MiniMaxAI/MiniMax-H3: a root-level Modular Diffusers pipeline whose +# repo also carries a sibling task transformer (transformer_ref) that a slim install must be able +# to skip while still fetching the root pipeline index and the transformer's bare config.json. +@pytest.fixture +def minimax_h3_test_files() -> list[Path]: + return [ + Path(f) + for f in [ + "MiniMax-H3/.gitattributes", + "MiniMax-H3/LICENSE", + "MiniMax-H3/README.md", + "MiniMax-H3/model_index.json", + "MiniMax-H3/modular_model_index.json", + "MiniMax-H3/audio_scheduler/scheduler_config.json", + "MiniMax-H3/audio_vae/config.json", + "MiniMax-H3/audio_vae/diffusion_pytorch_model.safetensors", + "MiniMax-H3/processor/chat_template.json", + "MiniMax-H3/processor/preprocessor_config.json", + "MiniMax-H3/processor/tokenizer.json", + "MiniMax-H3/scheduler/scheduler_config.json", + "MiniMax-H3/text_encoder/config.json", + "MiniMax-H3/text_encoder/model-00001-of-00002.safetensors", + "MiniMax-H3/text_encoder/model-00002-of-00002.safetensors", + "MiniMax-H3/text_encoder/model.safetensors.index.json", + "MiniMax-H3/tokenizer/merges.txt", + "MiniMax-H3/tokenizer/tokenizer_config.json", + "MiniMax-H3/tokenizer/vocab.json", + "MiniMax-H3/transformer/config.json", + "MiniMax-H3/transformer/diffusion_pytorch_model-00001-of-00002.safetensors", + "MiniMax-H3/transformer/diffusion_pytorch_model-00002-of-00002.safetensors", + "MiniMax-H3/transformer/diffusion_pytorch_model.safetensors.index.json", + "MiniMax-H3/transformer_ref/config.json", + "MiniMax-H3/transformer_ref/diffusion_pytorch_model-00001-of-00002.safetensors", + "MiniMax-H3/transformer_ref/diffusion_pytorch_model-00002-of-00002.safetensors", + "MiniMax-H3/vae/config.json", + "MiniMax-H3/vae/diffusion_pytorch_model-00001-of-00003.safetensors", + "MiniMax-H3/vae/diffusion_pytorch_model-00002-of-00003.safetensors", + "MiniMax-H3/vae/diffusion_pytorch_model-00003-of-00003.safetensors", + "MiniMax-H3/vae/diffusion_pytorch_model.safetensors.index.json", + ] + ] + + +def test_select_subfolders_with_explicit_files(minimax_h3_test_files: list[Path]) -> None: + """Explicit file entries ride alongside subfolder entries: the root pipeline index and the + transformer's bare config.json are included verbatim (the latter would otherwise be dropped by + the config-only-folder pruning), while the transformer weights stay excluded.""" + filtered_files = filter_files( + minimax_h3_test_files, + subfolders=[ + Path("modular_model_index.json"), + Path("transformer/config.json"), + Path("tokenizer"), + Path("processor"), + Path("vae"), + Path("audio_vae"), + ], + ) + assert set(filtered_files) == { + Path(f) + for f in [ + "MiniMax-H3/modular_model_index.json", + "MiniMax-H3/transformer/config.json", + "MiniMax-H3/tokenizer/merges.txt", + "MiniMax-H3/tokenizer/tokenizer_config.json", + "MiniMax-H3/tokenizer/vocab.json", + "MiniMax-H3/processor/chat_template.json", + "MiniMax-H3/processor/preprocessor_config.json", + "MiniMax-H3/processor/tokenizer.json", + "MiniMax-H3/vae/config.json", + "MiniMax-H3/vae/diffusion_pytorch_model-00001-of-00003.safetensors", + "MiniMax-H3/vae/diffusion_pytorch_model-00002-of-00003.safetensors", + "MiniMax-H3/vae/diffusion_pytorch_model-00003-of-00003.safetensors", + "MiniMax-H3/vae/diffusion_pytorch_model.safetensors.index.json", + "MiniMax-H3/audio_vae/config.json", + "MiniMax-H3/audio_vae/diffusion_pytorch_model.safetensors", + ] + } + + +def test_select_explicit_files_only(minimax_h3_test_files: list[Path]) -> None: + """A list made up solely of explicit files selects exactly those files - not the whole repo.""" + filtered_files = filter_files( + minimax_h3_test_files, + subfolders=[Path("modular_model_index.json"), Path("transformer/config.json")], + ) + assert set(filtered_files) == { + Path("MiniMax-H3/modular_model_index.json"), + Path("MiniMax-H3/transformer/config.json"), + } + + +def test_select_explicit_weights_file_bypasses_name_prefilter() -> None: + """An explicit weights-file entry is included even when its name would fail the 'model' + naming-convention prefilter that subfolder contents are subject to.""" + files = [ + Path(f) + for f in [ + "Repo/README.md", + "Repo/text_encoders/foo_int8_convrot.safetensors", + "Repo/vae/config.json", + "Repo/vae/diffusion_pytorch_model.safetensors", + ] + ] + filtered_files = filter_files( + files, + subfolders=[Path("text_encoders/foo_int8_convrot.safetensors"), Path("vae")], + ) + assert set(filtered_files) == { + Path("Repo/text_encoders/foo_int8_convrot.safetensors"), + Path("Repo/vae/config.json"), + Path("Repo/vae/diffusion_pytorch_model.safetensors"), + } + + +def test_select_nonexistent_entry_selects_nothing(minimax_h3_test_files: list[Path]) -> None: + """An entry matching neither a file nor a folder contributes nothing (and does not disable + the subfolder filtering for the remaining entries).""" + filtered_files = filter_files( + minimax_h3_test_files, + subfolders=[Path("no_such_entry.json"), Path("audio_vae")], + ) + assert set(filtered_files) == { + Path("MiniMax-H3/audio_vae/config.json"), + Path("MiniMax-H3/audio_vae/diffusion_pytorch_model.safetensors"), + } + + +def test_select_multiple_plain_subfolders_unchanged(minimax_h3_test_files: list[Path]) -> None: + """Regression: a pure-folder multi-subfolder list (the pre-existing '+' syntax) behaves as + before - explicit-file handling must not alter it.""" + filtered_files = filter_files( + minimax_h3_test_files, + subfolders=[Path("text_encoder"), Path("tokenizer")], + ) + assert set(filtered_files) == { + Path(f) + for f in [ + "MiniMax-H3/text_encoder/config.json", + "MiniMax-H3/text_encoder/model-00001-of-00002.safetensors", + "MiniMax-H3/text_encoder/model-00002-of-00002.safetensors", + "MiniMax-H3/text_encoder/model.safetensors.index.json", + "MiniMax-H3/tokenizer/merges.txt", + "MiniMax-H3/tokenizer/tokenizer_config.json", + "MiniMax-H3/tokenizer/vocab.json", + ] + } diff --git a/tests/model_identification/stripped_models/6f4fe53b-31e2-45dd-83da-2753fa1b4c08/__test_metadata__.json b/tests/model_identification/stripped_models/6f4fe53b-31e2-45dd-83da-2753fa1b4c08/__test_metadata__.json index b2fd9454b76..ab6bbe5ad2a 100644 --- a/tests/model_identification/stripped_models/6f4fe53b-31e2-45dd-83da-2753fa1b4c08/__test_metadata__.json +++ b/tests/model_identification/stripped_models/6f4fe53b-31e2-45dd-83da-2753fa1b4c08/__test_metadata__.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b2b02183348847b4b2def482b47bae45ebe047748646b1dcce38073ba5c18acd -size 168 +oid sha256:538453d68843ac126b37da27ffbbbc332ef1d1364af8fb7f2de525dc4cc0d8b9 +size 199 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/__test_metadata__.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/__test_metadata__.json new file mode 100644 index 00000000000..b9d7e02aec2 --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/__test_metadata__.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b48d92dca1dafc9616d0a2465c7c7866f76db3a8bfa8496bee1ef3a02646d376 +size 394 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/config.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/config.json new file mode 100644 index 00000000000..83b0d085b96 --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/config.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9a3c645ff892b376c6f5f4c8685964cd75474731af594ff058492a0000caabb6 +size 2271 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/model.safetensors b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/model.safetensors new file mode 100644 index 00000000000..237b961654c --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/audio_vae/model.safetensors @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb2e2a2f7686fd2e45fa37dd632d66cdf9f4274d888b842fb6fa7ee01776a819 +size 95 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/modular_model_index.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/modular_model_index.json new file mode 100644 index 00000000000..20c48eddc34 --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/modular_model_index.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2b6a210e482ffb78e613b553f570c44e101afce6741bd4ed91429d0559af031 +size 2935 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/preprocessor_config.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/preprocessor_config.json new file mode 100644 index 00000000000..e7a1091ec59 --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/preprocessor_config.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516 +size 390 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/video_preprocessor_config.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/video_preprocessor_config.json new file mode 100644 index 00000000000..32579be08bc --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/processor/video_preprocessor_config.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13 +size 385 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/tokenizer/tokenizer_config.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/tokenizer/tokenizer_config.json new file mode 100644 index 00000000000..98cd9c27d57 --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/tokenizer/tokenizer_config.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a07e942ac874baa13758de8d1fbdb186683cc03416b5589e1b6671c6b3057c68 +size 11003 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/transformer/config.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/transformer/config.json new file mode 100644 index 00000000000..d2de6f23b60 --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/transformer/config.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74c11bff524336576096993cbfcdcdc2ef4fa2fa4409df693bdcbc6c666282ae +size 546 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/config.json b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/config.json new file mode 100644 index 00000000000..aa311976f36 --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/config.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78f67deec3d63aae807f2bfe7154bc1e26f6372cb20b63265fcbae1b62bb5745 +size 2011 diff --git a/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/diffusion_pytorch_model-00001-of-00003.safetensors b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/diffusion_pytorch_model-00001-of-00003.safetensors new file mode 100644 index 00000000000..0df1855547d --- /dev/null +++ b/tests/model_identification/stripped_models/e8530bdb-7d05-433f-81ed-e7a2853ad26f/vae/diffusion_pytorch_model-00001-of-00003.safetensors @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc8edc91c5380231b7b3944c9cfb644a4d50bad2610e6c4518d7aefcbe6be05d +size 102 From 5d5895dfcc394d9531bb2ab51c4ab6f159f828e3 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 7 Aug 2026 09:58:41 -0400 Subject: [PATCH 2/3] feat(models): MiniMax H3 starter bundle (slim components + int8 single files, ~59 GB) Replaces the TODO left when no viable download was expressible. Three entries plus a bundle: the slim components folder from MiniMaxAI/MiniMax-H3 (~11 GB), and Comfy-Org's AdaLN-pruned int8 transformer (~21 GB) and truncated int8 Qwen3-VL text encoder (~27 GB), the latter two selected in the MiniMax H3 Model Loader. The transformer entry depends on the other two, so a one-click install yields a working video+audio setup at ~59 GB instead of the ~498 GB naive repo pull. License note: kept in its own commit so a release can drop it - the MiniMax H3 Community License restricts use by territory (excludes the US, EU, UK and South Korea, extending to outputs) and requires prominent 'MiniMax H3' attribution (kept verbatim in every name/description). Co-Authored-By: Claude Fable 5 --- .../backend/model_manager/starter_models.py | 79 ++++++++++++++----- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py index 9d8bd97989c..228134077ad 100644 --- a/invokeai/backend/model_manager/starter_models.py +++ b/invokeai/backend/model_manager/starter_models.py @@ -1740,25 +1740,53 @@ def _gemini_3_resolution_presets( # endregion # region MiniMax H3 (local) -# TODO(minimax-h3): no starter entry yet — the installer cannot express a viable download. -# MiniMax H3 FL2VA lives in huggingface.co/MiniMaxAI/MiniMax-H3 as a root-level Modular -# Diffusers layout whose probe requires the root `modular_model_index.json`, but the repo also -# carries the Ref2VA transformer (`transformer_ref/`) and the original remote-code checkpoints -# (`FL2VA/`, `Ref2VA/`) as siblings: -# - a bare `MiniMaxAI/MiniMax-H3` source downloads ~498 GB (every subtree matches -# `filter_files`' weight patterns) for a ~42.5 GB working set; -# - a `::transformer+text_encoder+tokenizer+processor+vae+audio_vae` subfolder source skips -# the root `modular_model_index.json` (`filter_files` keeps only files INSIDE the listed -# subfolders), so identification fails and the install lands as an unknown model. -# Unblocking this needs either (a) `filter_files` learning to include root config JSONs -# alongside a subfolder set — an installer-wide behavior change that would also alter what -# existing `::subfolder` sources (e.g. the Wan T5 encoder) download — or (b) an upstream -# FL2VA-only diffusers repo. Until then, users install by pointing the Model Manager at a -# locally assembled root-layout folder (see the "MiniMax H3" default workflows' notes). -# Any future entry must keep "MiniMax H3" verbatim in its name/description (the MiniMax H3 -# Community License requires prominent attribution) and should note the license's territory -# restrictions (excludes the US, EU, UK and South Korea, extending to outputs), which is why -# this region is isolated in its own droppable commit. +# 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], +) # endregion alibabacloud_wan26_t2i = StarterModel( @@ -2261,6 +2289,9 @@ def _gemini_3_resolution_presets( 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, gemini_flash_image, gemini_pro_image_preview, gemini_3_1_flash_image_preview, @@ -2432,6 +2463,15 @@ def _gemini_3_resolution_presets( 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), @@ -2444,6 +2484,7 @@ def _gemini_3_resolution_presets( 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), } From 893fe6b53bbbea5c8889cd50a8197b72169fb3ce Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Fri, 7 Aug 2026 10:25:34 -0400 Subject: [PATCH 3/3] fix(ui): adversarial-review findings for components-only H3 installs - The launchpad's InitialStateMainModelPicker offered H3 single-file transformers as primary mains (the generation accordion already filtered them); selecting one enqueued and failed at load time. The filter now lives in a shared isSecondarySlotMainModelConfig() used by both pickers (also covers low-noise Wan GGUFs). - Stale single-file override selections defeated the new readiness gate: a modelsLoaded handler now clears minimaxH3TransformerModel / minimaxH3TextEncoderModel when the referenced model is uninstalled (mirroring the Krea-2 component sync). - Weight-glob tuples in the probe and loader guard gained *.pth so a folder holding only unloadable-but-real weights isn't mislabeled components-only. Co-Authored-By: Claude Fable 5 --- .../backend/model_manager/configs/main.py | 2 +- .../load/model_loaders/minimax_h3.py | 2 +- .../listeners/modelsLoaded.ts | 16 +++++++++++ .../MainModel/mainModelPickerUtils.ts | 19 +++++++++++++ .../MainModelPicker.tsx | 27 ++++--------------- .../layouts/InitialStateMainModelPicker.tsx | 11 ++++++-- 6 files changed, 51 insertions(+), 26 deletions(-) diff --git a/invokeai/backend/model_manager/configs/main.py b/invokeai/backend/model_manager/configs/main.py index 7ef1be77491..825a8ef7edc 100644 --- a/invokeai/backend/model_manager/configs/main.py +++ b/invokeai/backend/model_manager/configs/main.py @@ -1529,7 +1529,7 @@ def from_model_on_disk(cls, mod: ModelOnDisk, override_fields: dict[str, Any]) - if components_only is None: transformer_dir = mod.path / "transformer" components_only = not any( - any(transformer_dir.glob(pattern)) for pattern in ("*.safetensors", "*.bin", "*.pt", "*.ckpt") + any(transformer_dir.glob(pattern)) for pattern in ("*.safetensors", "*.bin", "*.pth", "*.pt", "*.ckpt") ) return cls( diff --git a/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py b/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py index 5ce263b8262..da38598519b 100644 --- a/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py +++ b/invokeai/backend/model_manager/load/model_loaders/minimax_h3.py @@ -53,7 +53,7 @@ def _raise_if_no_weight_shards(submodel_path: Path, submodel_label: str) -> None config JSONs, so a generic from_pretrained() here would otherwise die on a cryptic missing-shard error deep inside diffusers/transformers. """ - if not any(any(submodel_path.glob(pattern)) for pattern in ("*.safetensors", "*.bin", "*.pt", "*.ckpt")): + if not any(any(submodel_path.glob(pattern)) for pattern in ("*.safetensors", "*.bin", "*.pth", "*.pt", "*.ckpt")): raise ValueError( f"This MiniMax H3 model folder has no {submodel_label} weights - it is a components-only " f"(slim) install. In the MiniMax H3 Model Loader, select a single-file {submodel_label} " diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts index f92317a150a..058097a225a 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -7,6 +7,8 @@ import { fluxVAESelected, krea2Qwen3VlEncoderModelSelected, krea2VaeModelSelected, + minimaxH3TextEncoderModelSelected, + minimaxH3TransformerModelSelected, modelChanged, refinerModelChanged, t5EncoderModelSelected, @@ -84,6 +86,7 @@ export const addModelsLoadedListener = (startAppListening: AppStartListening) => handleMainModels(models, state, dispatch, log); handleKrea2Components(models, state, dispatch, log); + handleMiniMaxH3Overrides(models, state, dispatch, log); handleRefinerModels(models, state, dispatch, log); handleVAEModels(models, state, dispatch, log); handleLoRAModels(models, state, dispatch, log); @@ -125,6 +128,19 @@ export const handleKrea2Components: ModelHandler = (models, state, dispatch) => } }; +export const handleMiniMaxH3Overrides: ModelHandler = (models, state, dispatch) => { + // The MiniMax H3 single-file transformer / text-encoder overrides are optional (null = use the + // main folder's submodels), so never auto-select - but a selection whose model was uninstalled + // must be cleared, or it passes the components-only readiness gate and fails at invoke time. + const { minimaxH3TransformerModel, minimaxH3TextEncoderModel } = state.params; + if (minimaxH3TransformerModel && !models.some((m) => m.key === minimaxH3TransformerModel.key)) { + dispatch(minimaxH3TransformerModelSelected(null)); + } + if (minimaxH3TextEncoderModel && !models.some((m) => m.key === minimaxH3TextEncoderModel.key)) { + dispatch(minimaxH3TextEncoderModelSelected(null)); + } +}; + type ModelHandler = ( models: AnyModelConfig[], state: RootState, diff --git a/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts b/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts index 3c1d83f4bcd..735dd6e65c3 100644 --- a/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts +++ b/invokeai/frontend/web/src/features/parameters/components/MainModel/mainModelPickerUtils.ts @@ -12,3 +12,22 @@ export const isExternalModelUnsupportedForTab = (model: AnyModelConfigWithExtern return false; }; + +/** + * Some type=main configs are not selectable primary models: they fill a secondary slot in a + * family's advanced section, and selecting them as the main model always fails at load time. + * Every picker that offers main models must filter with this predicate. + */ +export const isSecondarySlotMainModelConfig = (c: AnyModelConfigWithExternal): boolean => { + // Low-noise Wan GGUFs belong in the Transformer (Low Noise) slot of the Wan advanced section, + // not as a primary main - filter them out so users can't accidentally wire them backwards. + if (c.type === 'main' && c.base === 'wan' && c.format === 'gguf_quantized' && 'expert' in c && c.expert === 'low') { + return true; + } + // MiniMax H3 single-file transformers belong in the Transformer (single file) slot of the + // MiniMax H3 advanced section - they carry no text encoder or VAEs. + if (c.type === 'main' && c.base === 'minimax-h3' && c.format === 'checkpoint') { + return true; + } + return false; +}; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx index 57d7ded9836..7baafd98519 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/GenerationSettingsAccordion/MainModelPicker.tsx @@ -1,7 +1,10 @@ import { Flex, FormLabel, Icon } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; -import { isExternalModelUnsupportedForTab } from 'features/parameters/components/MainModel/mainModelPickerUtils'; +import { + isExternalModelUnsupportedForTab, + isSecondarySlotMainModelConfig, +} from 'features/parameters/components/MainModel/mainModelPickerUtils'; import { UseDefaultSettingsButton } from 'features/parameters/components/MainModel/UseDefaultSettingsButton'; import { ModelPicker } from 'features/parameters/components/ModelPicker'; import { modelSelected } from 'features/parameters/store/actions'; @@ -18,28 +21,8 @@ export const MainModelPicker = memo(() => { const dispatch = useAppDispatch(); const activeTab = useAppSelector(selectActiveTab); const [allModelConfigs] = useMainModels(); - // Low-noise Wan GGUFs belong in the Transformer (Low Noise) slot of the - // Wan advanced section, not as a primary main. Filter them out of the main - // model dropdown so users can't accidentally wire them backwards. const modelConfigs = useMemo( - () => - allModelConfigs.filter((c) => { - if ( - c.type === 'main' && - c.base === 'wan' && - c.format === 'gguf_quantized' && - 'expert' in c && - c.expert === 'low' - ) { - return false; - } - // MiniMax H3 single-file transformers belong in the Transformer (single file) slot of - // the MiniMax H3 advanced section - they carry no text encoder or VAEs. - if (c.type === 'main' && c.base === 'minimax-h3' && c.format === 'checkpoint') { - return false; - } - return true; - }), + () => allModelConfigs.filter((c) => !isSecondarySlotMainModelConfig(c)), [allModelConfigs] ); const selectedModelConfig = useSelectedModelConfig(); diff --git a/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx b/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx index 5c5a304a303..e995e0ecd2d 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/InitialStateMainModelPicker.tsx @@ -1,7 +1,10 @@ import { Flex, FormControl, FormLabel, Icon } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; -import { isExternalModelUnsupportedForTab } from 'features/parameters/components/MainModel/mainModelPickerUtils'; +import { + isExternalModelUnsupportedForTab, + isSecondarySlotMainModelConfig, +} from 'features/parameters/components/MainModel/mainModelPickerUtils'; import { ModelPicker } from 'features/parameters/components/ModelPicker'; import { modelSelected } from 'features/parameters/store/actions'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; @@ -16,7 +19,11 @@ export const InitialStateMainModelPicker = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const activeTab = useAppSelector(selectActiveTab); - const [modelConfigs] = useMainModels(); + const [allModelConfigs] = useMainModels(); + const modelConfigs = useMemo( + () => allModelConfigs.filter((c) => !isSecondarySlotMainModelConfig(c)), + [allModelConfigs] + ); const selectedModelConfig = useSelectedModelConfig(); const onChange = useCallback( (modelConfig: AnyModelConfigWithExternal) => {