From cc1c7902d45f7f5be4a923942a948b5cb4ae6301 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 05:29:24 +0200 Subject: [PATCH 1/9] feat: update architecture capabilities and policies - Added `scheduler_applies_to_graph` to ERNIE-Image architecture definition to ensure proper UI behavior. - Introduced a new fixture for architecture capabilities to maintain consistency between backend and frontend. - Enhanced generation policies to correctly prioritize guidance settings based on architecture labels. - Added tests to verify that the scheduler application flag aligns with the node's capabilities. --- .../backend/architectures/defs/ernie_image.py | 5 + .../architectureCapabilities.json | 1172 +++++++++++++++++ .../core/baseGenerationPolicies.test.ts | 30 + .../generation/core/baseGenerationPolicies.ts | 20 +- .../test_capabilities_fixture.py | 77 ++ tests/backend/architectures/test_features.py | 31 + 6 files changed, 1334 insertions(+), 1 deletion(-) create mode 100644 invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json create mode 100644 tests/backend/architectures/test_capabilities_fixture.py diff --git a/invokeai/backend/architectures/defs/ernie_image.py b/invokeai/backend/architectures/defs/ernie_image.py index 7e9ca997f36..e4b925e97d9 100644 --- a/invokeai/backend/architectures/defs/ernie_image.py +++ b/invokeai/backend/architectures/defs/ernie_image.py @@ -35,5 +35,10 @@ dimension_grid=16, guidance_label="CFG", scheduler_set="flow", + # `ernie_image_denoise` takes a `scheduler` field and builds the sampler from it + # (ERNIE_IMAGE_SCHEDULER_MAP), so the choice reaches the graph rather than being a + # UI affordance. Omitting this defaulted it to False, which would have hidden the + # dropdown and pinned every generation to the first entry. + scheduler_applies_to_graph=True, ), ) diff --git a/invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json b/invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json new file mode 100644 index 00000000000..139b5dd0877 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json @@ -0,0 +1,1172 @@ +[ + { + "base": "anima", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "anima" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "cfg-gated" + }, + "dimension_grid": 8, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "anima", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 35, + "cfg_scale": 4.5, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "cogview4", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "cogview4" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 32, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "standard", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler_a", + "steps": 50, + "cfg_scale": 3.5, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "ernie-image", + "variant": null, + "modality": { + "modes": [ + "txt2img" + ], + "metadata_slug": "ernie_image" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "cfg-gated" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 50, + "cfg_scale": 4.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "flux", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "flux" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [ + "control_lora", + "controlnet" + ], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 28, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": 3.5, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "flux", + "variant": "dev_fill", + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "flux" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [ + "control_lora", + "controlnet" + ], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 50, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": 30.0, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "flux", + "variant": "schnell", + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "flux" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [ + "control_lora", + "controlnet" + ], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 4, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "flux2", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "flux2" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 4, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "flux2", + "variant": "dev", + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "flux2" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 28, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": 3.5, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "flux2", + "variant": "klein_4b_base", + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "flux2" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 28, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "flux2", + "variant": "klein_9b_base", + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "flux2" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 28, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "ideogram-4", + "variant": null, + "modality": { + "modes": [ + "txt2img" + ], + "metadata_slug": "ideogram4" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 48, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "krea-2", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "krea2" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "cfg-gated" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "flow", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 8, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "krea-2", + "variant": "krea2_base", + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "krea2" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "cfg-gated" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "flow", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 28, + "cfg_scale": 5.5, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "minimax-h3", + "variant": null, + "modality": { + "modes": [ + "extend_video", + "flf2v", + "i2v", + "lf2v", + "t2v", + "txt2img" + ], + "metadata_slug": "minimax_h3" + }, + "features": { + "negative_prompt": { + "visible": false, + "usage": "never" + }, + "dimension_grid": 32, + "spatial_compression": 16, + "guidance_label": "Guidance", + "scheduler_set": null, + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": null, + "steps": 50, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1344, + "height": 768, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "qwen-image", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "qwen_image" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "cfg-gated" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "standard", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 5, + "reference_images_require_variant": "edit", + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler_a", + "steps": 40, + "cfg_scale": 4.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "sd-1", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": null + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 8, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "standard", + "scheduler_applies_to_graph": true, + "control_kinds": [ + "controlnet", + "t2i_adapter" + ], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": true, + "clip_skip_max": 12, + "supports_seamless": true, + "supports_cfg_rescale": true, + "sd_vae_override": true, + "color_compensation": false, + "vae_precision": true + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler_a", + "steps": 30, + "cfg_scale": 7.0, + "cfg_rescale_multiplier": null, + "width": 512, + "height": 512, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "sd-2", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": null + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 8, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "standard", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": 24, + "supports_seamless": true, + "supports_cfg_rescale": true, + "sd_vae_override": true, + "color_compensation": false, + "vae_precision": true + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler_a", + "steps": 30, + "cfg_scale": 7.0, + "cfg_rescale_multiplier": null, + "width": 768, + "height": 768, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "sd-3", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "sd3" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "standard", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler_a", + "steps": 40, + "cfg_scale": 4.5, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "sdxl", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "sdxl" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 8, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "standard", + "scheduler_applies_to_graph": true, + "control_kinds": [ + "controlnet", + "t2i_adapter" + ], + "max_reference_images": 5, + "reference_images_require_variant": null, + "supports_regional_guidance": true, + "regional_negative": true, + "clip_skip_max": null, + "supports_seamless": true, + "supports_cfg_rescale": false, + "sd_vae_override": true, + "color_compensation": true, + "vae_precision": true + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler_a", + "steps": 30, + "cfg_scale": 7.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "sdxl-refiner", + "variant": null, + "modality": { + "modes": [], + "metadata_slug": null + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 8, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "standard", + "scheduler_applies_to_graph": true, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": true, + "color_compensation": true, + "vae_precision": true + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": null, + "steps": null, + "cfg_scale": null, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "wan", + "variant": null, + "modality": { + "modes": [ + "extend_video", + "i2v", + "img2img", + "inpaint", + "interpolate", + "outpaint", + "t2v", + "txt2img" + ], + "metadata_slug": "wan" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 40, + "cfg_scale": 4.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "wan", + "variant": "ti2v_5b", + "modality": { + "modes": [ + "extend_video", + "i2v", + "img2img", + "inpaint", + "interpolate", + "outpaint", + "t2v", + "txt2img" + ], + "metadata_slug": "wan" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "always" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "Guidance", + "scheduler_set": "flow", + "scheduler_applies_to_graph": false, + "control_kinds": [], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 30, + "cfg_scale": 5.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "z-image", + "variant": null, + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "z_image" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "cfg-gated" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [ + "z_image_control" + ], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 9, + "cfg_scale": 1.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + }, + { + "base": "z-image", + "variant": "zbase", + "modality": { + "modes": [ + "img2img", + "inpaint", + "outpaint", + "txt2img" + ], + "metadata_slug": "z_image" + }, + "features": { + "negative_prompt": { + "visible": true, + "usage": "cfg-gated" + }, + "dimension_grid": 16, + "spatial_compression": 8, + "guidance_label": "CFG", + "scheduler_set": "flow", + "scheduler_applies_to_graph": true, + "control_kinds": [ + "z_image_control" + ], + "max_reference_images": 0, + "reference_images_require_variant": null, + "supports_regional_guidance": false, + "regional_negative": false, + "clip_skip_max": null, + "supports_seamless": false, + "supports_cfg_rescale": false, + "sd_vae_override": false, + "color_compensation": false, + "vae_precision": false + }, + "defaults": { + "vae": null, + "vae_precision": null, + "scheduler": "euler", + "steps": 50, + "cfg_scale": 4.0, + "cfg_rescale_multiplier": null, + "width": 1024, + "height": 1024, + "guidance": null, + "cpu_only": null, + "fp8_storage": null + } + } +] diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts index bfafee58105..ed0eaf3bf50 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts @@ -1191,3 +1191,33 @@ describe('Krea-2, Ideogram 4 and Wan policies', () => { ).toEqual([]); }); }); + +describe('the guidance slider value from a model record', () => { + // MainModelDefaultSettings has separate cfg_scale and guidance fields; the UI has one control. + // Which field feeds it depends on guidanceLabel, and getting that backwards reads a distilled + // model's "CFG off" marker as its guidance setting. + it('prefers guidance over cfg_scale for a guidance-labelled architecture', () => { + const model = createModel('flux', { + variant: 'dev', + default_settings: { cfg_scale: 1, guidance: 3.5, steps: 28 }, + } as Partial); + + // Not 1 — that is the CFG-off marker, and buildFluxGraph wires this value into `guidance`. + expect(getDefaultGenerateSettings(model).cfgScale).toBe(3.5); + }); + + it('falls back to cfg_scale when a guidance-labelled model records no guidance', () => { + const model = createModel('flux', { + variant: 'schnell', + default_settings: { cfg_scale: 1, steps: 4 }, + } as Partial); + + expect(getDefaultGenerateSettings(model).cfgScale).toBe(1); + }); + + it('prefers cfg_scale for a CFG-labelled architecture', () => { + const model = createModel('sdxl', { default_settings: { cfg_scale: 6.5, steps: 30 } } as Partial); + + expect(getDefaultGenerateSettings(model).cfgScale).toBe(6.5); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts index f15fddb332b..755796caf02 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts @@ -433,13 +433,31 @@ export const getGenerationDimensions = ( }; }; +/** + * The value the single guidance slider takes from a model record. + * + * `MainModelDefaultSettings` carries `cfg_scale` and `guidance` as separate fields, but the UI has + * one control -- `guidanceLabel` is the whole difference between them. Which field feeds it depends + * on the label: a guidance-distilled model records `cfg_scale: 1.0` meaning "CFG off" *and* the + * guidance value it actually samples with, so preferring `cfg_scale` reads the off-switch as the + * setting. FLUX dev records `{cfg_scale: 1.0, guidance: 3.5}` and `buildFluxGraph` wires + * `guidance: settings.cfgScale`, so the old order generated at guidance 1.0 instead of 3.5. + */ +const getRecordGuidanceValue = ( + defaults: GenerateDefaultSettings | undefined, + guidanceLabel: GuidanceLabel +): number | null | undefined => + guidanceLabel === 'Guidance' + ? defaults?.guidance ?? defaults?.cfg_scale + : defaults?.cfg_scale ?? defaults?.guidance; + export const getGenerationDefaults = (model: GenerateModelConfig | undefined) => { const config = getBaseGenerationConfig(model); const defaults = model?.default_settings as GenerateDefaultSettings; return { cfgRescaleMultiplier: getNumber(defaults?.cfg_rescale_multiplier, 0), - cfgScale: getNumber(defaults?.cfg_scale ?? defaults?.guidance, config.defaults.cfgScale), + cfgScale: getNumber(getRecordGuidanceValue(defaults, config.guidanceLabel), config.defaults.cfgScale), scheduler: defaults?.scheduler ?? config.defaults.scheduler, steps: Math.max(1, Math.round(getNumber(defaults?.steps, config.defaults.steps))), vaePrecision: defaults?.vae_precision === 'fp16' ? ('fp16' as const) : ('fp32' as const), diff --git a/tests/backend/architectures/test_capabilities_fixture.py b/tests/backend/architectures/test_capabilities_fixture.py new file mode 100644 index 00000000000..7219fae04e8 --- /dev/null +++ b/tests/backend/architectures/test_capabilities_fixture.py @@ -0,0 +1,77 @@ +"""The capabilities response webv2 is built against. + +webv2 has no generated OpenAPI types -- it hand-writes its wire DTOs -- so nothing on the frontend +side would notice a renamed field, a dropped facet or a new architecture. This fixture is the +contract between the two, and it earns its keep twice: `scripts/mock-backend.mjs` serves it, so the +journey tests exercise the same payload shape the real route returns, and the frontend's unit tests +map it into their own types. + +The mirror image of `tests/app/invocations/test_frontend_graph_node_types.py`, which reads a +frontend-written file and checks it against the backend registry. This one goes the other way. +""" + +import json +import os +from pathlib import Path +from typing import Any + +from invokeai.backend.architectures.capabilities import architecture_capabilities + +FIXTURE_PATH = ( + Path(__file__).parents[3] + / "invokeai" + / "frontend" + / "webv2" + / "src" + / "features" + / "generation" + / "core" + / "__fixtures__" + / "architectureCapabilities.json" +) + +REGEN_HINT = ( + f"Regenerate it with:\n" + f" REGEN_CAPABILITIES_FIXTURE=1 pytest {Path(__file__).name}\n" + f"and commit the result, so webv2's tests and mock backend see what this backend serves." +) + + +def _rendered() -> list[dict[str, Any]]: + return [row.model_dump(mode="json") for row in architecture_capabilities()] + + +def test_the_fixture_matches_what_the_endpoint_serves() -> None: + rendered = _rendered() + + if os.environ.get("REGEN_CAPABILITIES_FIXTURE"): + FIXTURE_PATH.write_text( + json.dumps(rendered, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n" + ) + + assert FIXTURE_PATH.exists(), f"{FIXTURE_PATH} is missing. {REGEN_HINT}" + committed = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + + assert committed == rendered, ( + f"The committed capabilities fixture no longer matches this backend.\n{REGEN_HINT}\n" + "If a field was renamed or removed, webv2's hand-written wire type in " + "`features/generation/core/` needs the same edit -- that is what this test is for." + ) + + +def test_every_architecture_webv2_can_generate_with_has_a_base_row() -> None: + """The half that matters at runtime: a missing row means the Generate panel has no policy. + + Read from the graph-builder contract webv2 already writes for the invocation check, so the two + cross-stack artifacts stay in step rather than each carrying their own list of architectures. + """ + contract_path = FIXTURE_PATH.parents[1] / "__snapshots__" / "generateGraphNodeTypes.json" + if not contract_path.exists(): + # The frontend test that writes it may not have run in this checkout; the invocation-side + # test owns that failure, so do not duplicate it here. + return + + generatable = set(json.loads(contract_path.read_text(encoding="utf-8"))["byBase"]) + served = {row["base"] for row in _rendered() if row["variant"] is None} + + assert generatable <= served, sorted(generatable - served) diff --git a/tests/backend/architectures/test_features.py b/tests/backend/architectures/test_features.py index fe418d0decd..ca16b6ced09 100644 --- a/tests/backend/architectures/test_features.py +++ b/tests/backend/architectures/test_features.py @@ -165,3 +165,34 @@ def test_an_architecture_that_cannot_do_cfg_declares_no_cfg() -> None: if features.negative_prompt.usage == "never" and settings.cfg_scale != 1.0: contradictory.append(f"{base.value}: cfg_scale={settings.cfg_scale} but negative prompt is 'never'") assert contradictory == [] + + +def test_scheduler_applies_to_graph_matches_the_node() -> None: + """The flag must agree with whether the denoise node actually takes a `scheduler` field. + + `scheduler_applies_to_graph` drives whether the UI offers a scheduler dropdown at all. Declaring + it False for a node that reads one hides a control the graph honours and pins every generation + to whatever the node defaults to; declaring it True for a node without the field offers a + dropdown that reaches nothing. + + ERNIE-Image shipped the first of those: `ernie_image_denoise` builds its sampler from + `ERNIE_IMAGE_SCHEDULER_MAP[self.scheduler]`, but the facet omitted the flag and it defaulted to + False. Thirteen of the fourteen agreed; nothing compared them, so the one that did not was + invisible. + """ + schedulers = { + cls.get_type(): "scheduler" in cls.model_json_schema()["properties"] + for cls in InvocationRegistry.get_invocation_classes() + } + + disagreements = [] + for base, node_type in sorted(DENOISE_NODE.items(), key=lambda item: item[0].value): + features = get(base, FeaturesFacet) + if features is None or node_type not in schedulers: + continue + if schedulers[node_type] != features.scheduler_applies_to_graph: + disagreements.append( + f"{base.value}: {node_type} has scheduler field = {schedulers[node_type]}, " + f"facet declares scheduler_applies_to_graph = {features.scheduler_applies_to_graph}" + ) + assert disagreements == [] From c668917de4546ed56db05267c183d120e6f6b095 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 20:53:08 +0200 Subject: [PATCH 2/9] feat(architectures): serve model policy from the backend, and read it in webv2 `BASE_GENERATION` in webv2 held 14 architectures with grid, canvas, steps, CFG, scheduler, negative-prompt policy and six UI flags -- the same facts `architectures/defs/` declares. Adding a model type meant editing the frontend too, even when nothing about it was special. It is deleted; the policy now comes from `GET /api/v2/models/capabilities`. ## Reading the table `core/` cannot reach `data/` under `feature-core-purity`, so the table is pushed in through a registry, the shape `configureHttpAuth` already uses. `SupportedGenerateBase` is no longer `keyof typeof BASE_GENERATION` but a declared list beside `GRAPH_BUILDERS`: which architectures we can build a graph for is the one question only the frontend can answer, and it must not depend on load state -- `isSupportedGenerateModel` has a dozen callers outside the widget. Loading is gated on two levels. The widget shows a loading state, but the load-bearing gate is in `createGenerateWidgetSyncRuntime`: it reconciles at construction and *persists* the result into the project, so without the table it would write fallback defaults to disk -- an off-grid canvas for CogView 4 among them. `resolveGenerateWidgetValues` returns null until the table is there. Defaults change for six architectures, deliberately. FLUX moves to 28 steps at guidance 3.5 with real variant rows (Schnell 4, Fill 50 at guidance 30) -- the single row could not express that and carried Schnell's step count for every variant. sd-3 40/4.5, cogview4 50/3.5, anima 35/4.5, z-image 9 steps, SD 2.x 768px. Control kinds, reference-image limits and regional-guidance support are read from the table too; their data was verified identical to the hardcoded rules first, so those are behaviour-preserving. The limits in `controlValidation.ts` and the FLUX-Fill exception stay -- the endpoint has no column for them, and a comment says so. ## Which VAEs an architecture accepts The same fact was written down three times -- once per loader as `ui_model_base`, once in webv2's VAE picker, once more in its related-models linker -- and all three disagreed about Anima. It is not derivable: eight architectures share a 16-channel space at 8x compression, but SD 3.5 and CogView 4 belong to neither the Wan/Qwen family nor FLUX's. Nor does the VAE's own base settle it. The same physical file is registered as `anima` or `qwen-image` depending on which family it was installed for -- byte-identical, 194 tensors -- and a `wan` VAE is either the shared 16-channel Wan 2.1 file or TI2V-5B's 48-channel Wan2.2-VAE, which fits nothing else. So the new facet declares base *and*, where the base is ambiguous, the channel count `VAE_Checkpoint_Wan_Config` has been recording all along without anyone reading it. Two loader declarations were wrong and are corrected. `anima_model_loader` declared nothing, so its picker offered every VAE ever installed; it accepts the Wan-family file under any of its three bases plus a FLUX one, since `anima_l2i` branches on `isinstance(vae, (AutoencoderKLWan, FluxAutoEncoder))` with a separate decode path for each. `flux2_klein_model_loader` offered FLUX VAEs, claiming Klein "uses the same VAE as FLUX (16-channel)" -- it does not: generating with one raises `AutoEncoder.decode() got an unexpected keyword argument 'return_dict'`, the legacy 16-channel class against a 32-channel latent. ## Loading a Qwen-Image VAE Two layouts of that autoencoder are in circulation. Repo exports carry diffusers keys (`decoder.conv_in.weight`); community redistributions carry the original layout (`decoder.conv1.weight`) and need converting. Only the first was handled, and `AutoencoderKLQwenImage` registers no single-file conversion, so a redistributed file failed `load_state_dict(strict=True)` with all 194 keys missing. `strict=True` was right; the assumption in front of it was not. The byte-identical checkpoint installed under `anima` loaded fine all along through `AutoencoderKLWan.from_single_file`, which converts -- so whether a VAE worked came down to which base it happened to be probed as, and `VAE_Checkpoint_Wan_Config` deliberately hands 16-channel files to the Qwen-Image config unless the filename says "wan". The loader now looks at the state dict: diffusers keys take the direct path with `strict=True` unchanged, anything else is converted as the Wan-family file it is. No installation needs adjusting -- identification, model records and bases are untouched. ## Bugs that fall out of removing the duplicates - `bboxGrid.ts` kept its own copy of the grid column and had drifted: krea-2, wan and ideogram-4 got 8px steps while their denoise nodes enforce multiples of 16, so the canvas offered sizes that failed at enqueue. - `RegionalGuidanceSettings` asked "is this the FLUX family?" where the graph builder asks `regional_negative`. On krea-2 it rendered a negative prompt and an Auto-Negative switch that `addRegionalGuidance` then discarded. - ERNIE-Image's `FeaturesFacet` omitted `scheduler_applies_to_graph`, defaulting it to False, while `ernie_image_denoise` builds its sampler from the `scheduler` field. Thirteen of fourteen agreed; nothing compared them. - `getGenerationDefaults` folded `cfg_scale ?? guidance`, so a guidance-distilled model's "CFG off" marker won over the guidance it actually samples with. FLUX dev generated at guidance 1.0 instead of 3.5. ## Keeping it honest A committed fixture of the response body is pinned against `architecture_capabilities()` by a backend test, feeds the frontend's unit tests, and is what `scripts/mock-backend.mjs` serves -- so the hand-written wire type, the tests and the mock cannot drift from the real route. Invariant tests bind each facet to the node that enforces it: `dimension_grid` to `multiple_of`, `scheduler_applies_to_graph` to whether the denoise node takes a scheduler, and each `VaeFacet` to its loader's `ui_model_base`. Tests seed the registry through `architectureCapabilities.testing.ts` rather than a global setup file, so a test that wants to observe the unloaded behaviour still can. Verified end to end against real models: all seven installed architectures generate, previews included, and a `qwen-image`-registered VAE that failed before now works. --- .../invocations/anima/anima_model_loader.py | 9 + .../flux2/flux2_klein_model_loader.py | 7 +- .../qwen_image/qwen_image_model_loader.py | 4 +- invokeai/backend/architectures/defs/anima.py | 14 + invokeai/backend/architectures/defs/krea_2.py | 11 + .../backend/architectures/defs/qwen_image.py | 9 + invokeai/backend/architectures/defs/wan.py | 12 + .../backend/architectures/defs/z_image.py | 9 + invokeai/backend/architectures/facets/vae.py | 78 ++++++ .../model_manager/load/model_loaders/vae.py | 24 +- .../frontend/webv2/public/locales/en.json | 3 + .../frontend/webv2/scripts/mock-backend.mjs | 13 + .../src/app/GenerateWidgetSyncRuntime.tsx | 16 +- .../core/architectureCapabilities.test.ts | 141 ++++++++++ .../core/architectureCapabilities.testing.ts | 33 +++ .../core/architectureCapabilities.ts | 182 +++++++++++++ .../core/baseGenerationPolicies.test.ts | 54 ++-- .../generation/core/baseGenerationPolicies.ts | 256 ++++-------------- .../core/canvas/addControlLayers.test.ts | 3 + .../core/canvas/addRegionalGuidance.test.ts | 3 + .../core/canvas/addRegionalGuidance.ts | 24 +- .../core/canvas/compileCanvasGraph.test.ts | 3 + .../core/canvas/controlValidation.test.ts | 3 + .../core/canvas/controlValidation.ts | 23 +- .../generation/core/generationConfig.ts | 46 ++++ .../features/generation/core/graph.test.ts | 3 + .../generation/core/graphCoverage.test.ts | 5 +- .../generation/core/previewGraph.test.ts | 18 +- .../generation/core/promptHistory.test.ts | 3 + .../core/resolveGenerateWidgetValues.test.ts | 31 ++- .../core/resolveGenerateWidgetValues.ts | 9 + .../features/generation/core/settings.test.ts | 3 + .../generation/core/supportedBases.test.ts | 46 ++++ .../generation/core/supportedBases.ts | 38 +++ .../data/architectureCapabilitiesApi.ts | 15 + .../architectureCapabilitiesStore.test.ts | 87 ++++++ .../data/architectureCapabilitiesStore.ts | 87 ++++++ .../webv2/src/features/generation/graph.ts | 1 + .../src/features/generation/runtime.test.ts | 48 +++- .../webv2/src/features/generation/runtime.ts | 19 +- .../webv2/src/features/generation/settings.ts | 1 + .../generation/ui/GenerateWidgetView.tsx | 35 +++ .../importGalleryImages.test.ts | 3 + .../src/workbench/controlLayerChecks.test.ts | 3 + .../generationSettingsOrchestration.test.ts | 3 + .../appendReferenceImage.test.ts | 3 + .../image-actions/executeImageRecall.test.ts | 3 + .../image-actions/imageRecall.test.ts | 3 + .../webv2/src/workbench/invocation.test.ts | 3 + .../widget-frame/graphPreviewSource.test.ts | 3 + .../workbench/widgets/canvas/bboxGrid.test.ts | 26 +- .../src/workbench/widgets/canvas/bboxGrid.ts | 40 +-- .../invoke/prepareCanvasInvocation.test.ts | 3 + .../layers/RegionalGuidanceSettings.tsx | 9 +- tests/backend/architectures/test_vae.py | 117 ++++++++ .../load/test_qwen_image_vae_layout.py | 61 +++++ 56 files changed, 1418 insertions(+), 291 deletions(-) create mode 100644 invokeai/backend/architectures/facets/vae.py create mode 100644 invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.test.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/generationConfig.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/supportedBases.test.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/core/supportedBases.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesApi.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts create mode 100644 invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.ts create mode 100644 tests/backend/architectures/test_vae.py create mode 100644 tests/backend/model_manager/load/test_qwen_image_vae_layout.py diff --git a/invokeai/app/invocations/anima/anima_model_loader.py b/invokeai/app/invocations/anima/anima_model_loader.py index 0841f58bd9c..c84a6d0cf61 100644 --- a/invokeai/app/invocations/anima/anima_model_loader.py +++ b/invokeai/app/invocations/anima/anima_model_loader.py @@ -57,6 +57,15 @@ class AnimaModelLoaderInvocation(BaseInvocation): description="Standalone VAE model. Anima uses a Wan 2.1 / QwenImage VAE (16-channel). " "A FLUX VAE can also be used as a compatible fallback.", input=Input.Direct, + # `anima_l2i` accepts AutoencoderKLWan or FluxAutoEncoder. The Wan-family file is the same + # checkpoint whichever of the three bases it was installed under; declaring nothing here + # offered every VAE ever installed, including ones that cannot decode an Anima latent. + ui_model_base=[ + BaseModelType.Anima, + BaseModelType.QwenImage, + BaseModelType.Wan, + BaseModelType.Flux, + ], ui_model_type=ModelType.VAE, title="VAE", ) diff --git a/invokeai/app/invocations/flux2/flux2_klein_model_loader.py b/invokeai/app/invocations/flux2/flux2_klein_model_loader.py index 3cb8363146a..dffd55430a6 100644 --- a/invokeai/app/invocations/flux2/flux2_klein_model_loader.py +++ b/invokeai/app/invocations/flux2/flux2_klein_model_loader.py @@ -93,10 +93,13 @@ class Flux2KleinModelLoaderInvocation(BaseInvocation): vae_model: Optional[ModelIdentifierField] = InputField( default=None, - description="Standalone VAE model. Flux2 Klein uses the same VAE as FLUX (16-channel). " + description="Standalone VAE model (AutoencoderKLFlux2, 32-channel). " "If not provided, VAE will be loaded from the Qwen3 Source model.", input=Input.Direct, - ui_model_base=[BaseModelType.Flux, BaseModelType.Flux2], + # A FLUX VAE was offered here and does not work: it is the legacy `AutoEncoder` class with + # a 16-channel latent space, and decoding a FLUX.2 latent with it raises + # `AutoEncoder.decode() got an unexpected keyword argument 'return_dict'`. + ui_model_base=BaseModelType.Flux2, ui_model_type=ModelType.VAE, title="VAE", ) diff --git a/invokeai/app/invocations/qwen_image/qwen_image_model_loader.py b/invokeai/app/invocations/qwen_image/qwen_image_model_loader.py index b3e86d1bf4a..86a18747c9e 100644 --- a/invokeai/app/invocations/qwen_image/qwen_image_model_loader.py +++ b/invokeai/app/invocations/qwen_image/qwen_image_model_loader.py @@ -65,7 +65,9 @@ class QwenImageModelLoaderInvocation(BaseInvocation): description="Standalone Qwen Image VAE model. " "If not provided, VAE will be loaded from the Component Source (or from the main model if it is Diffusers).", input=Input.Direct, - ui_model_base=BaseModelType.QwenImage, + # The same 16-channel checkpoint is registered under either base depending on which + # family it was installed for; krea2_model_loader already offers both. + ui_model_base=[BaseModelType.QwenImage, BaseModelType.Anima], ui_model_type=ModelType.VAE, title="VAE", ) diff --git a/invokeai/backend/architectures/defs/anima.py b/invokeai/backend/architectures/defs/anima.py index 0953d44fb34..0a328831729 100644 --- a/invokeai/backend/architectures/defs/anima.py +++ b/invokeai/backend/architectures/defs/anima.py @@ -5,6 +5,7 @@ from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.facets.vae import VaeCompatibility, VaeFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -26,4 +27,17 @@ scheduler_set="anima", scheduler_applies_to_graph=True, ), + VaeFacet( + frozenset( + { + # `anima_l2i` accepts AutoencoderKLWan or FluxAutoEncoder, each with its own + # decode path. The Wan-family file is registered under whichever base it was + # installed for -- all three point at the same 194-tensor checkpoint. + VaeCompatibility(BaseModelType.Anima), + VaeCompatibility(BaseModelType.QwenImage), + VaeCompatibility(BaseModelType.Wan, latent_channels=16), + VaeCompatibility(BaseModelType.Flux), + } + ) + ), ) diff --git a/invokeai/backend/architectures/defs/krea_2.py b/invokeai/backend/architectures/defs/krea_2.py index 0b1987da6e2..9417a282e3e 100644 --- a/invokeai/backend/architectures/defs/krea_2.py +++ b/invokeai/backend/architectures/defs/krea_2.py @@ -5,6 +5,7 @@ from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.facets.vae import VaeCompatibility, VaeFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, Krea2VariantType @@ -34,4 +35,14 @@ scheduler_set="flow", supports_regional_guidance=True, ), + VaeFacet( + frozenset( + { + # Krea-2 decodes with the Qwen-Image VAE, which is why its graph reuses + # `qwen_image_l2i`. The same file also appears registered as `anima`. + VaeCompatibility(BaseModelType.QwenImage), + VaeCompatibility(BaseModelType.Anima), + } + ) + ), ) diff --git a/invokeai/backend/architectures/defs/qwen_image.py b/invokeai/backend/architectures/defs/qwen_image.py index c8ff84447a4..f4def104bd5 100644 --- a/invokeai/backend/architectures/defs/qwen_image.py +++ b/invokeai/backend/architectures/defs/qwen_image.py @@ -5,6 +5,7 @@ from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.facets.vae import VaeCompatibility, VaeFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType @@ -27,4 +28,12 @@ max_reference_images=5, reference_images_require_variant="edit", ), + VaeFacet( + frozenset( + { + VaeCompatibility(BaseModelType.QwenImage), + VaeCompatibility(BaseModelType.Anima), + } + ) + ), ) diff --git a/invokeai/backend/architectures/defs/wan.py b/invokeai/backend/architectures/defs/wan.py index 86ef2864a6e..fb9825510f3 100644 --- a/invokeai/backend/architectures/defs/wan.py +++ b/invokeai/backend/architectures/defs/wan.py @@ -5,6 +5,7 @@ from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import WAN21_16, WAN22_48, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.facets.vae import VaeCompatibility, VaeFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, WanVariantType @@ -38,4 +39,15 @@ guidance_label="Guidance", scheduler_set="flow", ), + VaeFacet( + frozenset( + { + # A14B uses the 16-channel Wan 2.1 VAE; TI2V-5B needs the 48-channel + # Wan2.2-VAE. Both are registered under `wan`, so the channel count is the + # only thing that tells them apart. + VaeCompatibility(BaseModelType.Wan, latent_channels=16), + VaeCompatibility(BaseModelType.Wan, latent_channels=48), + } + ) + ), ) diff --git a/invokeai/backend/architectures/defs/z_image.py b/invokeai/backend/architectures/defs/z_image.py index 20297203365..877f7b9ee11 100644 --- a/invokeai/backend/architectures/defs/z_image.py +++ b/invokeai/backend/architectures/defs/z_image.py @@ -5,6 +5,7 @@ from invokeai.backend.architectures.facets.features import FeaturesFacet, NegativePrompt from invokeai.backend.architectures.facets.latent_space import FLUX_16, LatentSpaceFacet from invokeai.backend.architectures.facets.modality import ModalityFacet +from invokeai.backend.architectures.facets.vae import VaeCompatibility, VaeFacet from invokeai.backend.architectures.registry import register from invokeai.backend.model_manager.configs.default_settings import MainModelDefaultSettings from invokeai.backend.model_manager.taxonomy import BaseModelType, ZImageVariantType @@ -34,4 +35,12 @@ scheduler_applies_to_graph=True, control_kinds=frozenset({"z_image_control"}), ), + VaeFacet( + frozenset( + { + # Z-Image decodes with a FLUX-compatible VAE; `z_image_model_loader` says so. + VaeCompatibility(BaseModelType.Flux), + } + ) + ), ) diff --git a/invokeai/backend/architectures/facets/vae.py b/invokeai/backend/architectures/facets/vae.py new file mode 100644 index 00000000000..0c6219cb7e2 --- /dev/null +++ b/invokeai/backend/architectures/facets/vae.py @@ -0,0 +1,78 @@ +"""Which VAEs an architecture can decode with. + +Not derivable from the latent geometry. Eight architectures share a 16-channel space at 8x +compression, but their VAEs are not interchangeable: SD 3.5 and CogView 4 are 16-channel too and +belong to neither the Wan/Qwen family nor FLUX's. Compatibility is a fact about the decoder class, +so it is declared. + +Nor is it derivable from the VAE's `base` alone. The same physical file is registered under +`anima` or `qwen-image` depending on which family it was installed for -- byte-identical, 194 +tensors -- and a `wan` VAE may be either the 16-channel Wan 2.1 file (the same family again) or +TI2V-5B's 48-channel Wan2.2-VAE, which fits nothing else. `VAE_Checkpoint_Wan_Config` already +records `latent_channels`; this facet is what finally reads it. +""" + +from dataclasses import dataclass +from typing import ClassVar + +from invokeai.backend.architectures.facet import Facet +from invokeai.backend.architectures.registry import get +from invokeai.backend.model_manager.taxonomy import BaseModelType + + +@dataclass(frozen=True) +class VaeCompatibility: + """One VAE an architecture accepts, as a model record can be matched against it.""" + + base: BaseModelType + + latent_channels: int | None = None + """Required channel count, where the base alone is ambiguous. + + Only `wan` is: its VAEs carry `latent_channels` of 16 or 48 and only the 16-channel ones belong + to the shared family. `None` means the base is unambiguous and the field is not consulted -- + which is also what happens for VAE configs that do not carry it. + """ + + def matches(self, vae_base: BaseModelType, vae_latent_channels: int | None = None) -> bool: + if vae_base != self.base: + return False + if self.latent_channels is None: + return True + return vae_latent_channels == self.latent_channels + + +@dataclass(frozen=True) +class VaeFacet(Facet): + """The VAEs a model of this architecture can be paired with. + + Includes the architecture's own base wherever that is accepted, so the set is complete on its + own and a consumer never has to add an implicit "or its own". + """ + + REQUIRED: ClassVar[bool] = False + + accepted: frozenset[VaeCompatibility] + + def accepts(self, vae_base: BaseModelType, vae_latent_channels: int | None = None) -> bool: + return any(entry.matches(vae_base, vae_latent_channels) for entry in self.accepted) + + @property + def accepted_bases(self) -> frozenset[BaseModelType]: + """The bases alone, for the UI's first-pass filter and for comparing against a loader's + `ui_model_base`, which cannot express a channel constraint.""" + return frozenset(entry.base for entry in self.accepted) + + +def accepts_vae(base: BaseModelType, vae_base: BaseModelType, vae_latent_channels: int | None = None) -> bool: + """Whether `base` can decode with this VAE. + + Architectures that declare no `VaeFacet` accept only their own base -- the SD family and + anything whose loader has no VAE input at all. + """ + facet = get(base, VaeFacet) + + if facet is None: + return vae_base == base + + return facet.accepts(vae_base, vae_latent_channels) diff --git a/invokeai/backend/model_manager/load/model_loaders/vae.py b/invokeai/backend/model_manager/load/model_loaders/vae.py index 51497e725a0..d58a98f9d4d 100644 --- a/invokeai/backend/model_manager/load/model_loaders/vae.py +++ b/invokeai/backend/model_manager/load/model_loaders/vae.py @@ -279,11 +279,16 @@ def _load_wan_vae_diffusers(self, config: VAE_Diffusers_Wan_Config) -> AnyModel: def _load_qwen_image_vae(self, config: VAE_Checkpoint_QwenImage_Config) -> AnyModel: """Load a Qwen Image VAE from a single safetensors file. - The Qwen Image VAE checkpoint is expected to be in the diffusers state-dict - layout (i.e. the same keys as `vae/diffusion_pytorch_model.safetensors` from - the Qwen-Image repo). `AutoencoderKLQwenImage` does not register a single-file - conversion in diffusers, so we instantiate the model with default config and - load the state dict directly. + Two layouts reach this method. Files exported from the Qwen-Image repo carry the diffusers + state-dict keys (`decoder.conv_in.weight`, ...) and are loaded directly, because + `AutoencoderKLQwenImage` registers no single-file conversion in diffusers. + + Community redistributions carry the original layout instead (`decoder.conv1.weight`, ...), + which needs converting. Those files are the 16-channel Wan-family VAE -- architecturally the + same autoencoder -- so `AutoencoderKLWan.from_single_file` reads them, and the identical + checkpoint installed under `anima` already takes that path. Loading them into + `AutoencoderKLQwenImage` with `strict=True` failed with 194 missing keys, which made a VAE + unusable purely because of the base it happened to be probed as. """ import accelerate from diffusers.models.autoencoders.autoencoder_kl_qwenimage import AutoencoderKLQwenImage @@ -291,6 +296,15 @@ def _load_qwen_image_vae(self, config: VAE_Checkpoint_QwenImage_Config) -> AnyMo sd = load_file(config.path) + if "decoder.conv_in.weight" not in sd: + from diffusers.models.autoencoders import AutoencoderKLWan + + from invokeai.backend.wan.rocm_causal_conv3d import patch_wan_causal_conv3d_for_rocm + + del sd + patch_wan_causal_conv3d_for_rocm() + return AutoencoderKLWan.from_single_file(config.path, torch_dtype=self._torch_dtype) + if self._torch_dtype is not None: for k in list(sd.keys()): if sd[k].is_floating_point(): diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index db241b746a3..781e561524f 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1487,6 +1487,7 @@ "addPromptTrigger": "Add prompt trigger", "advanced": "Advanced", "aspectRatio": "Aspect ratio", + "capabilitiesLoadFailed": "Could not load model capabilities from the backend.", "cfgRescale": "CFG rescale", "chooseModelToStart": "Choose a model to start", "clearPromptTemplate": "Stop using this template", @@ -1664,6 +1665,7 @@ "llmTaskGenerating": "Generating", "llmTaskLoadingModel": "Loading model", "llmTaskProgress": "LLM task progress", + "loadingCapabilities": "Loading model capabilities…", "loadingModels": "Loading backend models…", "lockAspectRatio": "Lock aspect ratio", "mainModel": "Main model", @@ -1701,6 +1703,7 @@ "promptHistoryKeyboardHelp": "Alt+Up/Down switches between prompts while focused.", "recentSeeds": "Recent seeds", "resetToModelDefault": "Reset to model default", + "retry": "Retry", "savePreset": "Save as preset…", "savePresetAction": "Save", "savePresetTitle": "Save preset", diff --git a/invokeai/frontend/webv2/scripts/mock-backend.mjs b/invokeai/frontend/webv2/scripts/mock-backend.mjs index 43911fea926..60970bd1662 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend.mjs @@ -26,6 +26,16 @@ const TINY_PNG = Buffer.from( ); const FIXTURE_VIDEO = readFileSync(resolve(import.meta.dirname, 'mock-assets/fixture-video.mp4')); const FIXTURE_VIDEO_POSTER = readFileSync(resolve(import.meta.dirname, 'mock-assets/fixture-video.webp')); +// Served verbatim from the same file the backend pins in +// `tests/backend/architectures/test_capabilities_fixture.py`, so the journey tests exercise the +// payload the real route returns rather than a stand-in that can drift from it. +const ARCHITECTURE_CAPABILITIES = JSON.parse( + readFileSync( + resolve(import.meta.dirname, '../src/features/generation/core/__fixtures__/architectureCapabilities.json'), + 'utf8' + ) +); + const MOCK_USER_ID = 'fixture-user'; const clone = (value) => structuredClone(value); @@ -882,6 +892,9 @@ export const startMockBackend = async (port, { profile = 'empty' } = {}) => { if (method === 'GET' && path === '/api/v2/models/install') { return json(200, []); } + if (method === 'GET' && path === '/api/v2/models/capabilities') { + return json(200, ARCHITECTURE_CAPABILITIES); + } if (method === 'GET' && path === '/api/v2/models/starter_models') { return json(200, STARTER_MODELS_RESPONSE); } diff --git a/invokeai/frontend/webv2/src/app/GenerateWidgetSyncRuntime.tsx b/invokeai/frontend/webv2/src/app/GenerateWidgetSyncRuntime.tsx index b9a5925fef5..17acbabe22b 100644 --- a/invokeai/frontend/webv2/src/app/GenerateWidgetSyncRuntime.tsx +++ b/invokeai/frontend/webv2/src/app/GenerateWidgetSyncRuntime.tsx @@ -1,4 +1,10 @@ -import { createGenerateWidgetSyncRuntime, type GenerateWidgetSyncProjectSnapshot } from '@features/generation/runtime'; +import { + createGenerateWidgetSyncRuntime, + ensureArchitectureCapabilitiesLoaded, + getArchitectureCapabilitiesSnapshot, + subscribeArchitectureCapabilities, + type GenerateWidgetSyncProjectSnapshot, +} from '@features/generation/runtime'; import { ensureModelsLoaded, getModelsSnapshot, subscribeModels } from '@features/models'; import { useMountEffect } from '@platform/react/useMountEffect'; import { createProjectedExternalStore } from '@platform/state/projectedExternalStore'; @@ -25,13 +31,21 @@ export const GenerateWidgetSyncRuntime = () => { select: (snapshot) => snapshot.models, source: { getSnapshot: getModelsSnapshot, subscribe: subscribeModels }, }); + const capabilitiesLoaded = createProjectedExternalStore({ + select: (snapshot) => snapshot.status === 'loaded', + source: { getSnapshot: getArchitectureCapabilitiesSnapshot, subscribe: subscribeArchitectureCapabilities }, + }); const runtime = createGenerateWidgetSyncRuntime({ + capabilitiesLoaded, models, patchValues: store.commands.generation.patchSettings, project, queryClient, }); + // Kicked here, at app boot, so the gate's window is one round trip rather than "whenever the + // Generate panel is first opened". + ensureArchitectureCapabilitiesLoaded(); void ensureModelsLoaded(); return () => runtime.dispose(); }); diff --git a/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.test.ts b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.test.ts new file mode 100644 index 00000000000..4a9f4ce6404 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.test.ts @@ -0,0 +1,141 @@ +/** + * The mapper, driven by the real response body. + * + * `__fixtures__/architectureCapabilities.json` is pinned against the backend by + * `tests/backend/architectures/test_capabilities_fixture.py`, so these assertions are about the + * payload the app actually receives rather than a hand-written stand-in. + */ + +import { afterEach, describe, expect, it } from 'vitest'; + +import fixture from './__fixtures__/architectureCapabilities.json'; +import { + type ArchitectureCapabilitiesRow, + getArchitectureCapabilityRow, + getArchitectureFeatures, + getArchitectureGenerationConfig, + hasArchitectureCapabilities, + resetArchitectureCapabilities, + setArchitectureCapabilities, + toBaseGenerationConfig, +} from './architectureCapabilities'; +import { SUPPORTED_GENERATE_BASES } from './supportedBases'; + +const rows = fixture as ArchitectureCapabilitiesRow[]; +const row = (base: string, variant: string | null = null): ArchitectureCapabilitiesRow => { + const found = rows.find((r) => r.base === base && r.variant === variant); + if (!found) { + throw new Error(`fixture has no row for ${base}/${variant}`); + } + return found; +}; + +afterEach(resetArchitectureCapabilities); + +describe('the wire contract', () => { + it('serves a base row for every architecture this build can generate with', () => { + const served = new Set(rows.filter((r) => r.variant === null).map((r) => r.base)); + expect(SUPPORTED_GENERATE_BASES.filter((base) => !served.has(base))).toEqual([]); + }); +}); + +describe('toBaseGenerationConfig', () => { + it('reads the guidance field for a guidance-labelled architecture', () => { + // dev is FLUX's base row -- variant rows exist only where they differ. It records cfg_scale + // 1.0 ("CFG off") alongside guidance 3.5, and buildFluxGraph wires this value into the node's + // `guidance`. Preferring cfg_scale would generate at 1.0. + expect(toBaseGenerationConfig(row('flux')).defaults.cfgScale).toBe(3.5); + expect(toBaseGenerationConfig(row('flux', 'dev_fill')).defaults.cfgScale).toBe(30); + }); + + it('falls back to cfg_scale when a guidance-labelled architecture declares no guidance', () => { + const schnell = toBaseGenerationConfig(row('flux', 'schnell')); + expect(schnell.guidanceLabel).toBe('Guidance'); + expect(schnell.defaults.cfgScale).toBe(1); + expect(schnell.defaults.steps).toBe(4); + }); + + it('reads cfg_scale for a CFG-labelled architecture', () => { + const sdxl = toBaseGenerationConfig(row('sdxl')); + expect(sdxl.guidanceLabel).toBe('CFG'); + expect(sdxl.defaults).toEqual({ steps: 30, cfgScale: 7, scheduler: 'euler_a' }); + }); + + it('derives the optimal side from the declared area, not from width alone', () => { + // Square today for every generatable architecture, so this is the identity -- but MiniMax H3 + // is 1344x768, so the area form is what keeps a non-square canvas from being squashed. + expect(toBaseGenerationConfig(row('sd-1')).dimensions.optimalSide).toBe(512); + expect(toBaseGenerationConfig(row('sd-2')).dimensions.optimalSide).toBe(768); + expect(toBaseGenerationConfig(row('minimax-h3')).dimensions.optimalSide).toBe(1016); // sqrt(1344*768) + }); + + it('carries the feature flags across verbatim', () => { + const sd1 = toBaseGenerationConfig(row('sd-1')); + expect(sd1.dimensions.grid).toBe(8); + expect(sd1.negativePrompt).toEqual({ visible: true, usage: 'always' }); + expect(sd1.ui).toEqual({ + sdVaeOverride: true, + colorCompensation: false, + vaePrecision: true, + seamless: true, + cfgRescale: true, + clipSkipMax: 12, + }); + }); + + it('turns an absent clip-skip ceiling into undefined rather than null', () => { + // Consumers branch on presence as well as falsiness. + expect(toBaseGenerationConfig(row('flux')).ui.clipSkipMax).toBeUndefined(); + expect('clipSkipMax' in toBaseGenerationConfig(row('flux')).ui).toBe(true); + }); + + it('declares ERNIE-Image scheduler choice as reaching the graph', () => { + // ernie_image_denoise builds its sampler from the `scheduler` field, so the dropdown is real. + expect(toBaseGenerationConfig(row('ernie-image')).schedulerAppliesToGraph).toBe(true); + }); +}); + +describe('the registry', () => { + it('is empty until the table is pushed in', () => { + expect(hasArchitectureCapabilities()).toBe(false); + expect(getArchitectureGenerationConfig('sd-1')).toBeUndefined(); + + setArchitectureCapabilities(rows); + + expect(hasArchitectureCapabilities()).toBe(true); + expect(getArchitectureGenerationConfig('sd-1')?.defaults.steps).toBe(30); + }); + + it('prefers a variant row and falls back to the architecture row', () => { + setArchitectureCapabilities(rows); + + expect(getArchitectureGenerationConfig('flux', 'schnell')?.defaults.steps).toBe(4); + expect(getArchitectureGenerationConfig('flux', 'dev')?.defaults.steps).toBe(28); + // A variant with no row of its own, and no variant at all, both land on the base row. + expect(getArchitectureGenerationConfig('flux', 'made-up')?.defaults.steps).toBe(28); + expect(getArchitectureGenerationConfig('flux')?.defaults.steps).toBe(28); + }); + + it('returns features from the architecture row regardless of variant', () => { + setArchitectureCapabilities(rows); + + expect(getArchitectureFeatures('sd-1')?.control_kinds).toEqual(['controlnet', 't2i_adapter']); + expect(getArchitectureFeatures('z-image')?.control_kinds).toEqual(['z_image_control']); + expect(getArchitectureFeatures('made-up')).toBeUndefined(); + }); + + it('hands back stable objects so policy accessors can be used in render paths', () => { + setArchitectureCapabilities(rows); + + expect(getArchitectureGenerationConfig('sdxl')).toBe(getArchitectureGenerationConfig('sdxl')); + }); + + it('forgets everything on reset', () => { + setArchitectureCapabilities(rows); + resetArchitectureCapabilities(); + + expect(hasArchitectureCapabilities()).toBe(false); + expect(getArchitectureCapabilityRow('sd-1')).toBeUndefined(); + expect(getArchitectureFeatures('sd-1')).toBeUndefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts new file mode 100644 index 00000000000..c2dba584713 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts @@ -0,0 +1,33 @@ +/** + * Seeds the architecture capability registry for tests. + * + * Generation policy comes from the backend now, and the resolver fails closed without it, so any + * test that touches policy needs the table present -- the way it always is in a running app, where + * app boot fetches it before the Generate widget renders. + * + * Explicit rather than a global setup file: a test that wants to observe the *unloaded* behaviour + * should be able to see that it is unloaded. + */ + +import { afterEach, beforeEach } from 'vitest'; + +import fixture from './__fixtures__/architectureCapabilities.json'; +import { + type ArchitectureCapabilitiesRow, + resetArchitectureCapabilities, + setArchitectureCapabilities, +} from './architectureCapabilities'; + +/** The response body the backend actually serves, pinned by `test_capabilities_fixture.py`. */ +export const architectureCapabilitiesFixture = fixture as ArchitectureCapabilitiesRow[]; + +/** Load the table before each test and drop it afterwards, so nothing leaks between files. */ +export const useArchitectureCapabilitiesFixture = (): void => { + beforeEach(() => { + setArchitectureCapabilities(architectureCapabilitiesFixture); + }); + + afterEach(() => { + resetArchitectureCapabilities(); + }); +}; diff --git a/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts new file mode 100644 index 00000000000..f8de4942ded --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts @@ -0,0 +1,182 @@ +/** + * What the backend says each architecture supports, and how it reaches generation policy. + * + * `GET /api/v2/models/capabilities` serves one row per architecture plus a row per variant that + * answers differently -- the same table `invokeai/backend/architectures/defs/.py` declares. + * Adding an architecture to the backend should not mean editing a table here too. + * + * webv2 has no generated OpenAPI types, so the wire shape below is hand-written and unguarded by + * the compiler. `tests/backend/architectures/test_capabilities_fixture.py` is what guards it: it + * pins `__fixtures__/architectureCapabilities.json` against what the backend actually renders, and + * that same fixture feeds these tests and the mock backend. + * + * This module is deliberately push-based. `feature-core-purity` forbids `core/` from importing + * `data/` or transport, so the store calls `setArchitectureCapabilities` rather than being read + * from here -- the same shape `configureHttpAuth` uses in `platform/transport/http.ts`. + */ + +import type { BaseGenerationConfig, GuidanceLabel, NegativePromptUsage, SchedulerSetId } from './generationConfig'; + +/** A capabilities row exactly as the backend serialises it. snake_case, unions kept open. */ +export interface ArchitectureCapabilitiesRow { + base: string; + /** Null for the architecture's own row. A variant row overrides it. */ + variant: string | null; + modality: { + modes: string[]; + metadata_slug: string | null; + }; + features: { + negative_prompt: { visible: boolean; usage: NegativePromptUsage }; + dimension_grid: number; + spatial_compression: number; + guidance_label: GuidanceLabel | (string & {}); + /** No `flow-no-lcm` here: that set is reached by a frontend-only variant rule, see below. */ + scheduler_set: SchedulerSetId | null; + scheduler_applies_to_graph: boolean; + control_kinds: string[]; + max_reference_images: number; + reference_images_require_variant: string | null; + supports_regional_guidance: boolean; + regional_negative: boolean; + clip_skip_max: number | null; + supports_seamless: boolean; + supports_cfg_rescale: boolean; + sd_vae_override: boolean; + color_compensation: boolean; + vae_precision: boolean; + }; + defaults: { + vae: string | null; + vae_precision: 'fp16' | 'fp32' | null; + scheduler: string | null; + steps: number | null; + cfg_scale: number | null; + cfg_rescale_multiplier: number | null; + width: number | null; + height: number | null; + guidance: number | null; + cpu_only: boolean | null; + fp8_storage: boolean | null; + } | null; +} + +const FALLBACK_STEPS = 30; +const FALLBACK_CFG_SCALE = 7; +const FALLBACK_SCHEDULER = 'euler_a'; +const FALLBACK_OPTIMAL_SIDE = 1024; + +/** + * The value the single guidance slider takes. + * + * There is one control, labelled from `guidance_label`, and two fields behind it. A + * guidance-distilled architecture records `cfg_scale: 1.0` meaning "CFG off" *and* the guidance it + * samples with, so reading `cfg_scale` first would present the off-switch as the setting. + */ +const guidanceValue = (defaults: NonNullable, label: GuidanceLabel): number => + (label === 'Guidance' ? defaults.guidance ?? defaults.cfg_scale : defaults.cfg_scale ?? defaults.guidance) ?? + FALLBACK_CFG_SCALE; + +/** + * The optimal canvas as a side length. + * + * `optimalSide` is squared back into an area by its consumers (`importGalleryImages` passes + * `optimal ** 2` to `calculateNewSize`), so deriving it from the area rather than from `width` + * alone survives an architecture whose default canvas is not square. Every architecture webv2 + * generates images with is square today; MiniMax H3 is 1344x768, which is why this is not just + * `width`. + */ +const optimalSide = (defaults: NonNullable): number => { + const { width, height } = defaults; + if (width && height) { + return Math.round(Math.sqrt(width * height)); + } + return width ?? height ?? FALLBACK_OPTIMAL_SIDE; +}; + +/** Map one row onto the shape generation policy already speaks. Pure. */ +export const toBaseGenerationConfig = (row: ArchitectureCapabilitiesRow): BaseGenerationConfig => { + const { features } = row; + const defaults = row.defaults; + const guidanceLabel: GuidanceLabel = features.guidance_label === 'Guidance' ? 'Guidance' : 'CFG'; + + return { + dimensions: { + grid: features.dimension_grid, + optimalSide: defaults ? optimalSide(defaults) : FALLBACK_OPTIMAL_SIDE, + }, + defaults: { + steps: defaults?.steps ?? FALLBACK_STEPS, + cfgScale: defaults ? guidanceValue(defaults, guidanceLabel) : FALLBACK_CFG_SCALE, + scheduler: defaults?.scheduler ?? FALLBACK_SCHEDULER, + }, + schedulerSet: features.scheduler_set ?? 'standard', + schedulerAppliesToGraph: features.scheduler_applies_to_graph, + guidanceLabel, + negativePrompt: features.negative_prompt, + ui: { + sdVaeOverride: features.sd_vae_override, + colorCompensation: features.color_compensation, + vaePrecision: features.vae_precision, + seamless: features.supports_seamless, + cfgRescale: features.supports_cfg_rescale, + // `undefined`, not `null`: consumers branch on falsiness and on presence. + clipSkipMax: features.clip_skip_max ?? undefined, + }, + }; +}; + +const key = (base: string, variant: string | null): string => `${base}\u0000${variant ?? ''}`; + +let rows: readonly ArchitectureCapabilitiesRow[] | null = null; +let byKey = new Map(); +let configByKey = new Map(); + +/** Called by `data/` once the table has been fetched. */ +export const setArchitectureCapabilities = (next: readonly ArchitectureCapabilitiesRow[]): void => { + rows = next; + byKey = new Map(next.map((row) => [key(row.base, row.variant), row])); + // Memoised so policy accessors stay O(1) and hand back referentially stable objects. + configByKey = new Map(next.map((row) => [key(row.base, row.variant), toBaseGenerationConfig(row)])); +}; + +/** Drop the table. The store calls this on account change; tests call it to isolate. */ +export const resetArchitectureCapabilities = (): void => { + rows = null; + byKey = new Map(); + configByKey = new Map(); +}; + +export const hasArchitectureCapabilities = (): boolean => rows !== null; + +/** `(base, variant)` if that variant answers differently, else the architecture's own row. */ +export const getArchitectureCapabilityRow = ( + base: string, + variant?: unknown +): ArchitectureCapabilitiesRow | undefined => { + if (typeof variant === 'string' && variant.length > 0) { + const exact = byKey.get(key(base, variant)); + if (exact) { + return exact; + } + } + return byKey.get(key(base, null)); +}; + +export const getArchitectureGenerationConfig = (base: string, variant?: unknown): BaseGenerationConfig | undefined => { + if (typeof variant === 'string' && variant.length > 0) { + const exact = configByKey.get(key(base, variant)); + if (exact) { + return exact; + } + } + return configByKey.get(key(base, null)); +}; + +/** + * Feature flags for an architecture. Variant-independent by design: the backend copies one + * `features` block onto every variant row of an architecture, so asking per variant would suggest + * a precision that is not there. + */ +export const getArchitectureFeatures = (base: string): ArchitectureCapabilitiesRow['features'] | undefined => + byKey.get(key(base, null))?.features; diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts index ed0eaf3bf50..ce52335345a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts @@ -10,7 +10,6 @@ import type { } from './types'; import { - BASE_GENERATION, coerceSchedulerForGraph, getComponentSectionPolicy, getAutoFlux2ComponentSourceModel, @@ -27,6 +26,7 @@ import { isSupportedGenerateModel, SUPPORTED_GENERATE_BASES, } from './baseGenerationPolicies'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const createModel = (base: string, overrides: Partial = {}): MainModelConfig => ({ base, @@ -102,7 +102,9 @@ const externalModel: GenerateModelConfig = { type: 'external_image_generator', }; -describe('BASE_GENERATION', () => { +useArchitectureCapabilitiesFixture(); + +describe('architecture policy, read from the backend capability table', () => { it('matches expected dimensions per base', () => { expect(getGenerationDimensions(createModel('sd-1'))).toMatchObject({ grid: 8, optimal: 512 }); expect(getGenerationDimensions(createModel('sdxl'))).toMatchObject({ grid: 8, optimal: 1024 }); @@ -111,6 +113,8 @@ describe('BASE_GENERATION', () => { }); it('matches expected defaults per base', () => { + // These now come from the architecture facets rather than a table maintained here, so several + // moved to the values the model cards recommend. See the PR description for the full list. expect(getDefaultGenerateSettings(createModel('sdxl'))).toMatchObject({ steps: 30, cfgScale: 7, @@ -118,9 +122,11 @@ describe('BASE_GENERATION', () => { width: 1024, height: 1024, }); + // FLUX's base row is dev: 28 steps at guidance 3.5. The old single row carried Schnell's step + // count for every variant, which is what the variant rows below now express properly. expect(getDefaultGenerateSettings(createModel('flux'))).toMatchObject({ - steps: 4, - cfgScale: 4, + steps: 28, + cfgScale: 3.5, scheduler: 'euler', }); expect(getDefaultGenerateSettings(createModel('flux2'))).toMatchObject({ @@ -133,13 +139,28 @@ describe('BASE_GENERATION', () => { cfgScale: 4, scheduler: 'euler_a', }); + // Tongyi-MAI/Z-Image-Turbo says num_inference_steps=9. expect(getDefaultGenerateSettings(createModel('z-image'))).toMatchObject({ - steps: 8, + steps: 9, cfgScale: 1, scheduler: 'euler', }); }); + it('answers per variant where the architecture does', () => { + // The gain the single-row table could not express: Schnell and dev want different step counts, + // and Fill wants a guidance an order of magnitude higher. + expect(getDefaultGenerateSettings(createModel('flux', { variant: 'schnell' }))).toMatchObject({ steps: 4 }); + expect(getDefaultGenerateSettings(createModel('flux', { variant: 'dev_fill' }))).toMatchObject({ + steps: 50, + cfgScale: 30, + }); + expect(getDefaultGenerateSettings(createModel('z-image', { variant: 'zbase' }))).toMatchObject({ + steps: 50, + cfgScale: 4, + }); + }); + it('matches expected scheduler sets per base', () => { const flux = createModel('flux'); const zbase = createModel('z-image', { variant: 'zbase' }); @@ -233,30 +254,15 @@ describe('BASE_GENERATION', () => { ).toBe(true); }); - it('has generation config for every graph builder base', () => { - expect(SUPPORTED_GENERATE_BASES).toEqual([ - 'sd-1', - 'sd-2', - 'sdxl', - 'sd-3', - 'flux', - 'flux2', - 'cogview4', - 'ernie-image', - 'qwen-image', - 'z-image', - 'ideogram-4', - 'krea-2', - 'wan', - 'anima', - ]); - }); + // The ordered list of generatable bases is pinned in `supportedBases.test.ts`, which now owns it. it('does not mark display-only bases as generatable', () => { expect(isSupportedGenerateModel(createModel('sdxl-refiner'))).toBe(false); expect(isSupportedGenerateModel(createModel('unknown'))).toBe(false); expect(isSupportedGenerateModel(createModel('made-up'))).toBe(false); - expect(BASE_GENERATION).not.toHaveProperty('external'); + // 'external' is the pseudo-base of external image generators; they are supported through + // `type === 'external_image_generator'`, not by having an architecture row. + expect(isSupportedGenerateModel(createModel('external'))).toBe(false); }); }); diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts index 755796caf02..b233ea82630 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts @@ -5,6 +5,24 @@ import type { PromptHistoryItem, } from '@features/generation/core/contracts'; +import { + isSupportedGenerateBase, + SUPPORTED_GENERATE_BASES, + type SupportedGenerateBase, +} from '@features/generation/core/supportedBases'; + +import type { + BaseGenerationConfig, + GuidanceLabel, + NegativePromptUsage, + SchedulerSetId, +} from '@features/generation/core/generationConfig'; + +import { + getArchitectureFeatures, + getArchitectureGenerationConfig, +} from '@features/generation/core/architectureCapabilities'; + import type { GenerateModelConfig, GenerateReferenceImage, @@ -71,38 +89,12 @@ export interface SchedulerOption { label: string; } -export type SchedulerSetId = 'standard' | 'flow' | 'flow-no-lcm' | 'anima'; - -export type NegativePromptUsage = 'always' | 'cfg-gated' | 'never'; - -export type GuidanceLabel = 'CFG' | 'Guidance'; - -export interface BaseGenerationConfig { - dimensions: { - grid: number; - optimalSide: number; - }; - defaults: { - steps: number; - cfgScale: number; - scheduler: string; - }; - schedulerSet: SchedulerSetId; - schedulerAppliesToGraph: boolean; - guidanceLabel: GuidanceLabel; - negativePrompt: { - visible: boolean; - usage: NegativePromptUsage; - }; - ui: { - sdVaeOverride: boolean; - colorCompensation: boolean; - vaePrecision: boolean; - seamless: boolean; - cfgRescale: boolean; - clipSkipMax?: number; - }; -} +export type { + BaseGenerationConfig, + GuidanceLabel, + NegativePromptUsage, + SchedulerSetId, +} from '@features/generation/core/generationConfig'; type GenerateDefaultSettings = | { @@ -180,166 +172,8 @@ const ANIMA_SCHEDULERS = new Set(ANIMA_SCHEDULER_OPTIONS.map((option) => option. export const isKnownScheduler = (value: string): boolean => KNOWN_SCHEDULERS.has(value); -export const BASE_GENERATION = { - 'sd-1': { - dimensions: { grid: 8, optimalSide: 512 }, - defaults: { steps: 30, cfgScale: 7, scheduler: 'euler_a' }, - schedulerSet: 'standard', - schedulerAppliesToGraph: true, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'always' }, - ui: { - sdVaeOverride: true, - colorCompensation: false, - vaePrecision: true, - seamless: true, - cfgRescale: true, - clipSkipMax: 12, - }, - }, - 'sd-2': { - dimensions: { grid: 8, optimalSide: 512 }, - defaults: { steps: 30, cfgScale: 7, scheduler: 'euler_a' }, - schedulerSet: 'standard', - schedulerAppliesToGraph: true, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'always' }, - ui: { - sdVaeOverride: true, - colorCompensation: false, - vaePrecision: true, - seamless: true, - cfgRescale: true, - clipSkipMax: 24, - }, - }, - sdxl: { - dimensions: { grid: 8, optimalSide: 1024 }, - defaults: { steps: 30, cfgScale: 7, scheduler: 'euler_a' }, - schedulerSet: 'standard', - schedulerAppliesToGraph: true, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'always' }, - ui: { sdVaeOverride: true, colorCompensation: true, vaePrecision: true, seamless: true, cfgRescale: false }, - }, - 'sd-3': { - dimensions: { grid: 16, optimalSide: 1024 }, - defaults: { steps: 30, cfgScale: 7, scheduler: 'euler_a' }, - schedulerSet: 'standard', - schedulerAppliesToGraph: false, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'always' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - flux: { - dimensions: { grid: 16, optimalSide: 1024 }, - defaults: { steps: 4, cfgScale: 4, scheduler: 'euler' }, - schedulerSet: 'flow', - schedulerAppliesToGraph: true, - guidanceLabel: 'Guidance', - negativePrompt: { visible: false, usage: 'never' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - flux2: { - dimensions: { grid: 16, optimalSide: 1024 }, - defaults: { steps: 4, cfgScale: 1, scheduler: 'euler' }, - schedulerSet: 'flow', - schedulerAppliesToGraph: true, - guidanceLabel: 'Guidance', - negativePrompt: { visible: false, usage: 'never' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - cogview4: { - dimensions: { grid: 32, optimalSide: 1024 }, - defaults: { steps: 30, cfgScale: 7, scheduler: 'euler_a' }, - schedulerSet: 'standard', - schedulerAppliesToGraph: false, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'always' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - 'ernie-image': { - // ernie_image_denoise carries multiple_of=16 on width/height. - dimensions: { grid: 16, optimalSide: 1024 }, - // The base model's numbers. ERNIE-Image-Turbo wants 8 steps at guidance 1.0, which arrives - // through the model's own default_settings rather than a second entry here: Turbo and the - // base model share an architecture and a config, so no variant discriminates them. - defaults: { steps: 50, cfgScale: 4, scheduler: 'euler' }, - // ERNIE_IMAGE_SCHEDULER_MAP is euler/heun/lcm, and the denoise node takes the choice. - schedulerSet: 'flow', - schedulerAppliesToGraph: true, - guidanceLabel: 'CFG', - // negative_conditioning is 'required when guidance_scale != 1.0'. - negativePrompt: { visible: true, usage: 'cfg-gated' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - 'qwen-image': { - dimensions: { grid: 16, optimalSide: 1024 }, - defaults: { steps: 40, cfgScale: 4, scheduler: 'euler_a' }, - schedulerSet: 'standard', - schedulerAppliesToGraph: false, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'cfg-gated' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - 'z-image': { - dimensions: { grid: 16, optimalSide: 1024 }, - defaults: { steps: 8, cfgScale: 1, scheduler: 'euler' }, - schedulerSet: 'flow', - schedulerAppliesToGraph: true, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'cfg-gated' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - 'ideogram-4': { - // Enforced by ideogram4_denoise: width/height carry multipleOf=16. - dimensions: { grid: 16, optimalSide: 1024 }, - // Steps and guidance come from the sampler preset unless explicitly overridden, so the - // shared step/CFG fields are inert here; the preset default is V4_QUALITY_48 (48 steps). - defaults: { steps: 48, cfgScale: 1, scheduler: 'euler' }, - schedulerSet: 'flow', - schedulerAppliesToGraph: false, - guidanceLabel: 'Guidance', - negativePrompt: { visible: false, usage: 'never' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - 'krea-2': { - dimensions: { grid: 16, optimalSide: 1024 }, - // Krea-2-Turbo's numbers. Krea-2-Raw wants ~28 steps at CFG ~4.5, which comes through the - // model's own default_settings rather than being hardcoded per variant here. - defaults: { steps: 8, cfgScale: 1, scheduler: 'euler' }, - schedulerSet: 'flow', - schedulerAppliesToGraph: false, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'cfg-gated' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - wan: { - // Wan's transformer patch-embeds with stride 2 and un-patches by 2; combined with the VAE's - // 8x spatial scale, dimensions must be multiples of 16 or the scheduler step fails on a - // latents-vs-noise spatial mismatch. - dimensions: { grid: 16, optimalSide: 1024 }, - defaults: { steps: 40, cfgScale: 4, scheduler: 'euler' }, - schedulerSet: 'flow', - schedulerAppliesToGraph: false, - guidanceLabel: 'Guidance', - negativePrompt: { visible: true, usage: 'always' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, - anima: { - dimensions: { grid: 8, optimalSide: 1024 }, - defaults: { steps: 30, cfgScale: 4, scheduler: 'euler' }, - schedulerSet: 'anima', - schedulerAppliesToGraph: true, - guidanceLabel: 'CFG', - negativePrompt: { visible: true, usage: 'cfg-gated' }, - ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, - }, -} as const satisfies Partial>; - -export type SupportedGenerateBase = keyof typeof BASE_GENERATION; -export const SUPPORTED_GENERATE_BASES = Object.keys(BASE_GENERATION) as SupportedGenerateBase[]; +export { isSupportedGenerateBase, SUPPORTED_GENERATE_BASES, type SupportedGenerateBase }; export interface GenerationModelPolicy { isSupported: boolean; @@ -391,17 +225,34 @@ const FALLBACK_GENERATION_CONFIG: BaseGenerationConfig = { ui: { sdVaeOverride: false, colorCompensation: false, vaePrecision: false, seamless: false, cfgRescale: false }, }; -// Fallbacks keep UI selectors crash-safe; isSupportedGenerateModel() still blocks invocation. +/** + * What the backend says about this model's architecture. + * + * The variant matters: FLUX Schnell wants 4 steps where dev wants 28, and the endpoint answers per + * variant where they differ. Falling back keeps UI selectors crash-safe for external generators and + * for an architecture the backend does not know; `isSupportedGenerateModel` still blocks invocation, + * and `resolveGenerateWidgetValues` refuses to resolve at all until the table has arrived. + */ const getBaseGenerationConfig = ( - model: Pick | undefined + model: (Pick & { variant?: unknown }) | undefined ): BaseGenerationConfig => { if (!model || model.type === 'external_image_generator') { return FALLBACK_GENERATION_CONFIG; } - return (BASE_GENERATION as Partial>)[model.base] ?? FALLBACK_GENERATION_CONFIG; + return getArchitectureGenerationConfig(model.base, model.variant) ?? FALLBACK_GENERATION_CONFIG; }; +/** + * The pixel grid generation dimensions must land on, or `null` if the backend has no row for this + * architecture. + * + * `null` rather than a default so callers keep owning their own "nothing selected" behaviour -- + * the canvas has a different sensible answer there than a generation graph does. + */ +export const getDimensionGridForBase = (base: string): number | null => + getArchitectureFeatures(base)?.dimension_grid ?? null; + const getNumber = (value: number | null | undefined, fallback: number): number => Number.isFinite(value) && value !== null && value !== undefined ? value : fallback; @@ -579,7 +430,7 @@ export const getGenerationUiPolicy = ( export const isSupportedGenerateModel = ( model: T ): model is T & GenerateModelConfig => - (model.type === 'main' && model.base in BASE_GENERATION) || + (model.type === 'main' && isSupportedGenerateBase(model.base)) || (model.type === 'external_image_generator' && model.base === 'external'); export const isGenerateModelSelectable = (model: T): boolean => isSupportedGenerateModel(model); @@ -1349,11 +1200,18 @@ export const isReferenceImageSupported = (model: GenerateModelConfig | undefined return model.capabilities?.supports_reference_images === true; } - if (model.base === 'qwen-image') { - return model.variant === 'edit'; + const features = getArchitectureFeatures(model.base); + + if (!features || features.max_reference_images <= 0) { + return false; } - return ['flux', 'flux2', 'sd-1', 'sdxl'].includes(model.base); + // Qwen-Image accepts reference images only as the `edit` variant -- the one feature the backend + // qualifies by variant, which is why it says so on the architecture row rather than inventing a + // variant row for it. + return ( + features.reference_images_require_variant === null || model.variant === features.reference_images_require_variant + ); }; export const getMaxReferenceImages = (model: GenerateModelConfig | undefined): number => { @@ -1365,7 +1223,7 @@ export const getMaxReferenceImages = (model: GenerateModelConfig | undefined): n return Math.max(0, model.capabilities.max_reference_images); } - return DEFAULT_REFERENCE_IMAGE_LIMIT; + return getArchitectureFeatures(model.base)?.max_reference_images ?? DEFAULT_REFERENCE_IMAGE_LIMIT; }; export const createReferenceImageId = (): string => diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts index 44b2289571f..fe1e6fad219 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts @@ -13,6 +13,7 @@ import { getControlLayerRejectionReason, isControlKindSupportedForBase, } from './addControlLayers'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; // --------------------------------------------------------------------------- // Test helpers @@ -69,6 +70,8 @@ const edgesTo = (graph: TestGraph, nodeId: string, field: string) => // 1. isControlKindSupportedForBase full matrix // --------------------------------------------------------------------------- +useArchitectureCapabilitiesFixture(); + describe('isControlKindSupportedForBase', () => { it('controlnet is supported on sd-1, sdxl, flux only', () => { expect(isControlKindSupportedForBase('sd-1', 'controlnet')).toBe(true); diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts index feaa093a2a4..1b1bd25fa3e 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts @@ -7,6 +7,7 @@ import { getRegionalGuidanceRejectionReason, isRegionalGuidanceSupportedForBase, } from './addRegionalGuidance'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; interface TestGraph { id: string; @@ -140,6 +141,8 @@ const hasEdge = (graph: TestGraph, s: string, sf: string, d: string, df: string) (e) => e.source.node_id === s && e.source.field === sf && e.destination.node_id === d && e.destination.field === df ); +useArchitectureCapabilitiesFixture(); + describe('isRegionalGuidanceSupportedForBase', () => { it('supports sd-1 / sdxl / flux / flux2 / krea-2 and nothing else', () => { expect(isRegionalGuidanceSupportedForBase('sd-1')).toBe(true); diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts index 65b0bf460c5..6a9bdd93553 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts @@ -1,3 +1,5 @@ +import { getArchitectureFeatures } from '@features/generation/core/architectureCapabilities'; + import type { BackendGraphContract, BackendInvocationContract } from '@features/generation/core/contracts'; import { addEdge, addNode } from '@features/generation/core/graphBuilder'; @@ -13,12 +15,26 @@ const NEG_COND_COLLECT_ID = 'neg_cond_collect'; /** The base models regional guidance supports. */ export type RegionalGuidanceBase = 'sd-1' | 'sdxl' | 'flux' | 'flux2' | 'krea-2'; -/** True when `base` supports regional guidance at all. */ +/** + * True when `base` supports regional guidance at all. + * + * The narrowing to `RegionalGuidanceBase` stays: everything below this point dispatches on the + * literal to pick node types and field names, and that is graph knowledge the backend has no say + * in. Only the *answer* comes from the capability table now. + */ export const isRegionalGuidanceSupportedForBase = (base: string): base is RegionalGuidanceBase => - base === 'sd-1' || base === 'sdxl' || base === 'flux' || base === 'flux2' || base === 'krea-2'; + getArchitectureFeatures(base)?.supports_regional_guidance ?? false; + +/** + * Whether a base supports regional NEGATIVE prompts / autoNegative. + * + * Exported because the layer settings UI needs the same answer: it used to ask "is this the FLUX + * family?" and got krea-2 wrong, rendering a negative-prompt field the graph builder discards. + */ +export const isRegionalNegativeSupportedForBase = (base: string): boolean => + getArchitectureFeatures(base)?.regional_negative ?? false; -/** Whether a base supports regional NEGATIVE prompts / autoNegative (SD family only). */ -const supportsRegionalNegative = (base: RegionalGuidanceBase): boolean => base === 'sd-1' || base === 'sdxl'; +const supportsRegionalNegative = (base: RegionalGuidanceBase): boolean => isRegionalNegativeSupportedForBase(base); /** A resolved reference-image (component) model identifier — the backend model field shape. */ export interface RegionalReferenceModel { diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts index 04ae26fe130..8012758a681 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts @@ -16,6 +16,7 @@ import type { CanvasCompileMode, CanvasCompositingSettings, Rect } from './types import { compileCanvasGraph } from './compileCanvasGraph'; import { DEFAULT_CANVAS_COMPOSITING } from './types'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; const sd2Model: MainModelConfig = { base: 'sd-2', key: 'sd2-model', name: 'SD 2', type: 'main' }; @@ -250,6 +251,8 @@ const BASE_CASES: BaseCase[] = [ }, ]; +useArchitectureCapabilitiesFixture(); + describe('compileCanvasGraph', () => { describe('txt2img per base', () => { it.each(BASE_CASES)( diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts index f6ed3c8e6ff..18ba7425d5a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { getControlValidationReason } from './controlValidation'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const valid = { adapterModel: { base: 'sd-1', type: 'controlnet' }, @@ -12,6 +13,8 @@ const valid = { weight: 0.75, }; +useArchitectureCapabilitiesFixture(); + describe('getControlValidationReason', () => { it('returns stable reason codes for the validation matrix', () => { expect(getControlValidationReason(valid)).toBeNull(); diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.ts index 203379d2413..cdad06de764 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.ts @@ -1,3 +1,5 @@ +import { getArchitectureFeatures } from '@features/generation/core/architectureCapabilities'; + export type ControlAdapterKind = 'controlnet' | 't2i_adapter' | 'control_lora' | 'z_image_control'; export type ControlValidationReason = @@ -10,18 +12,15 @@ export type ControlValidationReason = | 'z_image_control_limit' | 'flux_fill_control_lora'; -export const isControlKindSupportedForBase = (base: string, kind: ControlAdapterKind): boolean => { - if (kind === 'controlnet') { - return base === 'sd-1' || base === 'sdxl' || base === 'flux'; - } - if (kind === 't2i_adapter') { - return base === 'sd-1' || base === 'sdxl'; - } - if (kind === 'z_image_control') { - return base === 'z-image'; - } - return base === 'flux'; -}; +/** + * Which control adapters an architecture accepts, as the backend declares it. + * + * Only the base-to-kinds mapping comes from there. The limit rules further down -- one control + * LoRA, one Z-Image control, and FLUX Fill rejecting control LoRAs entirely -- have no column in + * the capability table and stay here. + */ +export const isControlKindSupportedForBase = (base: string, kind: ControlAdapterKind): boolean => + getArchitectureFeatures(base)?.control_kinds.includes(kind) ?? false; export const getControlValidationReason = (params: { adapterModel: { base: string; type: string } | null; diff --git a/invokeai/frontend/webv2/src/features/generation/core/generationConfig.ts b/invokeai/frontend/webv2/src/features/generation/core/generationConfig.ts new file mode 100644 index 00000000000..c1d92e02c4b --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/generationConfig.ts @@ -0,0 +1,46 @@ +/** + * The shape generation policy speaks about an architecture. + * + * Split out of `baseGenerationPolicies.ts` so that `architectureCapabilities.ts` -- which maps the + * backend's wire rows onto this shape -- and the policy accessors that consume it can both refer to + * it without importing each other. + */ + +export type SchedulerSetId = 'standard' | 'flow' | 'flow-no-lcm' | 'anima'; + +export type NegativePromptUsage = 'always' | 'cfg-gated' | 'never'; + +export type GuidanceLabel = 'CFG' | 'Guidance'; + +export interface BaseGenerationConfig { + dimensions: { + grid: number; + /** + * The side of the model's optimal square canvas. Consumers square it back into an area + * (`importGalleryImages` passes `optimal ** 2` to `calculateNewSize`), so it is derived from + * the declared area rather than from width alone. + */ + optimalSide: number; + }; + defaults: { + steps: number; + /** Whatever the single guidance slider shows -- CFG or distilled guidance, per `guidanceLabel`. */ + cfgScale: number; + scheduler: string; + }; + schedulerSet: SchedulerSetId; + schedulerAppliesToGraph: boolean; + guidanceLabel: GuidanceLabel; + negativePrompt: { + visible: boolean; + usage: NegativePromptUsage; + }; + ui: { + sdVaeOverride: boolean; + colorCompensation: boolean; + vaePrecision: boolean; + seamless: boolean; + cfgRescale: boolean; + clipSkipMax?: number; + }; +} diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts index 24294629902..511460b0008 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts @@ -11,6 +11,7 @@ import type { import { getDefaultGenerateSettings, isSupportedGenerateModel } from './baseGenerationPolicies'; import { compileGenerateGraph, generateSeedSequence, resolveGenerateSeed } from './graph'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; const sd2Model: MainModelConfig = { base: 'sd-2', key: 'sd2-model', name: 'SD 2', type: 'main' }; @@ -158,6 +159,8 @@ afterEach(() => { vi.restoreAllMocks(); }); +useArchitectureCapabilitiesFixture(); + describe('compileGenerateGraph', () => { it('recognizes the legacy-supported generate model families', () => { expect( diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts index 9357826fda9..3892fa78a5c 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -2,7 +2,7 @@ * Every supported base compiles a graph — the systematic counterpart to `graph.test.ts`. * * `graph.test.ts` asserts *what* individual families wire up, one hand-written case at a time. That - * leaves a base added to `BASE_GENERATION` and `GRAPH_BUILDERS` but never given a case silently + * leaves a base added to `SUPPORTED_GENERATE_BASES` and `GRAPH_BUILDERS` but never given a case silently * untested. This file instead iterates `SUPPORTED_GENERATE_BASES`, so a new architecture is covered * the moment it is registered, and asserts the properties that hold for *all* of them: the * component policy is satisfiable, the builder runs, and the resulting graph is structurally sound. @@ -37,6 +37,7 @@ import { SUPPORTED_GENERATE_BASES, } from './baseGenerationPolicies'; import { compileGenerateGraph, GRAPH_BUILDERS } from './graph'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; /** * Main-model shapes to compile per base. @@ -204,6 +205,8 @@ const cases = SUPPORTED_GENERATE_BASES.flatMap((base) => shapesForBase(base).map((shape) => ({ base, label: `${base} / ${shape.label}`, shape })) ); +useArchitectureCapabilitiesFixture(); + describe('generate graph coverage', () => { it('has a builder for every supported base and no builder for anything else', () => { expect(Object.keys(GRAPH_BUILDERS).sort()).toEqual([...SUPPORTED_GENERATE_BASES].sort()); diff --git a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts index 651e3227375..15b3158595d 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts @@ -1,4 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import capabilitiesFixture from './__fixtures__/architectureCapabilities.json'; +import { + type ArchitectureCapabilitiesRow, + resetArchitectureCapabilities, + setArchitectureCapabilities, +} from './architectureCapabilities'; + import type { GenerationModelCatalogItem } from './contracts'; import type { GenerateLora, GenerateWidgetValues, LoraModelConfig, MainModelConfig } from './types'; @@ -34,6 +42,14 @@ const storedValues = createGenerateValues({ width: 1024, }); +// The resolver fails closed without the backend's architecture table, so seed the registry with the +// same fixture the backend pins. Reset afterwards so registry state cannot leak between files. +beforeEach(() => { + setArchitectureCapabilities(capabilitiesFixture as ArchitectureCapabilitiesRow[]); +}); + +afterEach(resetArchitectureCapabilities); + describe('compileGeneratePreviewGraph', () => { it('compiles a ready graph with deterministic node ids across repeated compiles', () => { const input = { destination: 'gallery' as const, models, storedValues, useCpuNoise: false }; diff --git a/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts b/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts index 26c1942b505..622ac01cbae 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import type { MainModelConfig } from './types'; import { getDefaultGenerateSettings, getPromptHistoryRecallPatch } from './baseGenerationPolicies'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const createModel = (base: string): MainModelConfig => ({ base, @@ -11,6 +12,8 @@ const createModel = (base: string): MainModelConfig => ({ type: 'main', }); +useArchitectureCapabilitiesFixture(); + describe('getPromptHistoryRecallPatch', () => { it('clears an absent negative prompt when the selected model exposes it', () => { const model = createModel('sdxl'); diff --git a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts index 83f13ed44ba..010e67103aa 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts @@ -1,4 +1,12 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import capabilitiesFixture from './__fixtures__/architectureCapabilities.json'; +import { + type ArchitectureCapabilitiesRow, + resetArchitectureCapabilities, + setArchitectureCapabilities, +} from './architectureCapabilities'; + import type { GenerateWidgetValues, MainModelConfig } from './types'; @@ -26,6 +34,14 @@ const applySystemPatch = ( systemPatch: Partial ): Record => ({ ...storedValues, ...systemPatch }); +// The resolver fails closed without the backend's architecture table, so seed the registry with the +// same fixture the backend pins. Reset afterwards so registry state cannot leak between files. +beforeEach(() => { + setArchitectureCapabilities(capabilitiesFixture as ArchitectureCapabilitiesRow[]); +}); + +afterEach(resetArchitectureCapabilities); + describe('resolveGenerateWidgetValues', () => { it('returns null when the catalog has no supported generation model', () => { expect( @@ -188,3 +204,16 @@ describe('resolveGenerateWidgetValues', () => { expect(second?.systemPatch).toBeNull(); }); }); + +describe('without the backend capability table', () => { + it('resolves nothing rather than falling back to generic defaults', () => { + // Its `systemPatch` is persisted into the project, so a fallback grid or step count would be + // written to disk. Returning null is already how "no usable models" is signalled, and every + // caller handles it. + resetArchitectureCapabilities(); + + const model: MainModelConfig = { base: 'sdxl', key: 'model', name: 'model', type: 'main' }; + + expect(resolveGenerateWidgetValues({ models: [model], storedValues: {} })).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts index fc8bd06ae46..772b119f998 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts @@ -1,3 +1,5 @@ +import { hasArchitectureCapabilities } from '@features/generation/core/architectureCapabilities'; + import { areJsonValuesStructurallyEqual } from '@platform/core/json'; import type { GenerationModelCatalogItem } from './contracts'; @@ -68,6 +70,13 @@ export const resolveGenerateWidgetValues = ({ promptTemplates, storedValues, }: ResolveGenerateWidgetValuesInput): ResolvedGenerateWidgetValues | null => { + // Fail closed until the backend's architecture table has arrived. Resolving without it would + // fall back to generic defaults -- and this resolver's `systemPatch` is *persisted* into the + // project, so a fallback grid or step count would be written to disk rather than merely shown. + if (!hasArchitectureCapabilities()) { + return null; + } + const supportedModels = models.filter(isSupportedGenerateModel); if (supportedModels.length === 0) { diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts index 6b79407de2c..9a872ae6790 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts @@ -24,6 +24,7 @@ import { syncGenerateWidgetValuesWithModels, syncGenerateLorasWithModels, } from './settings'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; /** The persisted widget-value shape from before aspect ratio / VAE / seamless / CLIP skip landed. */ const legacyStoredValues = { @@ -41,6 +42,8 @@ const legacyStoredValues = { width: 512, }; +useArchitectureCapabilitiesFixture(); + describe('normalizeGenerateSettings', () => { it('enforces template view mode only when a valid template remains', () => { const validTemplate = { diff --git a/invokeai/frontend/webv2/src/features/generation/core/supportedBases.test.ts b/invokeai/frontend/webv2/src/features/generation/core/supportedBases.test.ts new file mode 100644 index 00000000000..aea901e5530 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/supportedBases.test.ts @@ -0,0 +1,46 @@ +/** + * The list of architectures this build can generate images with. + * + * It used to be `Object.keys(BASE_GENERATION)`, so this assertion doubled as a check on that + * table. With the policy data moving to the backend, the list stands on its own: it is the + * frontend's half of the contract -- the architectures we have a graph builder for. The pin stays + * because adding one must be a deliberate act that a reviewer sees. + */ + +import { describe, expect, it } from 'vitest'; + +import { isSupportedGenerateBase, SUPPORTED_GENERATE_BASES } from './supportedBases'; + +describe('SUPPORTED_GENERATE_BASES', () => { + it('is exactly the bases with a graph builder, in order', () => { + expect(SUPPORTED_GENERATE_BASES).toEqual([ + 'sd-1', + 'sd-2', + 'sdxl', + 'sd-3', + 'flux', + 'flux2', + 'cogview4', + 'ernie-image', + 'qwen-image', + 'z-image', + 'ideogram-4', + 'krea-2', + 'wan', + 'anima', + ]); + }); + + it('excludes architectures the backend serves but this build cannot generate with', () => { + // Both have capability rows on `/api/v2/models/capabilities`. Neither has an image graph + // builder: the refiner is a second pass over an SDXL latent, and MiniMax H3 is video-only. + expect(isSupportedGenerateBase('sdxl-refiner')).toBe(false); + expect(isSupportedGenerateBase('minimax-h3')).toBe(false); + }); + + it('rejects unknown and non-architecture values', () => { + expect(isSupportedGenerateBase('unknown')).toBe(false); + expect(isSupportedGenerateBase('made-up')).toBe(false); + expect(isSupportedGenerateBase('external')).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/supportedBases.ts b/invokeai/frontend/webv2/src/features/generation/core/supportedBases.ts new file mode 100644 index 00000000000..6a46825ac59 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/supportedBases.ts @@ -0,0 +1,38 @@ +/** + * The architectures this build can compile an image-generation graph for. + * + * Frontend-owned on purpose, and the one thing in generation policy that cannot come from the + * server: `GRAPH_BUILDERS` in `graph.ts` must have an entry for each of these, and the `satisfies` + * there is what enforces it. The capabilities endpoint advertises more architectures than this -- + * `sdxl-refiner` is never run on its own, and `minimax-h3` is video-only with no image graph -- so + * "the server knows about it" and "we can generate with it" are different questions. + * + * Previously derived as `keyof typeof BASE_GENERATION`, which tied the set to the key order of a + * table that is about to be served from the backend. Written out here, adding an architecture stays + * a deliberate act with a test that notices it. + */ + +import type { KnownGenerationModelBase } from '@features/generation/core/contracts'; + +export const SUPPORTED_GENERATE_BASES = [ + 'sd-1', + 'sd-2', + 'sdxl', + 'sd-3', + 'flux', + 'flux2', + 'cogview4', + 'ernie-image', + 'qwen-image', + 'z-image', + 'ideogram-4', + 'krea-2', + 'wan', + 'anima', +] as const satisfies readonly KnownGenerationModelBase[]; + +export type SupportedGenerateBase = (typeof SUPPORTED_GENERATE_BASES)[number]; + +const SUPPORTED = new Set(SUPPORTED_GENERATE_BASES); + +export const isSupportedGenerateBase = (base: string): base is SupportedGenerateBase => SUPPORTED.has(base); diff --git a/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesApi.ts b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesApi.ts new file mode 100644 index 00000000000..b6cdf30bab4 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesApi.ts @@ -0,0 +1,15 @@ +import type { ArchitectureCapabilitiesRow } from '@features/generation/core/architectureCapabilities'; + +import { apiFetchJson } from '@platform/transport/http'; + +/** + * The architecture capability table. + * + * A static list -- the same for every install and every user -- describing what each model + * architecture supports. Lives under the model-manager router because that is where architectures + * are addressed, but it is not model-record data: nothing here depends on what is installed. + */ +const CAPABILITIES_PATH = '/api/v2/models/capabilities'; + +export const getArchitectureCapabilities = (signal?: AbortSignal): Promise => + apiFetchJson(CAPABILITIES_PATH, { signal }); diff --git a/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts new file mode 100644 index 00000000000..995698af200 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import fixture from '@features/generation/core/__fixtures__/architectureCapabilities.json'; + +const api = vi.hoisted(() => ({ getArchitectureCapabilities: vi.fn() })); + +vi.mock('./architectureCapabilitiesApi', () => api); + +const rows = fixture as unknown[]; + +describe('architecture capabilities store', () => { + beforeEach(() => { + vi.resetModules(); + api.getArchitectureCapabilities.mockReset(); + }); + + it('fills the core registry rather than the snapshot', async () => { + // The rows have exactly one home: policy accessors read them synchronously from all over the + // app, including graph builders at enqueue time. + api.getArchitectureCapabilities.mockResolvedValue(rows); + const store = await import('./architectureCapabilitiesStore'); + const registry = await import('@features/generation/core/architectureCapabilities'); + + expect(registry.hasArchitectureCapabilities()).toBe(false); + await store.refreshArchitectureCapabilities(); + + expect(store.getArchitectureCapabilitiesSnapshot()).toEqual({ error: null, status: 'loaded' }); + expect(registry.getArchitectureGenerationConfig('sd-1')?.defaults.steps).toBe(30); + }); + + it('dedupes concurrent refreshes', async () => { + api.getArchitectureCapabilities.mockResolvedValue(rows); + const store = await import('./architectureCapabilitiesStore'); + + const first = store.refreshArchitectureCapabilities(); + expect(store.refreshArchitectureCapabilities()).toBe(first); + await first; + + expect(store.getArchitectureCapabilitiesSnapshot().status).toBe('loaded'); + }); + + it('reports a failure and leaves the registry empty', async () => { + api.getArchitectureCapabilities.mockRejectedValue(new Error('outage')); + const store = await import('./architectureCapabilitiesStore'); + const registry = await import('@features/generation/core/architectureCapabilities'); + + await store.refreshArchitectureCapabilities(); + + expect(store.getArchitectureCapabilitiesSnapshot()).toEqual({ error: 'outage', status: 'error' }); + expect(registry.hasArchitectureCapabilities()).toBe(false); + }); + + it('retries after an error but not after success', async () => { + api.getArchitectureCapabilities.mockRejectedValueOnce(new Error('outage')).mockResolvedValue(rows); + const store = await import('./architectureCapabilitiesStore'); + + store.ensureArchitectureCapabilitiesLoaded(); + await Promise.resolve(); + await Promise.resolve(); + expect(store.getArchitectureCapabilitiesSnapshot().status).toBe('error'); + + // One failed load must not stick... + await store.refreshArchitectureCapabilities(); + expect(store.getArchitectureCapabilitiesSnapshot().status).toBe('loaded'); + + // ...and a loaded table is never refetched: it is static per backend build. + store.ensureArchitectureCapabilitiesLoaded(); + expect(api.getArchitectureCapabilities).toHaveBeenCalledTimes(2); + }); + + it('clears the registry as well as the snapshot when the account changes', async () => { + // The addition to the starters-store template: the rows live outside this store, so clearing + // only the snapshot would leave the previous account's table readable behind an idle status. + api.getArchitectureCapabilities.mockResolvedValue(rows); + const lifecycle = await import('@platform/state/accountLifecycle'); + const store = await import('./architectureCapabilitiesStore'); + const registry = await import('@features/generation/core/architectureCapabilities'); + + await store.refreshArchitectureCapabilities(); + expect(registry.hasArchitectureCapabilities()).toBe(true); + + lifecycle.accountLifecycle.invalidate(); + + expect(store.getArchitectureCapabilitiesSnapshot()).toEqual({ error: null, status: 'idle' }); + expect(registry.hasArchitectureCapabilities()).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.ts b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.ts new file mode 100644 index 00000000000..46a52be3387 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.ts @@ -0,0 +1,87 @@ +import { + resetArchitectureCapabilities, + setArchitectureCapabilities, +} from '@features/generation/core/architectureCapabilities'; +import { + captureAccountScope, + isAccountScopeCurrent, + registerAccountOwnedResource, +} from '@platform/state/accountLifecycle'; +import { createExternalStore } from '@platform/state/externalStore'; +import { createTrailingSingleFlight } from '@platform/state/singleFlight'; +import { getApiErrorMessage } from '@platform/transport/http'; + +import { getArchitectureCapabilities } from './architectureCapabilitiesApi'; + +/** + * Load state for the architecture capability table. + * + * The rows themselves live in the core registry, not in this snapshot: generation policy is read + * from synchronous accessors all over the app, including from graph builders at enqueue time, so + * there must be exactly one place holding the table. This store owns only *whether* it is there. + * + * Fetched once. The table is static per backend build -- it is derived from + * `invokeai/backend/architectures/defs/`, not from installed models -- so nothing revalidates it. + */ + +export interface ArchitectureCapabilitiesSnapshot { + status: 'idle' | 'loading' | 'loaded' | 'error'; + error: string | null; +} + +const EMPTY_SNAPSHOT: ArchitectureCapabilitiesSnapshot = { error: null, status: 'idle' }; +const store = createExternalStore(EMPTY_SNAPSHOT); + +const refreshFlight = createTrailingSingleFlight(); + +registerAccountOwnedResource({ + clear: () => { + refreshFlight.reset(); + store.setSnapshot(EMPTY_SNAPSHOT); + // The registry is module state outside this store, so clearing the snapshot alone would leave + // the previous account's table readable behind a status that says nothing is loaded. + resetArchitectureCapabilities(); + }, + name: 'architecture-capabilities', +}); + +export const refreshArchitectureCapabilities = (): Promise => + refreshFlight.run(() => { + const owner = captureAccountScope(); + store.patchSnapshot({ status: store.getSnapshot().status === 'loaded' ? 'loaded' : 'loading' }); + + return getArchitectureCapabilities(owner.signal) + .then((rows) => { + if (!isAccountScopeCurrent(owner)) { + return; + } + + setArchitectureCapabilities(rows); + store.patchSnapshot({ error: null, status: 'loaded' }); + }) + .catch((error: unknown) => { + if (!isAccountScopeCurrent(owner)) { + return; + } + + store.patchSnapshot({ + error: getApiErrorMessage(error, 'Failed to load model capabilities.'), + status: store.getSnapshot().status === 'loaded' ? 'loaded' : 'error', + }); + }); + }); + +/** Fetch on first use or retry after an error, so one failed load never sticks. */ +export const ensureArchitectureCapabilitiesLoaded = (): void => { + const { status } = store.getSnapshot(); + + if (status === 'idle' || status === 'error') { + void refreshArchitectureCapabilities(); + } +}; + +export const getArchitectureCapabilitiesSnapshot = (): ArchitectureCapabilitiesSnapshot => store.getSnapshot(); + +export const subscribeArchitectureCapabilities = store.subscribe; + +export const useArchitectureCapabilitiesSelector = store.useSelector; diff --git a/invokeai/frontend/webv2/src/features/generation/graph.ts b/invokeai/frontend/webv2/src/features/generation/graph.ts index b83aab7726d..f60ff01bdea 100644 --- a/invokeai/frontend/webv2/src/features/generation/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/graph.ts @@ -29,6 +29,7 @@ export { getControlLayerRejectionReason, getControlValidationReasonMessage } fro export { getRegionalGuidanceRejectionReason, isRegionalGuidanceSupportedForBase, + isRegionalNegativeSupportedForBase, type RegionalGuidanceInput, type RegionalReferenceImageInput, } from './core/canvas/addRegionalGuidance'; diff --git a/invokeai/frontend/webv2/src/features/generation/runtime.test.ts b/invokeai/frontend/webv2/src/features/generation/runtime.test.ts index 55ac91f82df..39accece847 100644 --- a/invokeai/frontend/webv2/src/features/generation/runtime.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/runtime.test.ts @@ -2,9 +2,15 @@ import type { GenerationModelCatalogItem, GenerateWidgetValues, MainModelConfig import type { PromptTemplateRecord } from '@features/generation/data/promptTemplates'; import type * as GenerationQueries from '@features/generation/queries'; +import capabilitiesFixture from '@features/generation/core/__fixtures__/architectureCapabilities.json'; +import { + type ArchitectureCapabilitiesRow, + resetArchitectureCapabilities, + setArchitectureCapabilities, +} from '@features/generation/core/architectureCapabilities'; import { getDefaultGenerateSettings } from '@features/generation/settings'; import { QueryClient } from '@tanstack/react-query'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const templateQuery = vi.hoisted(() => ({ queryFn: vi.fn<() => Promise>(), @@ -66,14 +72,17 @@ const createReadableStore = (initialSnapshot: Snapshot) => { }; const setup = ({ + capabilitiesLoaded: initialCapabilitiesLoaded = true, models: initialModels, project: initialProject, }: { + capabilitiesLoaded?: boolean; models: readonly GenerationModelCatalogItem[]; project: GenerateWidgetSyncProjectSnapshot; }) => { const models = createReadableStore(initialModels); const project = createReadableStore(initialProject); + const capabilitiesLoaded = createReadableStore(initialCapabilitiesLoaded); const patches: Array<{ origin: 'system'; projectId: string; @@ -81,6 +90,7 @@ const setup = ({ }> = []; const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const deps: GenerateWidgetSyncRuntimeDeps = { + capabilitiesLoaded, models, patchValues: (values, projectId, origin) => { patches.push({ origin, projectId, values }); @@ -95,7 +105,7 @@ const setup = ({ }; const runtime = createGenerateWidgetSyncRuntime(deps); - return { models, patches, project, queryClient, runtime }; + return { capabilitiesLoaded, models, patches, project, queryClient, runtime }; }; beforeEach(() => { @@ -103,6 +113,14 @@ beforeEach(() => { templateQuery.queryFn.mockResolvedValue([]); }); +// The resolver fails closed without the backend's architecture table, so seed the registry with the +// same fixture the backend pins. Reset afterwards so registry state cannot leak between files. +beforeEach(() => { + setArchitectureCapabilities(capabilitiesFixture as ArchitectureCapabilitiesRow[]); +}); + +afterEach(resetArchitectureCapabilities); + describe('createGenerateWidgetSyncRuntime', () => { it('reconciles the newly active project after a project switch', () => { const model = createModel('model'); @@ -239,3 +257,29 @@ describe('createGenerateWidgetSyncRuntime', () => { expect(patches).toHaveLength(0); }); }); + +describe('the architecture-capabilities gate', () => { + // reconcile() writes its result into the project via patchValues, so it must not run on fallback + // policy: the values are persisted, not merely displayed. This is why the gate lives here and + // not only in the widget -- the runtime reconciles at construction, before anything renders. + it('writes nothing until the capability table has arrived', () => { + const { capabilitiesLoaded, patches } = setup({ + capabilitiesLoaded: false, + models: [createModel('model')], + project: { id: 'project-1', values: {} }, + }); + + expect(patches).toEqual([]); + + capabilitiesLoaded.setSnapshot(true); + + expect(patches.length).toBeGreaterThan(0); + expect(patches[0]?.projectId).toBe('project-1'); + }); + + it('reconciles at construction when the table is already there', () => { + const { patches } = setup({ models: [createModel('model')], project: { id: 'project-1', values: {} } }); + + expect(patches.length).toBeGreaterThan(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/runtime.ts b/invokeai/frontend/webv2/src/features/generation/runtime.ts index 53f6aaefc9b..25afb42e2cd 100644 --- a/invokeai/frontend/webv2/src/features/generation/runtime.ts +++ b/invokeai/frontend/webv2/src/features/generation/runtime.ts @@ -16,6 +16,14 @@ export interface GenerateWidgetSyncProjectSnapshot { } export interface GenerateWidgetSyncRuntimeDeps { + /** + * Whether the backend's architecture capability table has arrived. + * + * `reconcile` writes its result into the project, so it must not run on fallback policy: the + * values would be persisted, not just displayed. Subscribed like the other read models so the + * first reconcile happens as soon as the table lands. + */ + capabilitiesLoaded: ReadonlyStore; models: ReadonlyStore; patchValues(values: Partial, projectId: string, origin: 'system'): void; project: ReadonlyStore; @@ -42,7 +50,7 @@ export const createGenerateWidgetSyncRuntime = (deps: GenerateWidgetSyncRuntimeD let isPromptTemplateQueryEnabled = false; const reconcile = (): void => { - if (isDisposed || isReconciling) { + if (isDisposed || isReconciling || !deps.capabilitiesLoaded.getSnapshot()) { return; } @@ -78,6 +86,7 @@ export const createGenerateWidgetSyncRuntime = (deps: GenerateWidgetSyncRuntimeD const unsubscribeProject = deps.project.subscribe(reconcile); const unsubscribeModels = deps.models.subscribe(reconcile); + const unsubscribeCapabilities = deps.capabilitiesLoaded.subscribe(reconcile); const unsubscribePromptTemplates = promptTemplateObserver.subscribe(reconcile); reconcile(); @@ -90,9 +99,17 @@ export const createGenerateWidgetSyncRuntime = (deps: GenerateWidgetSyncRuntimeD isDisposed = true; unsubscribePromptTemplates(); + unsubscribeCapabilities(); unsubscribeModels(); unsubscribeProject(); promptTemplateObserver.destroy(); }, }; }; + +export { + ensureArchitectureCapabilitiesLoaded, + getArchitectureCapabilitiesSnapshot, + subscribeArchitectureCapabilities, + type ArchitectureCapabilitiesSnapshot, +} from './data/architectureCapabilitiesStore'; diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index ac78d1f31dd..d9bd1caa50d 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -41,6 +41,7 @@ export { getDefaultGenerateSettings, getDefaultReferenceImageConfig, getGenerateModelSelectionResult, + getDimensionGridForBase, getGenerationDimensions, getGenerationModelAvailabilityReasons, getGenerationUiPolicy, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx index ce4cbc6e390..eaefc6b3ce8 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx @@ -1,17 +1,27 @@ import type { GenerationModelCatalogItem as ModelConfig } from '@features/generation/contracts'; import type { GenerateModelConfig, GenerateSettings, LoraModelConfig } from '@features/generation/core/types'; +import { Stack, Text } from '@chakra-ui/react'; import { getDefaultGenerateSettings, isSupportedGenerateModel } from '@features/generation/core/baseGenerationPolicies'; +import { + ensureArchitectureCapabilitiesLoaded, + useArchitectureCapabilitiesSelector, +} from '@features/generation/data/architectureCapabilitiesStore'; import { isLoraModelConfig, normalizeGenerateSettings } from '@features/generation/core/settings'; import { resolveGenerateWidgetValues } from '@features/generation/settings'; +import { Button } from '@platform/ui/Button'; import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { getGenerateFormCommitPatch } from './generateFormViewModel'; import { GenerateSettingsForm } from './GenerateSettingsForm'; import { useGenerationUi } from './GenerationUiContext'; export const GenerateWidgetView = () => { + const { t } = useTranslation(); const ui = useGenerationUi(); + const capabilitiesStatus = useArchitectureCapabilitiesSelector((snapshot) => snapshot.status); + const capabilitiesError = useArchitectureCapabilitiesSelector((snapshot) => snapshot.error); const projectId = ui.project.activeProjectId; const storedValues = ui.project.generateValues; const error = ui.models.error; @@ -55,6 +65,31 @@ export const GenerateWidgetView = () => { [projectId, ui] ); + // Every field below is prefilled from architecture policy and is editable, so rendering the form + // before the backend's table arrives would offer generic fallbacks as if they were the model's + // own -- and a single keystroke would commit them. App boot kicks the fetch, so this is one round + // trip in practice. + if (capabilitiesStatus !== 'loaded') { + return ( + + {capabilitiesStatus === 'error' ? ( + <> + + {capabilitiesError ?? t('widgets.generate.capabilitiesLoadFailed')} + + + + ) : ( + + {t('widgets.generate.loadingCapabilities')} + + )} + + ); + } + return ( WorkbenchState) => ({ getProject: (projectId: string) => getState().projects.find((project) => project.id === projectId) ?? null, @@ -213,6 +214,8 @@ const expectedLayer = ( } }; +useArchitectureCapabilitiesFixture(); + describe('importGalleryImagesToCanvas', () => { it.each>([ 'raster', diff --git a/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts b/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts index e6cd04d0d51..acf4a6b2c6e 100644 --- a/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts +++ b/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts @@ -8,6 +8,7 @@ import { getControlLayerAttentionReason, hasControlLayerContent, } from './controlLayerChecks'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const model = (key: string, base: string, type: string): ModelConfig => ({ base, key, name: key, type }) as ModelConfig; @@ -43,6 +44,8 @@ const controlLayer = ( const sdxl = { base: 'sdxl' }; const models = [model('sdxl-control', 'sdxl', 'controlnet'), model('sd1-control', 'sd-1', 'controlnet')]; +useArchitectureCapabilitiesFixture(); + describe('hasControlLayerContent', () => { it('matches the pipeline content gate', () => { expect(hasControlLayerContent(controlLayer('image'))).toBe(true); diff --git a/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts b/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts index 887cfdf1094..79450f6c20e 100644 --- a/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts +++ b/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts @@ -9,6 +9,7 @@ import { getDefaultGenerateSettings } from '@features/generation/settings'; import { describe, expect, it, vi } from 'vitest'; import { recallProjectPromptHistoryItem, selectProjectGenerateModel } from './generationSettingsOrchestration'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const createModel = (base: string, key = `${base}-model`): GenerateModelConfig => ({ base, @@ -27,6 +28,8 @@ const createSettings = (model: GenerateModelConfig, overrides: Partial { it('atomically stores reconciled model settings and returns cleared labels', () => { const currentModel = createModel('sdxl'); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts index bb2409d68e1..6b892ae8ac0 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts @@ -5,6 +5,7 @@ import { getDefaultGenerateSettings, getMaxReferenceImages } from '@features/gen import { describe, expect, it } from 'vitest'; import { appendReferenceImage, type AppendReferenceImageResult } from './appendReferenceImage'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sd1Model: MainModelConfig = { base: 'sd-1', @@ -25,6 +26,8 @@ const generateValuesFor = (referenceImages: unknown[] = []): Record { it('appends a reference image built from the flat upload result', () => { const result = appendReferenceImage({ generateValues: generateValuesFor(), image, models }); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts index 4e0e831d675..49947789942 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts @@ -15,6 +15,7 @@ const galleryApi = vi.hoisted(() => ({ vi.mock('@features/gallery', () => galleryApi); import { executeImageRecall } from './executeImageRecall'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const model = { base: 'sdxl', @@ -53,6 +54,8 @@ const createCommands = () => { return { add, commands, setSettings }; }; +useArchitectureCapabilitiesFixture(); + describe('executeImageRecall', () => { beforeEach(() => { accountLifecycle.activate('test-account'); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts index bee1b8a2fcc..435fd95e419 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -9,6 +9,7 @@ import type { import { describe, expect, it } from 'vitest'; import { buildImageRecallSettings, getImageRecallCapabilities } from './imageRecall'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sdxlModel: MainModelConfig = { base: 'sdxl', key: 'sdxl-model', name: 'SDXL', type: 'main' }; const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; @@ -133,6 +134,8 @@ const metadata = { width: 513, }; +useArchitectureCapabilitiesFixture(); + describe('image recall', () => { it.each(['all', 'remix', 'prompts'] as const)( 'clears the active template and view mode when %s recalls prompt metadata', diff --git a/invokeai/frontend/webv2/src/workbench/invocation.test.ts b/invokeai/frontend/webv2/src/workbench/invocation.test.ts index 2fba396b8ca..621f150d94a 100644 --- a/invokeai/frontend/webv2/src/workbench/invocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocation.test.ts @@ -30,6 +30,7 @@ import { import { submitResolvedInvocation } from './invocationSubmit'; import { createInitialWorkbenchState, workbenchReducer } from './workbenchState.testing'; import { createWorkbenchStore } from './workbenchStore'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const animaModel: MainModelConfig = { base: 'anima', key: 'anima-model', name: 'Anima', type: 'main' }; const animaVae: VaeModelConfig = { base: 'qwen-image', key: 'anima-vae', name: 'Anima VAE', type: 'vae' }; @@ -112,6 +113,8 @@ const getActiveProject = (values: GenerateWidgetValues) => { return project!; }; +useArchitectureCapabilitiesFixture(); + describe('resolveInvocationRoute', () => { it('invalidates Anima generation until required components are selected', () => { const missingComponentsRoute = resolveInvocationRoute( diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts b/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts index 78476b08458..b7e4d812b7d 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts @@ -13,6 +13,7 @@ import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbe import { describe, expect, it } from 'vitest'; import { buildGraphPreviewSource } from './graphPreviewSource'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const t = ((key: string) => key) as TFunction; @@ -56,6 +57,8 @@ const generateSurface = createGraphBearingSurface(generateWidgetManifest, 'left' const workflowSurface = createGraphBearingSurface(workflowWidgetManifest, 'left', 'Workflow')!; const canvasSurface = createGraphBearingSurface(canvasWidgetManifest, 'center', 'Canvas')!; +useArchitectureCapabilitiesFixture(); + describe('buildGraphPreviewSource', () => { it('live-compiles the generate source and reports a seed notice when randomized', () => { const source = buildGraphPreviewSource({ models, project, surface: generateSurface, t, templates: idleTemplates }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts index 85212707e72..3ccee568288 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts @@ -1,27 +1,37 @@ import { describe, expect, it } from 'vitest'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; + import { DEFAULT_MODEL_GRID, gridSizeForModelBase } from './bboxGrid'; +useArchitectureCapabilitiesFixture(); + describe('gridSizeForModelBase', () => { - it('maps flux-family and sd-3 bases to a 16px grid', () => { - for (const base of ['flux', 'flux2', 'sd-3', 'qwen-image', 'z-image']) { + it('reads the grid the architecture declares', () => { + // The same number the denoise node enforces through `multiple_of` on width/height. + for (const base of ['flux', 'flux2', 'sd-3', 'qwen-image', 'z-image', 'ernie-image']) { expect(gridSizeForModelBase(base)).toBe(16); } - }); - it('maps cogview4 to a 32px grid', () => { expect(gridSizeForModelBase('cogview4')).toBe(32); - }); - it('maps sd/sdxl and unknown bases to the default 8px grid', () => { - for (const base of ['sd-1', 'sd-2', 'sdxl', 'anima', 'mystery-model']) { + for (const base of ['sd-1', 'sd-2', 'sdxl', 'anima']) { expect(gridSizeForModelBase(base)).toBe(8); } }); - it('falls back to the default grid for null/undefined', () => { + it('no longer offers 8px steps for architectures that reject them', () => { + // The drift this replaces: these three fell through to the default 8 here while their denoise + // nodes carry multiple_of=16, so the canvas offered sizes that failed at enqueue time. + for (const base of ['krea-2', 'wan', 'ideogram-4']) { + expect(gridSizeForModelBase(base)).toBe(16); + } + }); + + it('falls back to the default grid when there is no model or no row for its architecture', () => { expect(gridSizeForModelBase(null)).toBe(DEFAULT_MODEL_GRID); expect(gridSizeForModelBase(undefined)).toBe(DEFAULT_MODEL_GRID); + expect(gridSizeForModelBase('mystery-model')).toBe(DEFAULT_MODEL_GRID); expect(DEFAULT_MODEL_GRID).toBe(8); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts index 7d0513024bc..e0907502277 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts @@ -1,31 +1,19 @@ /** - * Maps a model base to the bbox snapping grid size (document px), mirroring the - * legacy `getGridSize` rule: generation dimensions must land on a base-specific - * multiple. React reads the active generate model's base and feeds the result - * into `engine.viewport.setBboxGrid`; the engine itself stays model-agnostic. + * Maps a model base to the bbox snapping grid size (document px). React reads the active generate + * model's base and feeds the result into `engine.viewport.setBboxGrid`; the engine itself stays + * model-agnostic. + * + * The rule comes from the backend now, which is where it is enforced: each denoise node carries a + * `multiple_of` on its width/height fields, and the architecture declares the same number. This + * used to be a second, hand-maintained copy of that column -- and it had drifted, offering 8px + * steps for krea-2, wan and ideogram-4, all of which reject anything but multiples of 16 at + * enqueue time. */ -/** Default grid when no model is selected (or an unknown base). */ +import { getDimensionGridForBase } from '@features/generation/settings'; + +/** Default grid when no model is selected, or the backend has no row for its architecture. */ export const DEFAULT_MODEL_GRID = 8; -/** - * The bbox grid size for a model base: - * - `cogview4` → 32 - * - `flux` / `flux2` / `sd-3` / `qwen-image` / `z-image` / `ernie-image` → 16 - * - everything else (sd-1/sd-2/sdxl/anima/unknown) → 8 - */ -export const gridSizeForModelBase = (base: string | null | undefined): number => { - switch (base) { - case 'cogview4': - return 32; - case 'flux': - case 'flux2': - case 'sd-3': - case 'qwen-image': - case 'z-image': - case 'ernie-image': - return 16; - default: - return DEFAULT_MODEL_GRID; - } -}; +export const gridSizeForModelBase = (base: string | null | undefined): number => + (base ? getDimensionGridForBase(base) : null) ?? DEFAULT_MODEL_GRID; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts index ea33791a54d..c1aee39a450 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts @@ -34,6 +34,7 @@ import { resolveRegionalReferenceImages, runCanvasInvocation, } from './prepareCanvasInvocation'; +import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const canvasBoundaryMocks = vi.hoisted(() => ({ getCanvasEngine: vi.fn(), @@ -320,6 +321,8 @@ const controlLayer = ( withTransparencyEffect: true, }); +useArchitectureCapabilitiesFixture(); + describe('prepareCanvasInvocation generation-device boundary', () => { beforeEach(() => { vi.restoreAllMocks(); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx index 88fb24ee2fb..01bc83a4218 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx @@ -17,6 +17,7 @@ import { galleryImages, galleryTransfers } from '@features/gallery'; import { invalidateGallery } from '@features/gallery/queries'; import { isGalleryImageDragData, useGalleryImageDroppable } from '@features/gallery/utility'; import { FluxReduxControls, PROMPT_ATTENTION_TARGET_PROPS, PromptTextarea } from '@features/generation/components'; +import { isRegionalNegativeSupportedForBase } from '@features/generation/graph'; import { useModelsSelector } from '@features/models'; import { assertAccountScopeCurrent, @@ -108,10 +109,12 @@ export const RegionalGuidanceSettings = ({ engine, layer }: RegionalGuidanceSett const [negativePrompt, setNegativePrompt] = useState(layer.negativePrompt ?? ''); const fill = layer.mask.fill; - const isFlux = base === 'flux'; const isFlux2 = base === 'flux2'; - const isFluxFamily = isFlux || isFlux2; - const showNegativeControls = !isFluxFamily || Boolean(layer.negativePrompt) || layer.autoNegative; + // Asked as "is this the FLUX family?" before, which got krea-2 wrong: it rendered a regional + // negative prompt and an Auto-Negative switch that `addRegionalGuidance` then discarded. The + // graph builder and this control now read the same declaration. + const supportsRegionalNegative = isRegionalNegativeSupportedForBase(base ?? ''); + const showNegativeControls = supportsRegionalNegative || Boolean(layer.negativePrompt) || layer.autoNegative; const showReferenceImages = !isFlux2 || layer.referenceImages.length > 0; const commitConfig = useCallback( diff --git a/tests/backend/architectures/test_vae.py b/tests/backend/architectures/test_vae.py new file mode 100644 index 00000000000..79e75c1b1ed --- /dev/null +++ b/tests/backend/architectures/test_vae.py @@ -0,0 +1,117 @@ +"""Which VAEs each architecture accepts, and that the loaders agree. + +The facts here were duplicated three times before -- once per loader as `ui_model_base`, once in +webv2's VAE picker, once more in its related-models linker -- and all three disagreed. Anima's +loader declared nothing at all while its description named three families; FLUX.2 Klein's offered +FLUX VAEs that crash in `decode()`. +""" + +import pytest + +from invokeai.app.invocations.baseinvocation import InvocationRegistry +from invokeai.app.services.shared.graph import Graph # noqa: F401 -- imports every invocation +from invokeai.backend.architectures import generative_bases, get +from invokeai.backend.architectures.facets.vae import VaeFacet, accepts_vae +from invokeai.backend.model_manager.taxonomy import BaseModelType + +# The loader that owns each architecture's VAE input. Architectures absent from this map take no +# standalone VAE: the SD family overrides it through model settings instead. +VAE_LOADER = { + BaseModelType.Anima: "anima_model_loader", + BaseModelType.Flux: "flux_model_loader", + BaseModelType.Flux2: "flux2_klein_model_loader", + BaseModelType.Krea2: "krea2_model_loader", + BaseModelType.QwenImage: "qwen_image_model_loader", + BaseModelType.StableDiffusion3: "sd3_model_loader", + BaseModelType.Wan: "wan_model_loader", + BaseModelType.ZImage: "z_image_model_loader", +} + + +def _ui_model_bases() -> dict[str, list[str] | None]: + out: dict[str, list[str] | None] = {} + for cls in InvocationRegistry.get_invocation_classes(): + vae = cls.model_json_schema().get("properties", {}).get("vae_model") + if vae is not None: + out[cls.get_type()] = vae.get("ui_model_base") + return out + + +def test_the_declaration_and_the_loader_agree() -> None: + """`ui_model_base` is what the UI filters on today; the facet is what it will filter on. + + While both exist they must say the same thing, or a model the graph accepts is hidden -- or + worse, one it rejects is offered. `ui_model_base` cannot express a channel constraint, so the + comparison is over bases; the channel rules are checked separately below. + """ + ui_bases = _ui_model_bases() + + disagreements = [] + for base, node_type in sorted(VAE_LOADER.items(), key=lambda item: item[0].value): + declared = ui_bases.get(node_type) + if declared is None: + continue # covered by test_every_loader_with_a_vae_input_declares_its_bases + + facet = get(base, VaeFacet) + accepted = facet.accepted_bases if facet else frozenset({base}) + if {b.value for b in accepted} != set(declared): + disagreements.append( + f"{base.value}: facet accepts {sorted(b.value for b in accepted)}, " + f"{node_type} offers {sorted(declared)}" + ) + + assert disagreements == [] + + +def test_flux2_no_longer_offers_a_vae_that_crashes() -> None: + """Klein advertised FLUX VAEs. Decoding a FLUX.2 latent with one raises + `AutoEncoder.decode() got an unexpected keyword argument 'return_dict'` -- the legacy FLUX + `AutoEncoder` against a 32-channel latent. Verified by generating with it.""" + assert accepts_vae(BaseModelType.Flux2, BaseModelType.Flux2) is True + assert accepts_vae(BaseModelType.Flux2, BaseModelType.Flux) is False + + +def test_anima_accepts_both_families_it_actually_handles() -> None: + """`anima_l2i` branches on `isinstance(vae, (AutoencoderKLWan, FluxAutoEncoder))`, each with its + own working-memory estimate. The Wan-family file is registered under any of three bases.""" + for vae_base in (BaseModelType.Anima, BaseModelType.QwenImage, BaseModelType.Flux): + assert accepts_vae(BaseModelType.Anima, vae_base) is True, vae_base.value + + assert accepts_vae(BaseModelType.Anima, BaseModelType.Wan, 16) is True + # TI2V-5B's Wan2.2-VAE is the same class but a 48-channel latent space; it fits nothing else. + assert accepts_vae(BaseModelType.Anima, BaseModelType.Wan, 48) is False + + +def test_an_architecture_without_the_facet_accepts_only_its_own_base() -> None: + assert accepts_vae(BaseModelType.StableDiffusionXL, BaseModelType.StableDiffusionXL) is True + assert accepts_vae(BaseModelType.StableDiffusionXL, BaseModelType.Flux) is False + + +@pytest.mark.parametrize("base", sorted(VAE_LOADER, key=lambda b: b.value)) +def test_every_loader_with_a_vae_input_declares_its_bases(base: BaseModelType) -> None: + """Anima's did not, so nothing constrained its picker and the two frontend copies drifted.""" + assert _ui_model_bases().get(VAE_LOADER[base]) is not None, ( + f"{VAE_LOADER[base]} takes a VAE but declares no `ui_model_base`, so the UI offers every " + f"VAE ever installed. Declare it, and keep it in step with {base.value}'s VaeFacet." + ) + + +def test_the_facet_is_only_declared_where_it_says_something_new() -> None: + """A facet repeating "its own base, no constraints" would be noise; `accepts_vae` says that + already for every architecture without one. + + Wan declares only its own base and is *not* redundant: it carries the channel split that tells + A14B's 16-channel VAE from TI2V-5B's 48-channel one. + """ + redundant = [] + for base in generative_bases(): + facet = get(base, VaeFacet) + if facet is None: + continue + says_nothing_new = facet.accepted_bases == frozenset({base}) and all( + entry.latent_channels is None for entry in facet.accepted + ) + if says_nothing_new: + redundant.append(base.value) + + assert redundant == [] diff --git a/tests/backend/model_manager/load/test_qwen_image_vae_layout.py b/tests/backend/model_manager/load/test_qwen_image_vae_layout.py new file mode 100644 index 00000000000..1494af25051 --- /dev/null +++ b/tests/backend/model_manager/load/test_qwen_image_vae_layout.py @@ -0,0 +1,61 @@ +"""The Qwen-Image VAE loader picks its path from the checkpoint's key layout, not from its base. + +Two layouts are in circulation for the same 16-channel autoencoder. Files exported from the +Qwen-Image repo carry diffusers keys (`decoder.conv_in.weight`); community redistributions carry the +original layout (`decoder.conv1.weight`) and need converting. + +Only the first was handled, with `strict=True`, so a redistributed file failed with 194 missing keys +-- while the byte-identical checkpoint installed under `anima` loaded fine, because that path uses +`AutoencoderKLWan.from_single_file`, which converts. Whether a VAE worked came down to which base it +happened to be probed as. +""" + +from unittest.mock import MagicMock, patch + +import torch + +from invokeai.backend.model_manager.load.model_loaders.vae import VAELoader + + +def _loader() -> VAELoader: + loader = VAELoader.__new__(VAELoader) + loader._torch_dtype = torch.float16 # type: ignore[attr-defined] + loader._ram_cache = MagicMock() # type: ignore[attr-defined] + return loader + + +def _config(path: str = "vae.safetensors") -> MagicMock: + config = MagicMock() + config.path = path + return config + + +def test_the_original_layout_is_converted_rather_than_rejected() -> None: + state_dict = {"decoder.conv1.weight": torch.zeros(1), "encoder.conv1.weight": torch.zeros(1)} + + with ( + patch("safetensors.torch.load_file", return_value=state_dict), + patch("diffusers.models.autoencoders.AutoencoderKLWan") as wan, + patch("invokeai.backend.wan.rocm_causal_conv3d.patch_wan_causal_conv3d_for_rocm"), + ): + result = _loader()._load_qwen_image_vae(_config()) + + wan.from_single_file.assert_called_once() + assert result is wan.from_single_file.return_value + + +def test_the_diffusers_layout_still_loads_directly() -> None: + """`AutoencoderKLQwenImage` registers no single-file conversion, so this path stays.""" + state_dict = {"decoder.conv_in.weight": torch.zeros(1)} + + with ( + patch("safetensors.torch.load_file", return_value=state_dict), + patch("accelerate.init_empty_weights"), + patch("diffusers.models.autoencoders.autoencoder_kl_qwenimage.AutoencoderKLQwenImage") as qwen, + patch("diffusers.models.autoencoders.AutoencoderKLWan") as wan, + ): + result = _loader()._load_qwen_image_vae(_config()) + + wan.from_single_file.assert_not_called() + qwen.return_value.load_state_dict.assert_called_once() + assert result is qwen.return_value From ce2a05609db9d76c017f994cf1a6536c3a6717ed Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 22:54:42 +0200 Subject: [PATCH 3/9] fix(webv2): satisfy the checks this branch was not run against Four CI jobs failed, from two causes. `oxfmt --check` rejected 28 files. They were linted and typechecked but never formatted: on a Windows checkout `core.autocrlf` gives every file CRLF while oxfmt writes LF, so `format:check` fails for all 1898 files locally and is useless as a signal. Formatted the ones this branch touches. `openapi.json` and `schema.ts` were stale. Correcting `ui_model_base` on the Anima, Qwen-Image and FLUX.2 Klein loaders changes the invocation schema, and the artifacts were last regenerated before that. Two more surfaced once formatting stopped short-circuiting the lint job: - The test helper was named `useArchitectureCapabilitiesFixture`, so `react-hooks/rules-of-hooks` read it as a hook called at module top level. It is not one; `seedArchitectureCapabilities` says what it does anyway. - Dead imports left behind by deleting `BASE_GENERATION` and moving the ordered-bases pin out. --- invokeai/frontend/web/openapi.json | 7 +- .../frontend/web/src/services/api/schema.ts | 2 +- .../architectureCapabilities.json | 198 +++--------------- .../core/architectureCapabilities.testing.ts | 2 +- .../core/architectureCapabilities.ts | 2 +- .../core/baseGenerationPolicies.test.ts | 5 +- .../generation/core/baseGenerationPolicies.ts | 30 +-- .../core/canvas/addControlLayers.test.ts | 4 +- .../core/canvas/addRegionalGuidance.test.ts | 4 +- .../core/canvas/addRegionalGuidance.ts | 3 +- .../core/canvas/compileCanvasGraph.test.ts | 4 +- .../core/canvas/controlValidation.test.ts | 4 +- .../features/generation/core/graph.test.ts | 4 +- .../generation/core/graphCoverage.test.ts | 4 +- .../generation/core/previewGraph.test.ts | 8 +- .../generation/core/promptHistory.test.ts | 4 +- .../core/resolveGenerateWidgetValues.test.ts | 6 +- .../core/resolveGenerateWidgetValues.ts | 1 - .../features/generation/core/settings.test.ts | 4 +- .../architectureCapabilitiesStore.test.ts | 3 +- .../generation/ui/GenerateWidgetView.tsx | 2 +- .../importGalleryImages.test.ts | 4 +- .../src/workbench/controlLayerChecks.test.ts | 4 +- .../generationSettingsOrchestration.test.ts | 4 +- .../appendReferenceImage.test.ts | 4 +- .../image-actions/executeImageRecall.test.ts | 5 +- .../image-actions/imageRecall.test.ts | 4 +- .../webv2/src/workbench/invocation.test.ts | 5 +- .../widget-frame/graphPreviewSource.test.ts | 4 +- 29 files changed, 89 insertions(+), 246 deletions(-) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index e9dc358f0ae..589488c8d43 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -15564,6 +15564,7 @@ "input": "direct", "orig_required": true, "title": "VAE", + "ui_model_base": ["anima", "qwen-image", "wan", "flux"], "ui_model_type": ["vae"] }, "qwen3_encoder_model": { @@ -31794,13 +31795,13 @@ } ], "default": null, - "description": "Standalone VAE model. Flux2 Klein uses the same VAE as FLUX (16-channel). If not provided, VAE will be loaded from the Qwen3 Source model.", + "description": "Standalone VAE model (AutoencoderKLFlux2, 32-channel). If not provided, VAE will be loaded from the Qwen3 Source model.", "field_kind": "input", "input": "direct", "orig_default": null, "orig_required": false, "title": "VAE", - "ui_model_base": ["flux", "flux2"], + "ui_model_base": ["flux2"], "ui_model_type": ["vae"] }, "qwen3_encoder_model": { @@ -80267,7 +80268,7 @@ "orig_default": null, "orig_required": false, "title": "VAE", - "ui_model_base": ["qwen-image"], + "ui_model_base": ["qwen-image", "anima"], "ui_model_type": ["vae"] }, "qwen_vl_encoder_model": { diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 875b37fa74c..e06fc4a0a53 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -12823,7 +12823,7 @@ export type components = { model: components["schemas"]["ModelIdentifierField"]; /** * VAE - * @description Standalone VAE model. Flux2 Klein uses the same VAE as FLUX (16-channel). If not provided, VAE will be loaded from the Qwen3 Source model. + * @description Standalone VAE model (AutoencoderKLFlux2, 32-channel). If not provided, VAE will be loaded from the Qwen3 Source model. * @default null */ vae_model?: components["schemas"]["ModelIdentifierField"] | null; diff --git a/invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json b/invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json index 139b5dd0877..e4c42570018 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json +++ b/invokeai/frontend/webv2/src/features/generation/core/__fixtures__/architectureCapabilities.json @@ -3,12 +3,7 @@ "base": "anima", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "anima" }, "features": { @@ -51,12 +46,7 @@ "base": "cogview4", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "cogview4" }, "features": { @@ -99,9 +89,7 @@ "base": "ernie-image", "variant": null, "modality": { - "modes": [ - "txt2img" - ], + "modes": ["txt2img"], "metadata_slug": "ernie_image" }, "features": { @@ -144,12 +132,7 @@ "base": "flux", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "flux" }, "features": { @@ -162,10 +145,7 @@ "guidance_label": "Guidance", "scheduler_set": "flow", "scheduler_applies_to_graph": true, - "control_kinds": [ - "control_lora", - "controlnet" - ], + "control_kinds": ["control_lora", "controlnet"], "max_reference_images": 5, "reference_images_require_variant": null, "supports_regional_guidance": true, @@ -195,12 +175,7 @@ "base": "flux", "variant": "dev_fill", "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "flux" }, "features": { @@ -213,10 +188,7 @@ "guidance_label": "Guidance", "scheduler_set": "flow", "scheduler_applies_to_graph": true, - "control_kinds": [ - "control_lora", - "controlnet" - ], + "control_kinds": ["control_lora", "controlnet"], "max_reference_images": 5, "reference_images_require_variant": null, "supports_regional_guidance": true, @@ -246,12 +218,7 @@ "base": "flux", "variant": "schnell", "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "flux" }, "features": { @@ -264,10 +231,7 @@ "guidance_label": "Guidance", "scheduler_set": "flow", "scheduler_applies_to_graph": true, - "control_kinds": [ - "control_lora", - "controlnet" - ], + "control_kinds": ["control_lora", "controlnet"], "max_reference_images": 5, "reference_images_require_variant": null, "supports_regional_guidance": true, @@ -297,12 +261,7 @@ "base": "flux2", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "flux2" }, "features": { @@ -345,12 +304,7 @@ "base": "flux2", "variant": "dev", "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "flux2" }, "features": { @@ -393,12 +347,7 @@ "base": "flux2", "variant": "klein_4b_base", "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "flux2" }, "features": { @@ -441,12 +390,7 @@ "base": "flux2", "variant": "klein_9b_base", "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "flux2" }, "features": { @@ -489,9 +433,7 @@ "base": "ideogram-4", "variant": null, "modality": { - "modes": [ - "txt2img" - ], + "modes": ["txt2img"], "metadata_slug": "ideogram4" }, "features": { @@ -534,12 +476,7 @@ "base": "krea-2", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "krea2" }, "features": { @@ -582,12 +519,7 @@ "base": "krea-2", "variant": "krea2_base", "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "krea2" }, "features": { @@ -630,14 +562,7 @@ "base": "minimax-h3", "variant": null, "modality": { - "modes": [ - "extend_video", - "flf2v", - "i2v", - "lf2v", - "t2v", - "txt2img" - ], + "modes": ["extend_video", "flf2v", "i2v", "lf2v", "t2v", "txt2img"], "metadata_slug": "minimax_h3" }, "features": { @@ -680,12 +605,7 @@ "base": "qwen-image", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "qwen_image" }, "features": { @@ -728,12 +648,7 @@ "base": "sd-1", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": null }, "features": { @@ -746,10 +661,7 @@ "guidance_label": "CFG", "scheduler_set": "standard", "scheduler_applies_to_graph": true, - "control_kinds": [ - "controlnet", - "t2i_adapter" - ], + "control_kinds": ["controlnet", "t2i_adapter"], "max_reference_images": 5, "reference_images_require_variant": null, "supports_regional_guidance": true, @@ -779,12 +691,7 @@ "base": "sd-2", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": null }, "features": { @@ -827,12 +734,7 @@ "base": "sd-3", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "sd3" }, "features": { @@ -875,12 +777,7 @@ "base": "sdxl", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "sdxl" }, "features": { @@ -893,10 +790,7 @@ "guidance_label": "CFG", "scheduler_set": "standard", "scheduler_applies_to_graph": true, - "control_kinds": [ - "controlnet", - "t2i_adapter" - ], + "control_kinds": ["controlnet", "t2i_adapter"], "max_reference_images": 5, "reference_images_require_variant": null, "supports_regional_guidance": true, @@ -969,16 +863,7 @@ "base": "wan", "variant": null, "modality": { - "modes": [ - "extend_video", - "i2v", - "img2img", - "inpaint", - "interpolate", - "outpaint", - "t2v", - "txt2img" - ], + "modes": ["extend_video", "i2v", "img2img", "inpaint", "interpolate", "outpaint", "t2v", "txt2img"], "metadata_slug": "wan" }, "features": { @@ -1021,16 +906,7 @@ "base": "wan", "variant": "ti2v_5b", "modality": { - "modes": [ - "extend_video", - "i2v", - "img2img", - "inpaint", - "interpolate", - "outpaint", - "t2v", - "txt2img" - ], + "modes": ["extend_video", "i2v", "img2img", "inpaint", "interpolate", "outpaint", "t2v", "txt2img"], "metadata_slug": "wan" }, "features": { @@ -1073,12 +949,7 @@ "base": "z-image", "variant": null, "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "z_image" }, "features": { @@ -1091,9 +962,7 @@ "guidance_label": "CFG", "scheduler_set": "flow", "scheduler_applies_to_graph": true, - "control_kinds": [ - "z_image_control" - ], + "control_kinds": ["z_image_control"], "max_reference_images": 0, "reference_images_require_variant": null, "supports_regional_guidance": false, @@ -1123,12 +992,7 @@ "base": "z-image", "variant": "zbase", "modality": { - "modes": [ - "img2img", - "inpaint", - "outpaint", - "txt2img" - ], + "modes": ["img2img", "inpaint", "outpaint", "txt2img"], "metadata_slug": "z_image" }, "features": { @@ -1141,9 +1005,7 @@ "guidance_label": "CFG", "scheduler_set": "flow", "scheduler_applies_to_graph": true, - "control_kinds": [ - "z_image_control" - ], + "control_kinds": ["z_image_control"], "max_reference_images": 0, "reference_images_require_variant": null, "supports_regional_guidance": false, diff --git a/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts index c2dba584713..187bfabb051 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.testing.ts @@ -22,7 +22,7 @@ import { export const architectureCapabilitiesFixture = fixture as ArchitectureCapabilitiesRow[]; /** Load the table before each test and drop it afterwards, so nothing leaks between files. */ -export const useArchitectureCapabilitiesFixture = (): void => { +export const seedArchitectureCapabilities = (): void => { beforeEach(() => { setArchitectureCapabilities(architectureCapabilitiesFixture); }); diff --git a/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts index f8de4942ded..af219f45ef9 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/architectureCapabilities.ts @@ -74,7 +74,7 @@ const FALLBACK_OPTIMAL_SIDE = 1024; * samples with, so reading `cfg_scale` first would present the off-switch as the setting. */ const guidanceValue = (defaults: NonNullable, label: GuidanceLabel): number => - (label === 'Guidance' ? defaults.guidance ?? defaults.cfg_scale : defaults.cfg_scale ?? defaults.guidance) ?? + (label === 'Guidance' ? (defaults.guidance ?? defaults.cfg_scale) : (defaults.cfg_scale ?? defaults.guidance)) ?? FALLBACK_CFG_SCALE; /** diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts index ce52335345a..b9e5b3482d8 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts @@ -1,3 +1,4 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import type { @@ -24,9 +25,7 @@ import { getSettingsWithModelDefaults, isReferenceImageSupported, isSupportedGenerateModel, - SUPPORTED_GENERATE_BASES, } from './baseGenerationPolicies'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const createModel = (base: string, overrides: Partial = {}): MainModelConfig => ({ base, @@ -102,7 +101,7 @@ const externalModel: GenerateModelConfig = { type: 'external_image_generator', }; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('architecture policy, read from the backend capability table', () => { it('matches expected dimensions per base', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts index b233ea82630..0767f192dfc 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts @@ -1,28 +1,20 @@ import type { GenerationModelCatalogItem as ModelConfig, GenerationModelTaxonomyType as ModelTaxonomyType, - KnownGenerationModelBase as KnownModelBase, PromptHistoryItem, } from '@features/generation/core/contracts'; +import type { BaseGenerationConfig, GuidanceLabel } from '@features/generation/core/generationConfig'; +import { + getArchitectureFeatures, + getArchitectureGenerationConfig, +} from '@features/generation/core/architectureCapabilities'; import { isSupportedGenerateBase, SUPPORTED_GENERATE_BASES, type SupportedGenerateBase, } from '@features/generation/core/supportedBases'; -import type { - BaseGenerationConfig, - GuidanceLabel, - NegativePromptUsage, - SchedulerSetId, -} from '@features/generation/core/generationConfig'; - -import { - getArchitectureFeatures, - getArchitectureGenerationConfig, -} from '@features/generation/core/architectureCapabilities'; - import type { GenerateModelConfig, GenerateReferenceImage, @@ -89,12 +81,7 @@ export interface SchedulerOption { label: string; } -export type { - BaseGenerationConfig, - GuidanceLabel, - NegativePromptUsage, - SchedulerSetId, -} from '@features/generation/core/generationConfig'; +export type { BaseGenerationConfig, GuidanceLabel } from '@features/generation/core/generationConfig'; type GenerateDefaultSettings = | { @@ -172,7 +159,6 @@ const ANIMA_SCHEDULERS = new Set(ANIMA_SCHEDULER_OPTIONS.map((option) => option. export const isKnownScheduler = (value: string): boolean => KNOWN_SCHEDULERS.has(value); - export { isSupportedGenerateBase, SUPPORTED_GENERATE_BASES, type SupportedGenerateBase }; export interface GenerationModelPolicy { @@ -299,8 +285,8 @@ const getRecordGuidanceValue = ( guidanceLabel: GuidanceLabel ): number | null | undefined => guidanceLabel === 'Guidance' - ? defaults?.guidance ?? defaults?.cfg_scale - : defaults?.cfg_scale ?? defaults?.guidance; + ? (defaults?.guidance ?? defaults?.cfg_scale) + : (defaults?.cfg_scale ?? defaults?.guidance); export const getGenerationDefaults = (model: GenerateModelConfig | undefined) => { const config = getBaseGenerationConfig(model); diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts index fe1e6fad219..b0feea80ef7 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.test.ts @@ -1,3 +1,4 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import type { @@ -13,7 +14,6 @@ import { getControlLayerRejectionReason, isControlKindSupportedForBase, } from './addControlLayers'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; // --------------------------------------------------------------------------- // Test helpers @@ -70,7 +70,7 @@ const edgesTo = (graph: TestGraph, nodeId: string, field: string) => // 1. isControlKindSupportedForBase full matrix // --------------------------------------------------------------------------- -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('isControlKindSupportedForBase', () => { it('controlnet is supported on sd-1, sdxl, flux only', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts index 1b1bd25fa3e..1686651850e 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.test.ts @@ -1,3 +1,4 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import type { AddRegionalGuidanceOptions, RegionalGuidanceInput, RegionalReferenceModel } from './addRegionalGuidance'; @@ -7,7 +8,6 @@ import { getRegionalGuidanceRejectionReason, isRegionalGuidanceSupportedForBase, } from './addRegionalGuidance'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; interface TestGraph { id: string; @@ -141,7 +141,7 @@ const hasEdge = (graph: TestGraph, s: string, sf: string, d: string, df: string) (e) => e.source.node_id === s && e.source.field === sf && e.destination.node_id === d && e.destination.field === df ); -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('isRegionalGuidanceSupportedForBase', () => { it('supports sd-1 / sdxl / flux / flux2 / krea-2 and nothing else', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts index 6a9bdd93553..36928df44ab 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addRegionalGuidance.ts @@ -1,7 +1,6 @@ -import { getArchitectureFeatures } from '@features/generation/core/architectureCapabilities'; - import type { BackendGraphContract, BackendInvocationContract } from '@features/generation/core/contracts'; +import { getArchitectureFeatures } from '@features/generation/core/architectureCapabilities'; import { addEdge, addNode } from '@features/generation/core/graphBuilder'; /** The deterministic denoise node id every canvas base graph uses. */ diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts index 8012758a681..96310f4833c 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts @@ -7,6 +7,7 @@ import type { VaeModelConfig, } from '@features/generation/core/types'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings } from '@features/generation/core/baseGenerationPolicies'; import { describe, expect, it } from 'vitest'; @@ -16,7 +17,6 @@ import type { CanvasCompileMode, CanvasCompositingSettings, Rect } from './types import { compileCanvasGraph } from './compileCanvasGraph'; import { DEFAULT_CANVAS_COMPOSITING } from './types'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; const sd2Model: MainModelConfig = { base: 'sd-2', key: 'sd2-model', name: 'SD 2', type: 'main' }; @@ -251,7 +251,7 @@ const BASE_CASES: BaseCase[] = [ }, ]; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('compileCanvasGraph', () => { describe('txt2img per base', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts index 18ba7425d5a..38eb6009e45 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.test.ts @@ -1,7 +1,7 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import { getControlValidationReason } from './controlValidation'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const valid = { adapterModel: { base: 'sd-1', type: 'controlnet' }, @@ -13,7 +13,7 @@ const valid = { weight: 0.75, }; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('getControlValidationReason', () => { it('returns stable reason codes for the validation matrix', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts index 511460b0008..3c1ee9c5d10 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts @@ -1,3 +1,4 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { @@ -11,7 +12,6 @@ import type { import { getDefaultGenerateSettings, isSupportedGenerateModel } from './baseGenerationPolicies'; import { compileGenerateGraph, generateSeedSequence, resolveGenerateSeed } from './graph'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; const sd2Model: MainModelConfig = { base: 'sd-2', key: 'sd2-model', name: 'SD 2', type: 'main' }; @@ -159,7 +159,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('compileGenerateGraph', () => { it('recognizes the legacy-supported generate model families', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts index 3892fa78a5c..66efb31e08a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -20,6 +20,7 @@ import type { BackendGraphContract } from '@features/generation/core/contracts'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import type { @@ -37,7 +38,6 @@ import { SUPPORTED_GENERATE_BASES, } from './baseGenerationPolicies'; import { compileGenerateGraph, GRAPH_BUILDERS } from './graph'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; /** * Main-model shapes to compile per base. @@ -205,7 +205,7 @@ const cases = SUPPORTED_GENERATE_BASES.flatMap((base) => shapesForBase(base).map((shape) => ({ base, label: `${base} / ${shape.label}`, shape })) ); -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('generate graph coverage', () => { it('has a builder for every supported base and no builder for anything else', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts index 15b3158595d..206e699fa2f 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.test.ts @@ -1,16 +1,14 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { GenerationModelCatalogItem } from './contracts'; +import type { GenerateLora, GenerateWidgetValues, LoraModelConfig, MainModelConfig } from './types'; + import capabilitiesFixture from './__fixtures__/architectureCapabilities.json'; import { type ArchitectureCapabilitiesRow, resetArchitectureCapabilities, setArchitectureCapabilities, } from './architectureCapabilities'; - - -import type { GenerationModelCatalogItem } from './contracts'; -import type { GenerateLora, GenerateWidgetValues, LoraModelConfig, MainModelConfig } from './types'; - import { getDefaultGenerateSettings } from './baseGenerationPolicies'; import { compileGeneratePreviewGraph, stabilizeBackendGraphIds } from './previewGraph'; diff --git a/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts b/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts index 622ac01cbae..d1addb23570 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/promptHistory.test.ts @@ -1,9 +1,9 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import type { MainModelConfig } from './types'; import { getDefaultGenerateSettings, getPromptHistoryRecallPatch } from './baseGenerationPolicies'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const createModel = (base: string): MainModelConfig => ({ base, @@ -12,7 +12,7 @@ const createModel = (base: string): MainModelConfig => ({ type: 'main', }); -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('getPromptHistoryRecallPatch', () => { it('clears an absent negative prompt when the selected model exposes it', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts index 010e67103aa..fc55038830e 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.test.ts @@ -1,15 +1,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { GenerateWidgetValues, MainModelConfig } from './types'; + import capabilitiesFixture from './__fixtures__/architectureCapabilities.json'; import { type ArchitectureCapabilitiesRow, resetArchitectureCapabilities, setArchitectureCapabilities, } from './architectureCapabilities'; - - -import type { GenerateWidgetValues, MainModelConfig } from './types'; - import { getDefaultGenerateSettings } from './baseGenerationPolicies'; import { resolveGenerateWidgetValues } from './resolveGenerateWidgetValues'; diff --git a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts index 772b119f998..c7488e07188 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/resolveGenerateWidgetValues.ts @@ -1,5 +1,4 @@ import { hasArchitectureCapabilities } from '@features/generation/core/architectureCapabilities'; - import { areJsonValuesStructurallyEqual } from '@platform/core/json'; import type { GenerationModelCatalogItem } from './contracts'; diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts index 9a872ae6790..caafaf6b047 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts @@ -1,3 +1,4 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import type { @@ -24,7 +25,6 @@ import { syncGenerateWidgetValuesWithModels, syncGenerateLorasWithModels, } from './settings'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; /** The persisted widget-value shape from before aspect ratio / VAE / seamless / CLIP skip landed. */ const legacyStoredValues = { @@ -42,7 +42,7 @@ const legacyStoredValues = { width: 512, }; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('normalizeGenerateSettings', () => { it('enforces template view mode only when a valid template remains', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts index 995698af200..4054955b066 100644 --- a/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/data/architectureCapabilitiesStore.test.ts @@ -1,6 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - import fixture from '@features/generation/core/__fixtures__/architectureCapabilities.json'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; const api = vi.hoisted(() => ({ getArchitectureCapabilities: vi.fn() })); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx index eaefc6b3ce8..2966ec246e0 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateWidgetView.tsx @@ -3,11 +3,11 @@ import type { GenerateModelConfig, GenerateSettings, LoraModelConfig } from '@fe import { Stack, Text } from '@chakra-ui/react'; import { getDefaultGenerateSettings, isSupportedGenerateModel } from '@features/generation/core/baseGenerationPolicies'; +import { isLoraModelConfig, normalizeGenerateSettings } from '@features/generation/core/settings'; import { ensureArchitectureCapabilitiesLoaded, useArchitectureCapabilitiesSelector, } from '@features/generation/data/architectureCapabilitiesStore'; -import { isLoraModelConfig, normalizeGenerateSettings } from '@features/generation/core/settings'; import { resolveGenerateWidgetValues } from '@features/generation/settings'; import { Button } from '@platform/ui/Button'; import { useCallback, useMemo } from 'react'; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts index 35251f29702..3a8fa78e1d0 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts @@ -6,6 +6,7 @@ import type { uploadCanvasImage } from '@workbench/canvas-operations/backend/can import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; import type { Project, WorkbenchState } from '@workbench/projectContracts'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { createControlLayer, @@ -21,7 +22,6 @@ import { createInitialWorkbenchState, type WorkbenchAction } from '@workbench/wo import { describe, expect, it, vi } from 'vitest'; import { importGalleryImagesToCanvas, type GalleryCanvasImportDestination } from './importGalleryImages'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const queriesFor = (getState: () => WorkbenchState) => ({ getProject: (projectId: string) => getState().projects.find((project) => project.id === projectId) ?? null, @@ -214,7 +214,7 @@ const expectedLayer = ( } }; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('importGalleryImagesToCanvas', () => { it.each>([ diff --git a/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts b/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts index acf4a6b2c6e..d1b69a0a75b 100644 --- a/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts +++ b/invokeai/frontend/webv2/src/workbench/controlLayerChecks.test.ts @@ -1,6 +1,7 @@ import type { ModelConfig } from '@features/models'; import type { CanvasControlLayerContract } from '@workbench/canvas-engine/api'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import { @@ -8,7 +9,6 @@ import { getControlLayerAttentionReason, hasControlLayerContent, } from './controlLayerChecks'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const model = (key: string, base: string, type: string): ModelConfig => ({ base, key, name: key, type }) as ModelConfig; @@ -44,7 +44,7 @@ const controlLayer = ( const sdxl = { base: 'sdxl' }; const models = [model('sdxl-control', 'sdxl', 'controlnet'), model('sd1-control', 'sd-1', 'controlnet')]; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('hasControlLayerContent', () => { it('matches the pipeline content gate', () => { diff --git a/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts b/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts index 79450f6c20e..56ee8318db9 100644 --- a/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts +++ b/invokeai/frontend/webv2/src/workbench/generationSettingsOrchestration.test.ts @@ -5,11 +5,11 @@ import type { VaeModelConfig, } from '@features/generation/contracts'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings } from '@features/generation/settings'; import { describe, expect, it, vi } from 'vitest'; import { recallProjectPromptHistoryItem, selectProjectGenerateModel } from './generationSettingsOrchestration'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const createModel = (base: string, key = `${base}-model`): GenerateModelConfig => ({ base, @@ -28,7 +28,7 @@ const createSettings = (model: GenerateModelConfig, overrides: Partial { it('atomically stores reconciled model settings and returns cleared labels', () => { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts index 6b892ae8ac0..694d5091966 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/appendReferenceImage.test.ts @@ -1,11 +1,11 @@ import type { MainModelConfig } from '@features/generation/contracts'; import type { ModelConfig } from '@features/models'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings, getMaxReferenceImages } from '@features/generation/settings'; import { describe, expect, it } from 'vitest'; import { appendReferenceImage, type AppendReferenceImageResult } from './appendReferenceImage'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sd1Model: MainModelConfig = { base: 'sd-1', @@ -26,7 +26,7 @@ const generateValuesFor = (referenceImages: unknown[] = []): Record { it('appends a reference image built from the flat upload result', () => { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts index 49947789942..6681e7e0c57 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts @@ -14,8 +14,9 @@ const galleryApi = vi.hoisted(() => ({ vi.mock('@features/gallery', () => galleryApi); +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; + import { executeImageRecall } from './executeImageRecall'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const model = { base: 'sdxl', @@ -54,7 +55,7 @@ const createCommands = () => { return { add, commands, setSettings }; }; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('executeImageRecall', () => { beforeEach(() => { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts index 435fd95e419..fc1f110948f 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -6,10 +6,10 @@ import type { VaeModelConfig, } from '@features/generation/contracts'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import { buildImageRecallSettings, getImageRecallCapabilities } from './imageRecall'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const sdxlModel: MainModelConfig = { base: 'sdxl', key: 'sdxl-model', name: 'SDXL', type: 'main' }; const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; @@ -134,7 +134,7 @@ const metadata = { width: 513, }; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('image recall', () => { it.each(['all', 'remix', 'prompts'] as const)( diff --git a/invokeai/frontend/webv2/src/workbench/invocation.test.ts b/invokeai/frontend/webv2/src/workbench/invocation.test.ts index 621f150d94a..878032a0ad3 100644 --- a/invokeai/frontend/webv2/src/workbench/invocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocation.test.ts @@ -21,6 +21,8 @@ const parseDynamicPromptsMock = vi.hoisted(() => vi.fn()); vi.mock('@features/generation/data/promptUtilities', () => ({ parseDynamicPrompts: parseDynamicPromptsMock })); +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; + import { areInvocationRouteInputsEqual, getInvocationRouteInput, @@ -30,7 +32,6 @@ import { import { submitResolvedInvocation } from './invocationSubmit'; import { createInitialWorkbenchState, workbenchReducer } from './workbenchState.testing'; import { createWorkbenchStore } from './workbenchStore'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const animaModel: MainModelConfig = { base: 'anima', key: 'anima-model', name: 'Anima', type: 'main' }; const animaVae: VaeModelConfig = { base: 'qwen-image', key: 'anima-vae', name: 'Anima VAE', type: 'vae' }; @@ -113,7 +114,7 @@ const getActiveProject = (values: GenerateWidgetValues) => { return project!; }; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('resolveInvocationRoute', () => { it('invalidates Anima generation until required components are selected', () => { diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts b/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts index b7e4d812b7d..152ac4186e5 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/graphPreviewSource.test.ts @@ -4,6 +4,7 @@ import type { InvocationTemplatesSnapshot } from '@features/workflow/react'; import type { GraphContract } from '@workbench/graphContracts'; import type { TFunction } from 'i18next'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings } from '@features/generation/settings'; import { createGraphBearingSurface } from '@workbench/graphSurfaces'; import { canvasWidgetManifest } from '@workbench/widgets/canvas/manifest'; @@ -13,7 +14,6 @@ import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbe import { describe, expect, it } from 'vitest'; import { buildGraphPreviewSource } from './graphPreviewSource'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const t = ((key: string) => key) as TFunction; @@ -57,7 +57,7 @@ const generateSurface = createGraphBearingSurface(generateWidgetManifest, 'left' const workflowSurface = createGraphBearingSurface(workflowWidgetManifest, 'left', 'Workflow')!; const canvasSurface = createGraphBearingSurface(canvasWidgetManifest, 'center', 'Canvas')!; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('buildGraphPreviewSource', () => { it('live-compiles the generate source and reports a seed notice when randomized', () => { From 98de037a81d96681905c45813adc1b0fae1c9c66 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 22:55:59 +0200 Subject: [PATCH 4/9] fix(webv2): satisfy the checks this branch was not run against Four CI jobs failed, from two causes. `oxfmt --check` rejected 28 files. They were linted and typechecked but never formatted: on a Windows checkout `core.autocrlf` gives every file CRLF while oxfmt writes LF, so `format:check` fails for all 1898 files locally and is useless as a signal. Formatted the ones this branch touches. `openapi.json` and `schema.ts` were stale. Correcting `ui_model_base` on the Anima, Qwen-Image and FLUX.2 Klein loaders changes the invocation schema, and the artifacts were last regenerated before that. Two more surfaced once formatting stopped short-circuiting the lint job: - The test helper was named `useArchitectureCapabilitiesFixture`, so `react-hooks/rules-of-hooks` read it as a hook called at module top level. It is not one; `seedArchitectureCapabilities` says what it does anyway. - Dead imports left behind by deleting `BASE_GENERATION` and moving the ordered-bases pin out. --- .../webv2/src/workbench/widgets/canvas/bboxGrid.test.ts | 5 ++--- .../widgets/canvas/invoke/prepareCanvasInvocation.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts index 3ccee568288..e4ab0e07057 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts @@ -1,10 +1,9 @@ +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; - import { DEFAULT_MODEL_GRID, gridSizeForModelBase } from './bboxGrid'; -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('gridSizeForModelBase', () => { it('reads the grid the architecture declares', () => { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts index c1aee39a450..748c2c13464 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts @@ -15,6 +15,7 @@ import type { WorkbenchState } from '@workbench/projectContracts'; import type { WorkbenchAction } from '@workbench/workbenchState.testing'; import type { WorkbenchCommands } from '@workbench/workbenchStore'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings } from '@features/generation/settings'; import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; import { @@ -34,7 +35,6 @@ import { resolveRegionalReferenceImages, runCanvasInvocation, } from './prepareCanvasInvocation'; -import { useArchitectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; const canvasBoundaryMocks = vi.hoisted(() => ({ getCanvasEngine: vi.fn(), @@ -321,7 +321,7 @@ const controlLayer = ( withTransparencyEffect: true, }); -useArchitectureCapabilitiesFixture(); +seedArchitectureCapabilities(); describe('prepareCanvasInvocation generation-device boundary', () => { beforeEach(() => { From c8b15e0dc07320e8c699d231e246a64a357896e0 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 23:09:36 +0200 Subject: [PATCH 5/9] fix(webv2): seed the capability fixture in the upscale graph test `compileUpscaleGraph` passes Upscale's own `kdpm_2` through `coerceSchedulerForGraph`, which reads architecture policy. Without the table the fallback config applies, and its `schedulerAppliesToGraph: false` makes the function return the fallback scheduler rather than pass the requested one through -- so the compiled graph carried `euler_a`. Missed because the webv2 suite was run in subsets while iterating. It completes in under two minutes; there was never a reason not to run all of it. --- .../frontend/webv2/src/features/upscale/core/graph.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts b/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts index 317801eb7d8..70c2694fe9f 100644 --- a/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts +++ b/invokeai/frontend/webv2/src/features/upscale/core/graph.test.ts @@ -1,6 +1,7 @@ import type { GenerateLora, VaeModelConfig } from '@features/generation/contracts'; import type { ModelConfig } from '@features/models'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { describe, expect, it } from 'vitest'; import { compileUpscaleGraph, getUpscaleControlNetValues, getUpscaleDenoisingStart } from './graph'; @@ -54,6 +55,8 @@ const hasEdge = ( edge.destination.field === destinationField ); +seedArchitectureCapabilities(); + describe('compileUpscaleGraph', () => { it('preserves the exact legacy creativity and structure formulas', () => { expect(getUpscaleDenoisingStart(0)).toBeCloseTo(0.499); From 2a936ce58b3d73ef877194ab5dc8213557f8ffe6 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 23:32:27 +0200 Subject: [PATCH 6/9] fix(webv2): seed the capability fixture in the image-actions browser test `deriveImageRecallCapabilities` reports whether an image's metadata can be recalled, and clip skip depends on the architecture's `clipSkipMax`. That comes from the capability table now, so without it the fallback applies and the capability reads false. Missed because `vitest.config.mts` excludes `*.browser.test.*` -- they run under `vitest.browser.config.mts`, which had not been run at all. Both suites pass now: 807 browser tests and 6435 node tests. --- .../workbench/image-actions/useImageActions.browser.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx index 30979852f76..4683bf4651a 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.browser.test.tsx @@ -1,5 +1,6 @@ import type { GalleryImage, GalleryItem, GalleryItemKey, GalleryItemRef } from '@features/gallery'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { accountLifecycle } from '@platform/state/accountLifecycle'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, createRef, type Ref, useImperativeHandle } from 'react'; @@ -257,6 +258,8 @@ afterEach(async () => { root = null; }); +seedArchitectureCapabilities(); + describe('image recall capability cancellation', () => { it('re-derives capabilities from the current generate model without another metadata request', async () => { const image: GalleryImage = { From 7b73ed71a488099e72a2c417ceee6db2124c5afd Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 23 Aug 2026 23:53:18 +0200 Subject: [PATCH 7/9] chore(webv2): rebaseline performance budgets for the capability modules Reading architecture policy from the backend adds four modules to the editor and launchpad bundles: +52 bytes owned JavaScript on launchpad, +281 on editor, and one additional script request per editor route. Only the structural numbers are updated. `--update-baseline` also re-records timings, and those come from whichever machine ran it -- the fresh numbers were ~10% above the committed ones, which would have loosened a gated tolerance and let real regressions of that size through. The 56 timing values are kept as committed. --- .../performance/architecture-baseline.json | 48 ++-- .../webv2/performance/browser-baseline.json | 214 +++++++++--------- 2 files changed, 140 insertions(+), 122 deletions(-) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index f4e65dd2b9d..fc5884f26ed 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,15 +2,15 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 837395, + "brotliBytes": 837513, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 836375, + "gzipBytes": 836446, "imageRawBytes": 156376, - "initialRawBytes": 1947050, + "initialRawBytes": 1947179, "largestAssetRawBytes": 746355, "otherAssetRawBytes": 0, - "ownedRawBytes": 69114, + "ownedRawBytes": 69166, "requestCount": 41, "scriptRequestCount": 25, "sourceOwners": [ @@ -287,15 +287,15 @@ ] }, "limits": { - "brotliBytes": 845769, + "brotliBytes": 845889, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 844739, + "gzipBytes": 844811, "imageRawBytes": 157940, - "initialRawBytes": 1966521, + "initialRawBytes": 1966651, "largestAssetRawBytes": 753819, "otherAssetRawBytes": 0, - "ownedRawBytes": 69114, + "ownedRawBytes": 69166, "requestCount": 41, "scriptRequestCount": 25 }, @@ -305,17 +305,17 @@ }, "editor": { "baseline": { - "brotliBytes": 1050685, + "brotliBytes": 1051808, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1046295, + "gzipBytes": 1047439, "imageRawBytes": 156376, - "initialRawBytes": 2585403, + "initialRawBytes": 2585222, "largestAssetRawBytes": 746355, "otherAssetRawBytes": 0, - "ownedRawBytes": 115755, - "requestCount": 95, - "scriptRequestCount": 79, + "ownedRawBytes": 116036, + "requestCount": 96, + "scriptRequestCount": 80, "sourceOwners": [ "package:@ark-ui/react", "package:@chakra-ui/react", @@ -460,6 +460,7 @@ "source:src/features/gallery/ui/galleryDnd.ts", "source:src/features/gallery/ui/galleryStateView.ts", "source:src/features/gallery/utility.ts", + "source:src/features/generation/core/architectureCapabilities.ts", "source:src/features/generation/core/baseGenerationPolicies.ts", "source:src/features/generation/core/batch.ts", "source:src/features/generation/core/canvas/addControlLayers.ts", @@ -484,6 +485,9 @@ "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", "source:src/features/generation/core/settings.ts", + "source:src/features/generation/core/supportedBases.ts", + "source:src/features/generation/data/architectureCapabilitiesApi.ts", + "source:src/features/generation/data/architectureCapabilitiesStore.ts", "source:src/features/generation/data/dynamicPromptsQueries.ts", "source:src/features/generation/data/llmTaskProgress.ts", "source:src/features/generation/data/promptTemplates.ts", @@ -865,17 +869,17 @@ ] }, "limits": { - "brotliBytes": 1061192, + "brotliBytes": 1062327, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 1056758, + "gzipBytes": 1057914, "imageRawBytes": 157940, - "initialRawBytes": 2611258, + "initialRawBytes": 2611075, "largestAssetRawBytes": 753819, "otherAssetRawBytes": 0, - "ownedRawBytes": 115755, - "requestCount": 95, - "scriptRequestCount": 79 + "ownedRawBytes": 116036, + "requestCount": 96, + "scriptRequestCount": 80 }, "owner": "workbench", "remediationTicket": "deepen-widget-registry-loading", @@ -927,7 +931,9 @@ "src/features/queue/ui/launchpad/QueueStatusBand.tsx", "src/workbench/launchpad/home/livePanels.ts" ], - "editorForbiddenInitialChunkNames": ["ag-psd"], + "editorForbiddenInitialChunkNames": [ + "ag-psd" + ], "inactiveWidgetGateOwner": "deepen-widget-registry-loading" } } diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 58cd415cf5a..ab60f51d3bb 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1,5 +1,5 @@ { - "browserExecutable": "/home/toast/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome", + "browserExecutable": "C:\\Users\\PC1\\AppData\\Local\\ms-playwright\\chromium-1234\\chrome-win64\\chrome.exe", "capturedAt": "2026-08-23", "routes": [ { @@ -41,22 +41,22 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, + "otherRawBytes": 148781, "requestCount": 37, - "scriptRawBytes": 1621828, + "scriptRawBytes": 1621957, "scriptRequestCount": 33, - "totalRawBytes": 1796202 + "totalRawBytes": 1796561 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, + "otherRawBytes": 150269, "requestCount": 37, - "scriptRawBytes": 1638047, + "scriptRawBytes": 1638177, "scriptRequestCount": 33, - "totalRawBytes": 1814165 + "totalRawBytes": 1814527 }, "routeReadyMedianMs": 258.70000000298023, "scriptSourceOwnerSet": "launchpad-static", @@ -101,22 +101,22 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, + "otherRawBytes": 148781, "requestCount": 37, - "scriptRawBytes": 1621828, + "scriptRawBytes": 1621957, "scriptRequestCount": 33, - "totalRawBytes": 1796202 + "totalRawBytes": 1796561 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, + "otherRawBytes": 150269, "requestCount": 37, - "scriptRawBytes": 1638047, + "scriptRawBytes": 1638177, "scriptRequestCount": 33, - "totalRawBytes": 1814165 + "totalRawBytes": 1814527 }, "routeReadyMedianMs": 269.6000000014901, "scriptSourceOwnerSet": "launchpad-static", @@ -161,22 +161,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 114, - "scriptRawBytes": 3152121, - "scriptRequestCount": 109, - "totalRawBytes": 3482871 + "otherRawBytes": 148781, + "requestCount": 115, + "scriptRawBytes": 3152673, + "scriptRequestCount": 110, + "totalRawBytes": 3483653 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 114, - "scriptRawBytes": 3183643, - "scriptRequestCount": 109, - "totalRawBytes": 3517700 + "otherRawBytes": 150269, + "requestCount": 115, + "scriptRawBytes": 3184200, + "scriptRequestCount": 110, + "totalRawBytes": 3518490 }, "routeReadyMedianMs": 666, "scriptSourceOwnerSet": "editor-minimal-static", @@ -221,22 +221,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 114, - "scriptRawBytes": 3152121, - "scriptRequestCount": 109, - "totalRawBytes": 3482871 + "otherRawBytes": 148781, + "requestCount": 115, + "scriptRawBytes": 3152673, + "scriptRequestCount": 110, + "totalRawBytes": 3483653 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 114, - "scriptRawBytes": 3183643, - "scriptRequestCount": 109, - "totalRawBytes": 3517700 + "otherRawBytes": 150269, + "requestCount": 115, + "scriptRawBytes": 3184200, + "scriptRequestCount": 110, + "totalRawBytes": 3518490 }, "routeReadyMedianMs": 446.90000000596046, "scriptSourceOwnerSet": "editor-minimal-static", @@ -250,9 +250,9 @@ "largestAssetRawBytes": 136455, "otherRawBytes": 0, "requestCount": 6, - "scriptRawBytes": 289455, + "scriptRawBytes": 289520, "scriptRequestCount": 6, - "totalRawBytes": 289455 + "totalRawBytes": 289520 }, "activatedResourceLimits": { "cssRawBytes": 0, @@ -261,9 +261,9 @@ "largestAssetRawBytes": 137820, "otherRawBytes": 0, "requestCount": 6, - "scriptRawBytes": 292350, + "scriptRawBytes": 292416, "scriptRequestCount": 6, - "totalRawBytes": 292350 + "totalRawBytes": 292416 }, "domContentLoadedMedianMs": 117.89999999850988, "id": "editor-canvas", @@ -281,22 +281,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 120, - "scriptRawBytes": 3441576, - "scriptRequestCount": 115, - "totalRawBytes": 3772326 + "otherRawBytes": 148781, + "requestCount": 121, + "scriptRawBytes": 3442193, + "scriptRequestCount": 116, + "totalRawBytes": 3773173 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 120, - "scriptRawBytes": 3475992, - "scriptRequestCount": 115, - "totalRawBytes": 3810050 + "otherRawBytes": 150269, + "requestCount": 121, + "scriptRawBytes": 3476615, + "scriptRequestCount": 116, + "totalRawBytes": 3810905 }, "routeReadyMedianMs": 1059.7999999970198, "scriptSourceOwnerSet": "editor-canvas-static", @@ -310,9 +310,9 @@ "largestAssetRawBytes": 136455, "otherRawBytes": 0, "requestCount": 6, - "scriptRawBytes": 289455, + "scriptRawBytes": 289520, "scriptRequestCount": 6, - "totalRawBytes": 289455 + "totalRawBytes": 289520 }, "activatedResourceLimits": { "cssRawBytes": 0, @@ -321,9 +321,9 @@ "largestAssetRawBytes": 137820, "otherRawBytes": 0, "requestCount": 6, - "scriptRawBytes": 292350, + "scriptRawBytes": 292416, "scriptRequestCount": 6, - "totalRawBytes": 292350 + "totalRawBytes": 292416 }, "domContentLoadedMedianMs": 113.19999999552965, "id": "editor-canvas", @@ -341,22 +341,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 120, - "scriptRawBytes": 3441576, - "scriptRequestCount": 115, - "totalRawBytes": 3772326 + "otherRawBytes": 148781, + "requestCount": 121, + "scriptRawBytes": 3442193, + "scriptRequestCount": 116, + "totalRawBytes": 3773173 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 120, - "scriptRawBytes": 3475992, - "scriptRequestCount": 115, - "totalRawBytes": 3810050 + "otherRawBytes": 150269, + "requestCount": 121, + "scriptRawBytes": 3476615, + "scriptRequestCount": 116, + "totalRawBytes": 3810905 }, "routeReadyMedianMs": 1242.2999999970198, "scriptSourceOwnerSet": "editor-canvas-static", @@ -401,22 +401,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 121, - "scriptRawBytes": 3516312, - "scriptRequestCount": 115, - "totalRawBytes": 3862475 + "otherRawBytes": 148781, + "requestCount": 122, + "scriptRawBytes": 3516864, + "scriptRequestCount": 116, + "totalRawBytes": 3863257 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 121, - "scriptRawBytes": 3551476, - "scriptRequestCount": 115, - "totalRawBytes": 3901100 + "otherRawBytes": 150269, + "requestCount": 122, + "scriptRawBytes": 3552033, + "scriptRequestCount": 116, + "totalRawBytes": 3901890 }, "routeReadyMedianMs": 1064.1000000014901, "scriptSourceOwnerSet": "editor-workflow-static", @@ -461,22 +461,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 121, - "scriptRawBytes": 3516312, - "scriptRequestCount": 115, - "totalRawBytes": 3862475 + "otherRawBytes": 148781, + "requestCount": 122, + "scriptRawBytes": 3516864, + "scriptRequestCount": 116, + "totalRawBytes": 3863257 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 121, - "scriptRawBytes": 3551476, - "scriptRequestCount": 115, - "totalRawBytes": 3901100 + "otherRawBytes": 150269, + "requestCount": 122, + "scriptRawBytes": 3552033, + "scriptRequestCount": 116, + "totalRawBytes": 3901890 }, "routeReadyMedianMs": 1175.3000000044703, "scriptSourceOwnerSet": "editor-workflow-static", @@ -521,22 +521,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 114, - "scriptRawBytes": 3152121, - "scriptRequestCount": 109, - "totalRawBytes": 3482871 + "otherRawBytes": 148781, + "requestCount": 115, + "scriptRawBytes": 3152673, + "scriptRequestCount": 110, + "totalRawBytes": 3483653 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 114, - "scriptRawBytes": 3183643, - "scriptRequestCount": 109, - "totalRawBytes": 3517700 + "otherRawBytes": 150269, + "requestCount": 115, + "scriptRawBytes": 3184200, + "scriptRequestCount": 110, + "totalRawBytes": 3518490 }, "routeReadyMedianMs": 1265, "scriptSourceOwnerSet": "editor-minimal-static", @@ -581,22 +581,22 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746355, - "otherRawBytes": 148551, - "requestCount": 114, - "scriptRawBytes": 3152121, - "scriptRequestCount": 109, - "totalRawBytes": 3482871 + "otherRawBytes": 148781, + "requestCount": 115, + "scriptRawBytes": 3152673, + "scriptRequestCount": 110, + "totalRawBytes": 3483653 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753819, - "otherRawBytes": 150037, - "requestCount": 114, - "scriptRawBytes": 3183643, - "scriptRequestCount": 109, - "totalRawBytes": 3517700 + "otherRawBytes": 150269, + "requestCount": 115, + "scriptRawBytes": 3184200, + "scriptRequestCount": 110, + "totalRawBytes": 3518490 }, "routeReadyMedianMs": 1450.5999999940395, "scriptSourceOwnerSet": "editor-minimal-static", @@ -1111,6 +1111,7 @@ "source:src/features/gallery/ui/useGalleryUploadInput.ts", "source:src/features/gallery/utility.ts", "source:src/features/gallery/widget.ts", + "source:src/features/generation/core/architectureCapabilities.ts", "source:src/features/generation/core/baseGenerationPolicies.ts", "source:src/features/generation/core/batch.ts", "source:src/features/generation/core/canvas/addControlLayers.ts", @@ -1138,9 +1139,12 @@ "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", "source:src/features/generation/core/settings.ts", + "source:src/features/generation/core/supportedBases.ts", "source:src/features/generation/core/systemPrompts.ts", "source:src/features/generation/core/wildcardCatalog.ts", "source:src/features/generation/core/wildcardTransfer.ts", + "source:src/features/generation/data/architectureCapabilitiesApi.ts", + "source:src/features/generation/data/architectureCapabilitiesStore.ts", "source:src/features/generation/data/dynamicPromptsQueries.ts", "source:src/features/generation/data/llmTaskProgress.ts", "source:src/features/generation/data/promptTemplates.ts", @@ -2037,6 +2041,7 @@ "source:src/features/gallery/utility.ts", "source:src/features/gallery/widget.ts", "source:src/features/generation/components.ts", + "source:src/features/generation/core/architectureCapabilities.ts", "source:src/features/generation/core/baseGenerationPolicies.ts", "source:src/features/generation/core/batch.ts", "source:src/features/generation/core/canvas/addControlLayers.ts", @@ -2064,9 +2069,12 @@ "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", "source:src/features/generation/core/settings.ts", + "source:src/features/generation/core/supportedBases.ts", "source:src/features/generation/core/systemPrompts.ts", "source:src/features/generation/core/wildcardCatalog.ts", "source:src/features/generation/core/wildcardTransfer.ts", + "source:src/features/generation/data/architectureCapabilitiesApi.ts", + "source:src/features/generation/data/architectureCapabilitiesStore.ts", "source:src/features/generation/data/dynamicPromptsQueries.ts", "source:src/features/generation/data/llmTaskProgress.ts", "source:src/features/generation/data/promptTemplates.ts", @@ -3073,6 +3081,7 @@ "source:src/features/gallery/ui/useGalleryUploadInput.ts", "source:src/features/gallery/utility.ts", "source:src/features/gallery/widget.ts", + "source:src/features/generation/core/architectureCapabilities.ts", "source:src/features/generation/core/baseGenerationPolicies.ts", "source:src/features/generation/core/batch.ts", "source:src/features/generation/core/canvas/addControlLayers.ts", @@ -3100,9 +3109,12 @@ "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", "source:src/features/generation/core/settings.ts", + "source:src/features/generation/core/supportedBases.ts", "source:src/features/generation/core/systemPrompts.ts", "source:src/features/generation/core/wildcardCatalog.ts", "source:src/features/generation/core/wildcardTransfer.ts", + "source:src/features/generation/data/architectureCapabilitiesApi.ts", + "source:src/features/generation/data/architectureCapabilitiesStore.ts", "source:src/features/generation/data/dynamicPromptsQueries.ts", "source:src/features/generation/data/llmTaskProgress.ts", "source:src/features/generation/data/promptTemplates.ts", From e730cd16250f9b695936a9908a739a574ec3c0ed Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Mon, 24 Aug 2026 00:03:52 +0200 Subject: [PATCH 8/9] chore(webv2): format the regenerated performance baseline `--update-baseline` writes `architecture-baseline.json` itself and not in oxfmt's style, so `format:check` rejected it. Values are unchanged -- the only difference is how one array is wrapped. --- .../frontend/webv2/performance/architecture-baseline.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index fc5884f26ed..19486c52ab5 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -931,9 +931,7 @@ "src/features/queue/ui/launchpad/QueueStatusBand.tsx", "src/workbench/launchpad/home/livePanels.ts" ], - "editorForbiddenInitialChunkNames": [ - "ag-psd" - ], + "editorForbiddenInitialChunkNames": ["ag-psd"], "inactiveWidgetGateOwner": "deepen-widget-registry-loading" } } From 7df4aac27f6b442ff428225fe4edac818ab7aa84 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Mon, 24 Aug 2026 12:26:27 +0200 Subject: [PATCH 9/9] chore: check out frontend sources with LF regardless of core.autocrlf `oxfmt` and `prettier` read the file on disk, so on a Windows checkout `core.autocrlf=true` makes `format:check` fail for all 1898 files -- including every file the contributor never touched. The check is therefore unusable locally, and formatting errors are only found by CI. That happened four times in a row on one PR. `* text=auto` already normalizes these files to LF in the repository, so this changes the working copy only: `git add --renormalize invokeai/frontend` reports nothing, and no file content differs after re-checkout. `docker/**` has carried the same rule for the same reason. Verified after re-checkout: `pnpm run format:check` reports "All matched files use the correct format", and `prettier --check` passes for the legacy frontend. --- .gitattributes | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.gitattributes b/.gitattributes index 6cf175e7c5a..8d2e4d195ab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,5 +3,26 @@ # For more info see: https://www.aleksandrhovhannisyan.com/blog/crlf-vs-lf-normalizing-line-endings-in-git/ * text=auto docker/** text eol=lf + +# The frontend formatters read the file on disk, so a CRLF working tree makes `format:check` fail +# for every file -- including ones nobody touched. That leaves contributors on Windows unable to run +# the same check CI runs, which is how formatting errors reach CI in the first place. +# +# Only the working copy changes: these files are already stored with LF (`* text=auto` normalizes on +# commit), so nothing in history is rewritten. +invokeai/frontend/**/*.ts text eol=lf +invokeai/frontend/**/*.tsx text eol=lf +invokeai/frontend/**/*.mts text eol=lf +invokeai/frontend/**/*.js text eol=lf +invokeai/frontend/**/*.jsx text eol=lf +invokeai/frontend/**/*.mjs text eol=lf +invokeai/frontend/**/*.cjs text eol=lf +invokeai/frontend/**/*.json text eol=lf +invokeai/frontend/**/*.webmanifest text eol=lf +invokeai/frontend/**/*.css text eol=lf +invokeai/frontend/**/*.html text eol=lf +invokeai/frontend/**/*.md text eol=lf +invokeai/frontend/**/*.yaml text eol=lf +invokeai/frontend/**/*.yml text eol=lf tests/test_model_probe/stripped_models/** filter=lfs diff=lfs merge=lfs -text tests/model_identification/stripped_models/** filter=lfs diff=lfs merge=lfs -text