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..825a8ef7edc 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", "*.pth", "*.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..da38598519b 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", "*.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} " + 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/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), } 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/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/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/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) => { 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