From eb428340b8d078e8dcab9892b3f901b1e84467b8 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 21:41:06 -0600 Subject: [PATCH 1/3] feat(canvas): restore PiD decode and the Anima inpaint adapter Canvas txt2img and img2img decode through PiD in fit and native modes, resolving the encode VAE from the PiD graph and encoding at the denoise size; inpaint/outpaint and Scale Before Processing keep the legacy rejections. Anima inpaint/outpaint can add the optional 4-channel ControlNet-LLLite adapter with a weight, fed the raster image and inverted mask through the shared collector, recorded in metadata and restored by Recall All and Remix. --- .../03.Models/Local Models/anima.mdx | 5 + .../Users Guide/07.Canvas/03.layers-panel.mdx | 2 +- .../frontend/webv2/public/locales/en.json | 3 +- .../animaLLLiteCanvasGraphs.json | 577 +++++++++++++++++- .../core/baseGenerationPolicies.test.ts | 47 ++ .../generation/core/baseGenerationPolicies.ts | 21 + .../core/canvas/addControlLayers.ts | 31 +- .../core/canvas/compileCanvasGraph.test.ts | 338 +++++++++- .../core/canvas/compileCanvasGraph.ts | 167 ++++- .../core/canvas/controlValidation.test.ts | 14 + .../core/canvas/controlValidation.ts | 10 + .../generation/core/componentCompatibility.ts | 6 + .../src/features/generation/core/graph.ts | 26 +- .../src/features/generation/core/pidGraph.ts | 5 + .../features/generation/core/settings.test.ts | 24 + .../src/features/generation/core/settings.ts | 15 + .../src/features/generation/core/types.ts | 6 + .../ui/GenerateComponentsSection.tsx | 52 +- .../importGalleryImages.test.ts | 2 + .../image-actions/imageRecall.test.ts | 67 ++ .../workbench/image-actions/imageRecall.ts | 36 +- .../src/workbench/workbenchState.test.ts | 2 + .../test_frontend_anima_lllite_graphs.py | 98 ++- 23 files changed, 1471 insertions(+), 83 deletions(-) diff --git a/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx b/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx index 6d404b53368..c77f2d143b1 100644 --- a/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx @@ -130,6 +130,11 @@ transformer on an image. Each adapter model may be used only once per generation re-identify it in the Model Manager. A control layer from an older project may still be set to **ControlNet**, which Anima cannot run; its settings say so and offer **Switch to Anima ControlNet-LLLite**, which keeps the selected adapter. +- **When inpainting or outpainting on the canvas**, pick the Inpainting adapter as the optional **Inpaint + Adapter** in the Generate panel's **Components** section, and set its **Inpaint adapter weight** (1 by + default). Invoke then conditions the adapter on the raster image and the region being generated; the + setting is ignored for text-to-image and image-to-image. The adapter and its weight are recorded in the + image's metadata and restored by **Recall all** and **Remix**. - **In the workflow editor**, add an **Anima ControlNet-LLLite** node, give it the conditioning image and a control model, and connect it to the **Control LLLite** input of **Denoise - Anima**, collecting several nodes to combine them. diff --git a/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx b/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx index 22f18556c02..d9805e7dd32 100644 --- a/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx +++ b/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx @@ -123,7 +123,7 @@ Adjustment child rows each have their own settings, such as **Brightness** and * ### Control layer * **Adapter type** — ControlNet, T2I Adapter, Control LoRA, Z-Image ControlNet or Anima ControlNet-LLLite, depending on the model selected in the Generate panel. -* **Model** — the installed control models of that type for the same model family. If the list is empty, install one from the starter models. For Anima, the list holds the LLLite control adapters but not the Inpainting adapter, and each LLLite model can drive only one control layer per generation. +* **Model** — the installed control models of that type for the same model family. If the list is empty, install one from the starter models. For Anima, the list holds the LLLite control adapters but not the Inpainting adapter, which is chosen as the **Inpaint Adapter** in the Generate panel's Components section instead, and each LLLite model can drive only one control layer per generation. * **Weight** — how strongly the guide is followed (0 to 2; the default is 0.75 for ControlNet). * **Step range** — the part of the generation, from start to finish, in which the guide applies. * **Control mode** (ControlNet only) — **Balanced**, **More prompt**, **More control** or **Unbalanced**. diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 728a9fad1f5..ab77458f3b0 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2655,7 +2655,8 @@ "pidSteps": "PiD steps", "pidHelp": "Replaces the VAE decode with a 4x super-resolution decode.", "pidFitHelp": "Generates at the requested size, then decodes 4x and scales back down.", - "pidNativeHelp": "The requested size is the 4x target; generation runs at a quarter of it." + "pidNativeHelp": "The requested size is the 4x target; generation runs at a quarter of it.", + "animaInpaintAdapterWeight": "Inpaint adapter weight" }, "upscale": { "concepts": "Concepts", diff --git a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json index f5c4355639d..6341918c2d5 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json +++ b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json @@ -1,5 +1,5 @@ { - "_comment": "Generated by compileCanvasGraph.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_frontend_anima_lllite_graphs.py, which validates the graph against the backend invocations.", + "_comment": "Generated by compileCanvasGraph.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_frontend_anima_lllite_graphs.py, which validates the graphs against the backend invocations.", "inpaint_three_adapters": { "edges": [ { @@ -509,5 +509,580 @@ "weight": 2 } } + }, + "outpaint_inpaint_adapter_and_control_layer": { + "edges": [ + { + "destination": { + "field": "transformer", + "node_id": "denoise_latents" + }, + "source": { + "field": "transformer", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "qwen3_encoder", + "node_id": "pos_cond" + }, + "source": { + "field": "qwen3_encoder", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "vae", + "node_id": "canvas_l2i" + }, + "source": { + "field": "vae", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "prompt", + "node_id": "pos_cond" + }, + "source": { + "field": "value", + "node_id": "positive_prompt" + } + }, + { + "destination": { + "field": "item", + "node_id": "pos_cond_collect" + }, + "source": { + "field": "conditioning", + "node_id": "pos_cond" + } + }, + { + "destination": { + "field": "positive_conditioning", + "node_id": "denoise_latents" + }, + "source": { + "field": "collection", + "node_id": "pos_cond_collect" + } + }, + { + "destination": { + "field": "qwen3_encoder", + "node_id": "neg_cond" + }, + "source": { + "field": "qwen3_encoder", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "prompt", + "node_id": "neg_cond" + }, + "source": { + "field": "value", + "node_id": "negative_prompt" + } + }, + { + "destination": { + "field": "item", + "node_id": "neg_cond_collect" + }, + "source": { + "field": "conditioning", + "node_id": "neg_cond" + } + }, + { + "destination": { + "field": "negative_conditioning", + "node_id": "denoise_latents" + }, + "source": { + "field": "collection", + "node_id": "neg_cond_collect" + } + }, + { + "destination": { + "field": "seed", + "node_id": "denoise_latents" + }, + "source": { + "field": "value", + "node_id": "seed" + } + }, + { + "destination": { + "field": "latents", + "node_id": "canvas_l2i" + }, + "source": { + "field": "latents", + "node_id": "denoise_latents" + } + }, + { + "destination": { + "field": "seed", + "node_id": "core_metadata_0_i" + }, + "source": { + "field": "value", + "node_id": "seed" + } + }, + { + "destination": { + "field": "positive_prompt", + "node_id": "core_metadata_0_i" + }, + "source": { + "field": "value", + "node_id": "positive_prompt" + } + }, + { + "destination": { + "field": "negative_prompt", + "node_id": "core_metadata_0_i" + }, + "source": { + "field": "value", + "node_id": "negative_prompt" + } + }, + { + "destination": { + "field": "metadata", + "node_id": "canvas_output" + }, + "source": { + "field": "metadata", + "node_id": "core_metadata_0_i" + } + }, + { + "destination": { + "field": "mask2", + "node_id": "mask_combine" + }, + "source": { + "field": "image", + "node_id": "image_alpha_to_mask" + } + }, + { + "destination": { + "field": "image", + "node_id": "canvas_i2l" + }, + "source": { + "field": "image", + "node_id": "infill" + } + }, + { + "destination": { + "field": "vae", + "node_id": "canvas_i2l" + }, + "source": { + "field": "vae", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "latents", + "node_id": "denoise_latents" + }, + "source": { + "field": "latents", + "node_id": "canvas_i2l" + } + }, + { + "destination": { + "field": "mask", + "node_id": "create_gradient_mask" + }, + "source": { + "field": "image", + "node_id": "mask_combine" + } + }, + { + "destination": { + "field": "vae", + "node_id": "create_gradient_mask" + }, + "source": { + "field": "vae", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "denoise_mask", + "node_id": "denoise_latents" + }, + "source": { + "field": "denoise_mask", + "node_id": "create_gradient_mask" + } + }, + { + "destination": { + "field": "image", + "node_id": "invert_anima_inpaint_adapter_mask" + }, + "source": { + "field": "image", + "node_id": "mask_combine" + } + }, + { + "destination": { + "field": "image", + "node_id": "anima_inpaint_adapter" + }, + "source": { + "field": "image", + "node_id": "infill" + } + }, + { + "destination": { + "field": "mask", + "node_id": "anima_inpaint_adapter" + }, + "source": { + "field": "image", + "node_id": "invert_anima_inpaint_adapter_mask" + } + }, + { + "destination": { + "field": "control_lllite", + "node_id": "denoise_latents" + }, + "source": { + "field": "collection", + "node_id": "control_lllite_collector" + } + }, + { + "destination": { + "field": "item", + "node_id": "control_lllite_collector" + }, + "source": { + "field": "control", + "node_id": "anima_inpaint_adapter" + } + }, + { + "destination": { + "field": "mask", + "node_id": "expand_mask" + }, + "source": { + "field": "expanded_mask_area", + "node_id": "create_gradient_mask" + } + }, + { + "destination": { + "field": "layer_upper", + "node_id": "canvas_output" + }, + "source": { + "field": "image", + "node_id": "canvas_l2i" + } + }, + { + "destination": { + "field": "mask", + "node_id": "canvas_output" + }, + "source": { + "field": "image", + "node_id": "expand_mask" + } + }, + { + "destination": { + "field": "item", + "node_id": "control_lllite_collector" + }, + "source": { + "field": "control", + "node_id": "anima_lllite_sketch-layer" + } + } + ], + "id": "anima_lllite_canvas_inpaint_adapter", + "nodes": { + "negative_prompt": { + "is_intermediate": true, + "use_cache": true, + "id": "negative_prompt", + "type": "string" + }, + "positive_prompt": { + "is_intermediate": true, + "use_cache": true, + "id": "positive_prompt", + "type": "string" + }, + "seed": { + "is_intermediate": true, + "use_cache": true, + "id": "seed", + "type": "integer" + }, + "model_loader": { + "is_intermediate": true, + "use_cache": true, + "id": "model_loader", + "model": { + "base": "anima", + "key": "anima-model", + "name": "Anima", + "type": "main", + "hash": "blake3:anima" + }, + "qwen3_encoder_model": { + "base": "any", + "key": "qwen3", + "name": "Qwen3 Encoder", + "type": "qwen3_encoder", + "variant": "qwen3_06b", + "hash": "blake3:qwen3" + }, + "type": "anima_model_loader", + "vae_model": { + "base": "qwen-image", + "key": "qwen-vae", + "name": "Qwen VAE", + "type": "vae", + "hash": "blake3:vae" + } + }, + "pos_cond": { + "is_intermediate": true, + "use_cache": true, + "id": "pos_cond", + "type": "anima_text_encoder" + }, + "pos_cond_collect": { + "is_intermediate": true, + "use_cache": true, + "id": "pos_cond_collect", + "type": "collect" + }, + "neg_cond": { + "is_intermediate": true, + "use_cache": true, + "id": "neg_cond", + "type": "anima_text_encoder" + }, + "neg_cond_collect": { + "is_intermediate": true, + "use_cache": true, + "id": "neg_cond_collect", + "type": "collect" + }, + "denoise_latents": { + "is_intermediate": true, + "use_cache": true, + "denoising_end": 1, + "denoising_start": 0.4, + "guidance_scale": 4.5, + "height": 1024, + "id": "denoise_latents", + "scheduler": "euler", + "steps": 35, + "type": "anima_denoise", + "width": 768 + }, + "core_metadata_0_i": { + "is_intermediate": true, + "use_cache": true, + "cfg_scale": 4.5, + "cfg_rescale_multiplier": 0, + "generation_mode": "anima_outpaint", + "height": 1024, + "id": "core_metadata_0_i", + "model": { + "base": "anima", + "key": "anima-model", + "name": "Anima", + "type": "main", + "hash": "blake3:anima" + }, + "rand_device": "cpu", + "scheduler": "euler", + "seamless_x": false, + "seamless_y": false, + "steps": 35, + "type": "core_metadata", + "vae": { + "base": "qwen-image", + "key": "qwen-vae", + "name": "Qwen VAE", + "type": "vae", + "hash": "blake3:vae" + }, + "width": 768, + "qwen3_encoder": { + "base": "any", + "key": "qwen3", + "name": "Qwen3 Encoder", + "type": "qwen3_encoder", + "variant": "qwen3_06b", + "hash": "blake3:qwen3" + }, + "clip_skip": 0, + "anima_inpaint_adapter": { + "base": "anima", + "cond_in_channels": 4, + "key": "anima-inpaint", + "name": "Anima LLLite Inpainting", + "type": "controlnet", + "hash": "blake3:inpaint" + }, + "anima_inpaint_adapter_weight": 0.75, + "strength": 0.6 + }, + "infill": { + "is_intermediate": true, + "use_cache": true, + "id": "infill", + "type": "infill_lama", + "image": { + "image_name": "canvas-composite.png" + } + }, + "image_alpha_to_mask": { + "is_intermediate": true, + "use_cache": true, + "id": "image_alpha_to_mask", + "image": { + "image_name": "canvas-composite.png" + }, + "type": "tomask" + }, + "mask_combine": { + "is_intermediate": true, + "use_cache": true, + "id": "mask_combine", + "mask1": { + "image_name": "canvas-mask.png" + }, + "type": "mask_combine" + }, + "canvas_i2l": { + "is_intermediate": true, + "use_cache": true, + "id": "canvas_i2l", + "type": "anima_i2l" + }, + "canvas_l2i": { + "is_intermediate": true, + "use_cache": false, + "id": "canvas_l2i", + "type": "anima_l2i" + }, + "create_gradient_mask": { + "is_intermediate": true, + "use_cache": true, + "coherence_mode": "Gaussian Blur", + "edge_radius": 16, + "fp32": false, + "id": "create_gradient_mask", + "image": { + "image_name": "canvas-composite.png" + }, + "minimum_denoise": 0, + "type": "create_gradient_mask" + }, + "invert_anima_inpaint_adapter_mask": { + "is_intermediate": true, + "use_cache": true, + "id": "invert_anima_inpaint_adapter_mask", + "max": 0, + "min": 255, + "type": "img_lerp" + }, + "anima_inpaint_adapter": { + "is_intermediate": true, + "use_cache": true, + "control_model": { + "base": "anima", + "cond_in_channels": 4, + "key": "anima-inpaint", + "name": "Anima LLLite Inpainting", + "type": "controlnet", + "hash": "blake3:inpaint" + }, + "id": "anima_inpaint_adapter", + "type": "anima_lllite", + "weight": 0.75 + }, + "control_lllite_collector": { + "is_intermediate": true, + "use_cache": true, + "id": "control_lllite_collector", + "type": "collect" + }, + "expand_mask": { + "is_intermediate": true, + "use_cache": true, + "fade_size_px": 16, + "id": "expand_mask", + "type": "expand_mask_with_fade" + }, + "canvas_output": { + "is_intermediate": true, + "use_cache": false, + "id": "canvas_output", + "layer_base": { + "image_name": "canvas-composite.png" + }, + "type": "invokeai_img_blend" + }, + "anima_lllite_sketch-layer": { + "is_intermediate": true, + "use_cache": true, + "begin_step_percent": 0, + "control_model": { + "base": "anima", + "hash": "blake3:sketch", + "key": "sketch", + "name": "sketch", + "type": "controlnet" + }, + "end_step_percent": 0.9, + "id": "anima_lllite_sketch-layer", + "image": { + "image_name": "sketch-layer-composite.png" + }, + "type": "anima_lllite", + "weight": 0.8 + } + } } } 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 68d069e8e72..04d33524ec3 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts @@ -1098,6 +1098,53 @@ describe('component policies', () => { }); }); +describe('Anima inpaint adapter slot', () => { + const anima = createModel('anima'); + const inpaintAdapter: ComponentModelConfig = { + base: 'anima', + cond_in_channels: 4, + key: 'anima-inpaint', + name: 'Anima LLLite Inpainting', + type: 'controlnet', + }; + + it('is offered to Anima only, optional, and takes 4-channel or pre-field Anima LLLite models', () => { + const settings = createSettings(anima); + const slot = getComponentSectionPolicy(anima, settings).slots.find((s) => s.key === 'animaInpaintAdapterModel'); + const ctx = { model: anima, selectedComponents: settings, settings }; + + expect(slot?.modelTypes).toEqual(['controlnet']); + expect(slot?.required).toBeUndefined(); + expect(slot?.filter?.(inpaintAdapter, ctx)).toBe(true); + expect(slot?.filter?.({ ...inpaintAdapter, cond_in_channels: null }, ctx)).toBe(true); + expect(slot?.filter?.({ ...inpaintAdapter, cond_in_channels: 3 }, ctx)).toBe(false); + expect(slot?.filter?.({ ...inpaintAdapter, base: 'sdxl' }, ctx)).toBe(false); + expect(getGenerationValidationReasons(anima, settings)).not.toContain(expect.stringContaining('Inpaint Adapter')); + expect( + getComponentSectionPolicy(createModel('sdxl'), settings).slots.some((s) => s.key === 'animaInpaintAdapterModel') + ).toBe(false); + }); + + it('is cleared, and named, when the model leaves Anima', () => { + const settings = createSettings(anima, { + animaInpaintAdapterModel: inpaintAdapter, + animaInpaintAdapterWeight: 0.6, + qwen3EncoderModel: animaQwen3Encoder, + vae: qwenImageVae, + }); + + const result = getGenerateModelSelectionResult({ currentValues: settings, model: createModel('sdxl'), models: [] }); + + expect(result.settings.animaInpaintAdapterModel).toBeNull(); + expect(result.clearedLabels).toContain('Inpaint Adapter'); + expect(result.settings.animaInpaintAdapterWeight).toBe(0.6); + expect( + getGenerateModelSelectionResult({ currentValues: settings, model: anima, models: [] }).settings + .animaInpaintAdapterModel + ).toBe(inpaintAdapter); + }); +}); + describe('Krea-2, Ideogram 4 and Wan policies', () => { const krea2Vae: VaeModelConfig = { base: 'qwen-image', key: 'krea2-vae', name: 'Qwen VAE', type: 'vae' }; const qwen3VlEncoder: ComponentModelConfig = { diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts index e3e849062f2..7e0a9a4082e 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts @@ -30,6 +30,7 @@ import type { import { getCompatibleDiffusersComponentSource, isBundledMainForBase, + isAnimaInpaintAdapter, isAnimaQwen35Encoder, isAnimaQwen3Encoder, isClipVariant, @@ -57,6 +58,7 @@ import { } from './pid'; import { clampDimension, + DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT, DEFAULT_HIDIFFUSION_T1_RATIO, DEFAULT_HIDIFFUSION_T2_RATIO, DEFAULT_IDEOGRAM4_SAMPLER_PRESET, @@ -458,6 +460,8 @@ export const getDefaultGenerateSettings = (model?: GenerateModelConfig): Generat qwenVLEncoderModel: null, qwen3VLEncoderModel: null, qwen35EncoderModel: null, + animaInpaintAdapterModel: null, + animaInpaintAdapterWeight: DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT, wanT5EncoderModel: null, ideogram4UnconditionalModel: null, wanLowNoiseModel: null, @@ -527,6 +531,7 @@ export type GenerateComponentValueKey = | 'qwenVLEncoderModel' | 'qwen3VLEncoderModel' | 'qwen35EncoderModel' + | 'animaInpaintAdapterModel' | 'wanT5EncoderModel' | 'wanLowNoiseModel' | 'ideogram4UnconditionalModel' @@ -576,6 +581,7 @@ const TYPE_QWEN_VL: ModelTaxonomyType[] = ['qwen_vl_encoder']; const TYPE_QWEN3_VL: ModelTaxonomyType[] = ['qwen3_vl_encoder']; const TYPE_QWEN35: ModelTaxonomyType[] = ['qwen3_5_encoder']; const TYPE_WAN_T5: ModelTaxonomyType[] = ['wan_t5_encoder']; +const TYPE_CONTROLNET: ModelTaxonomyType[] = ['controlnet']; const TYPE_PID_DECODER: ModelTaxonomyType[] = ['pid_decoder']; const TYPE_GEMMA2: ModelTaxonomyType[] = ['gemma2_encoder']; const TYPE_T5: ModelTaxonomyType[] = ['t5_encoder']; @@ -648,6 +654,18 @@ const qwen35EncoderSlot = (helpText: string): ComponentSlotPolicy => filter: wrapFilter(isAnimaQwen35Encoder), }); +/** Optional; only the canvas inpaint/outpaint graphs read it, so it is never required. */ +const animaInpaintAdapterSlot = (): ComponentSlotPolicy => + slot({ + key: 'animaInpaintAdapterModel', + label: 'Inpaint Adapter', + modelTypes: TYPE_CONTROLNET, + valueKind: 'component', + helpText: 'Optional 4-channel ControlNet-LLLite adapter, used only when inpainting or outpainting on the Canvas.', + placeholder: 'None', + filter: wrapFilter(isAnimaInpaintAdapter), + }); + /** Expose PiD slots before enabling it; Z-Image uses FLUX's decoder. */ const pidDecoderSlot = (): ComponentSlotPolicy => slot({ @@ -1074,6 +1092,7 @@ const getBaseComponentSectionPolicy = ( required: () => true, missingMessage: 'Generate needs a VAE for Anima models.', }, + animaInpaintAdapterSlot(), ]); default: return EMPTY_COMPONENT_POLICY; @@ -1113,6 +1132,7 @@ const getComponentPolicyContext = (model: GenerateModelConfig, settings: Generat qwenVLEncoderModel: settings.qwenVLEncoderModel, qwen3VLEncoderModel: settings.qwen3VLEncoderModel, qwen35EncoderModel: settings.qwen35EncoderModel, + animaInpaintAdapterModel: settings.animaInpaintAdapterModel, wanT5EncoderModel: settings.wanT5EncoderModel, wanLowNoiseModel: settings.wanLowNoiseModel, ideogram4UnconditionalModel: settings.ideogram4UnconditionalModel, @@ -1132,6 +1152,7 @@ const COMPONENT_SETTING_LABELS: Record = { qwenVLEncoderModel: 'Qwen VL Encoder', qwen3VLEncoderModel: 'Qwen3-VL Encoder', qwen35EncoderModel: 'Qwen3.5 Encoder', + animaInpaintAdapterModel: 'Inpaint Adapter', wanT5EncoderModel: 'Wan T5 Encoder', wanLowNoiseModel: 'Low-noise expert', ideogram4UnconditionalModel: 'Transformer (Unconditional)', diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.ts index d941d9959c4..4f7b689f1c1 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/addControlLayers.ts @@ -12,6 +12,8 @@ import { createControlValidationSequence } from './controlValidation'; /** The deterministic denoise node id every canvas base graph uses. */ export const CONTROL_DENOISE_NODE_ID = 'denoise_latents'; +const LLLITE_COLLECTOR_NODE_ID = 'control_lllite_collector'; + /** The control-adapter kinds a control layer can carry. */ export type { ControlAdapterKind } from './controlValidation'; @@ -53,6 +55,23 @@ export interface AddControlLayersOptions { layers: readonly ControlLayerGraphInput[]; } +/** + * The one collector every Anima ControlNet-LLLite adapter joins. `anima_denoise.control_lllite` takes one field or a + * list, and the denoiser applies each model once, so control layers and the canvas inpaint adapter share it. + */ +export const getOrAddLliteCollector = ( + graph: BackendGraphContract, + denoise: BackendInvocationContract +): BackendInvocationContract => { + const existing = graph.nodes[LLLITE_COLLECTOR_NODE_ID]; + if (existing) { + return existing; + } + const collector = addNode(graph, { id: LLLITE_COLLECTOR_NODE_ID, type: 'collect' }); + addEdge(graph, collector, 'collection', denoise, 'control_lllite'); + return collector; +}; + /** Resolves the backend node type for a controlnet layer on `base`. */ const controlNetNodeType = (base: string): string => (base === 'flux' ? 'flux_controlnet' : 'controlnet'); @@ -66,7 +85,6 @@ export const addControlLayers = (graph: BackendGraphContract, options: AddContro let controlNetCollector: BackendInvocationContract | null = null; let t2iAdapterCollector: BackendInvocationContract | null = null; - let lliteCollector: BackendInvocationContract | null = null; const validate = createControlValidationSequence({ base, variant: modelVariant }); const ensureControlNetCollector = (): BackendInvocationContract => { @@ -85,15 +103,6 @@ export const addControlLayers = (graph: BackendGraphContract, options: AddContro return t2iAdapterCollector; }; - // `anima_denoise.control_lllite` takes one field or a list; a collector carries any count through one edge. - const ensureLliteCollector = (): BackendInvocationContract => { - if (!lliteCollector) { - lliteCollector = addNode(graph, { id: 'control_lllite_collector', type: 'collect' }); - addEdge(graph, lliteCollector, 'collection', denoise, 'control_lllite'); - } - return lliteCollector; - }; - for (const layer of layers) { const reason = validate({ adapterModel: { ...layer.model, cond_in_channels: layer.modelCondInChannels }, @@ -151,7 +160,7 @@ export const addControlLayers = (graph: BackendGraphContract, options: AddContro type: 'anima_lllite', weight: layer.weight, }); - addEdge(graph, node, 'control', ensureLliteCollector(), 'item'); + addEdge(graph, node, 'control', getOrAddLliteCollector(graph, denoise), 'item'); } else { const node = addNode(graph, { begin_step_percent: layer.beginEndStepPct[0], 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 f0c5d3d695a..373fc7b75f0 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 @@ -9,6 +9,8 @@ import type { import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings } from '@features/generation/core/baseGenerationPolicies'; +import { getPidDecoderBaseForMainBase } from '@features/generation/core/pid'; +import { isPidDecodeNodeType } from '@features/generation/core/pidGraph'; import { describe, expect, it, vi } from 'vitest'; import type { ControlLayerGraphInput } from './addControlLayers'; @@ -414,21 +416,6 @@ describe('compileCanvasGraph', () => { it('rejects external image generators for img2img', () => { expect(() => compile(externalModel, 'img2img')).toThrow('does not support canvas generation'); }); - - it('rejects PiD with an actionable message rather than an internal VAE error', () => { - // PiD lacks the VAE seam expected by canvas compilation, so reject it explicitly. - for (const mode of ['txt2img', 'img2img'] as const) { - expect(() => - compile(sdxlModel, mode, { - settings: { - gemma2EncoderModel: { base: 'any', key: 'g', name: 'Gemma 2', type: 'gemma2_encoder' }, - pidDecoderModel: { base: 'sdxl', key: 'pid', name: 'PiD SDXL', type: 'pid_decoder' }, - pidMode: 'fit', - }, - }) - ).toThrow('PiD decoding is not supported on the canvas yet.'); - } - }); }); describe('validation', () => { @@ -960,24 +947,189 @@ describe('compileCanvasGraph — Anima ControlNet-LLLite (integration)', () => { expect(backendGraph.edges.some((edge) => edge.destination.node_id.startsWith('anima_lllite_'))).toBe(false); }); - it('writes the graph the backend validates against its invocations', async () => { + const inpaintAdapter: ComponentModelConfig = { + base: 'anima', + cond_in_channels: 4, + key: 'anima-inpaint', + name: 'Anima LLLite Inpainting', + type: 'controlnet', + }; + const withAdapter = (weight = 1): Partial => ({ + animaInpaintAdapterModel: inpaintAdapter, + animaInpaintAdapterWeight: weight, + }); + const edgesInto = (graph: BackendGraphContract, nodeId: string) => + graph.edges + .filter((edge) => edge.destination.node_id === nodeId) + .map((edge) => [edge.destination.field, edge.source]); + + it('inpaint: the adapter reads the raster image and the inverted mask, through the collector (legacy addInpaint.ts)', () => { + const { backendGraph } = compile(animaModel, 'inpaint', { settings: withAdapter(0.8) }); + + // The canvas mask is white = keep; img_lerp 255 → 0 makes it white = inpaint for the adapter. + expect(backendGraph.nodes.invert_anima_inpaint_adapter_mask).toEqual({ + id: 'invert_anima_inpaint_adapter_mask', + image: { image_name: 'canvas-mask.png' }, + is_intermediate: true, + max: 0, + min: 255, + type: 'img_lerp', + use_cache: true, + }); + expect(backendGraph.nodes.anima_inpaint_adapter).toEqual({ + control_model: inpaintAdapter, + id: 'anima_inpaint_adapter', + image: { image_name: 'canvas-composite.png' }, + is_intermediate: true, + type: 'anima_lllite', + use_cache: true, + weight: 0.8, + }); + expect(edgesInto(backendGraph, 'anima_inpaint_adapter')).toEqual([ + ['mask', { field: 'image', node_id: 'invert_anima_inpaint_adapter_mask' }], + ]); + expect(getEdge(backendGraph, 'control_lllite_collector', 'item')?.source).toEqual({ + field: 'control', + node_id: 'anima_inpaint_adapter', + }); + expect(getEdge(backendGraph, 'denoise_latents', 'control_lllite')?.source).toEqual({ + field: 'collection', + node_id: 'control_lllite_collector', + }); + // The denoise mask keeps limiting the denoise; the adapter is extra conditioning. + expect(getEdge(backendGraph, 'denoise_latents', 'denoise_mask')?.source.node_id).toBe('create_gradient_mask'); + const metadata = getNodeByType(backendGraph, 'core_metadata'); + expect(metadata?.anima_inpaint_adapter).toEqual(inpaintAdapter); + expect(metadata?.anima_inpaint_adapter_weight).toBe(0.8); + }); + + it('inpaint with Scale Before Processing: the adapter reads the resized image and mask', () => { + const { backendGraph } = compile(animaModel, 'inpaint', { + scaling: { height: 1024, method: 'manual', width: 1024 }, + settings: withAdapter(), + }); + + expect(backendGraph.nodes.invert_anima_inpaint_adapter_mask?.image).toBeUndefined(); + expect(backendGraph.nodes.anima_inpaint_adapter?.image).toBeUndefined(); + expect(getEdge(backendGraph, 'invert_anima_inpaint_adapter_mask', 'image')?.source.node_id).toBe( + 'canvas_resize_mask_to_processing' + ); + expect(getEdge(backendGraph, 'anima_inpaint_adapter', 'image')?.source.node_id).toBe( + 'canvas_resize_initial_to_processing' + ); + }); + + it('shares the collector with control layers, each adapter once', () => { + const { backendGraph } = compile(animaModel, 'inpaint', { + controlLayers: [lliteLayer('sketch-layer', 'sketch')], + settings: withAdapter(), + }); + + expect( + Object.values(backendGraph.nodes) + .filter((node) => node.type === 'collect') + .map((node) => node.id) + ).toEqual(['pos_cond_collect', 'neg_cond_collect', 'control_lllite_collector']); + expect( + backendGraph.edges + .filter((edge) => edge.destination.node_id === 'control_lllite_collector') + .map((edge) => edge.source.node_id) + ).toEqual(['anima_inpaint_adapter', 'anima_lllite_sketch-layer']); + expect(backendGraph.edges.filter((edge) => edge.destination.field === 'control_lllite')).toHaveLength(1); + }); + + it('outpaint: the adapter reads the infilled image and the alpha-derived or combined mask (legacy addOutpaint.ts)', () => { + const alphaOnly = compile(animaModel, 'outpaint', { maskImageName: null, settings: withAdapter() }).backendGraph; + expect(getEdge(alphaOnly, 'anima_inpaint_adapter', 'image')?.source.node_id).toBe('infill'); + expect(getEdge(alphaOnly, 'invert_anima_inpaint_adapter_mask', 'image')?.source.node_id).toBe( + 'image_alpha_to_mask' + ); + expect(alphaOnly.nodes.anima_inpaint_adapter?.image).toBeUndefined(); + expect(getNodeByType(alphaOnly, 'core_metadata')?.anima_inpaint_adapter_weight).toBe(1); + + const combined = compile(animaModel, 'outpaint', { + maskImageName: 'canvas-mask.png', + settings: withAdapter(), + }).backendGraph; + expect(getEdge(combined, 'invert_anima_inpaint_adapter_mask', 'image')?.source.node_id).toBe('mask_combine'); + expect(getEdge(combined, 'anima_inpaint_adapter', 'mask')?.source.node_id).toBe( + 'invert_anima_inpaint_adapter_mask' + ); + }); + + it.each(['inpaint', 'outpaint'] as const)('leaves the %s graph unchanged without an adapter', (mode) => { + // Only the metadata node id is minted per compile; pin it so the graphs compare structurally. + const pinned = (graph: BackendGraphContract) => { + const metadataId = getNodeByType(graph, 'core_metadata')!.id; + return JSON.parse(JSON.stringify({ edges: graph.edges, nodes: graph.nodes }).replaceAll(metadataId, 'metadata')); + }; + const unset = compile(animaModel, mode, { settings: { animaInpaintAdapterModel: null } }).backendGraph; + const defaults = compile(animaModel, mode).backendGraph; + + expect(pinned(unset)).toEqual(pinned(defaults)); + expect(unset.nodes.anima_inpaint_adapter).toBeUndefined(); + expect(getEdge(unset, 'denoise_latents', 'control_lllite')).toBeUndefined(); + expect(getNodeByType(unset, 'core_metadata')?.anima_inpaint_adapter).toBeUndefined(); + }); + + it('ignores a picked adapter outside inpaint/outpaint and outside Anima', () => { + for (const mode of ['txt2img', 'img2img'] as const) { + const { backendGraph } = compile(animaModel, mode, { settings: withAdapter() }); + expect(backendGraph.nodes.anima_inpaint_adapter).toBeUndefined(); + expect(getNodeByType(backendGraph, 'core_metadata')?.anima_inpaint_adapter).toBeUndefined(); + } + const { backendGraph } = compile(sdxlModel, 'inpaint', { settings: withAdapter() }); + expect(backendGraph.nodes.anima_inpaint_adapter).toBeUndefined(); + expect(getNodeByType(backendGraph, 'anima_lllite')).toBeUndefined(); + }); + + it('rejects a control (3-channel) adapter and an out-of-range weight', () => { + expect(() => + compile(animaModel, 'inpaint', { + settings: { animaInpaintAdapterModel: { ...inpaintAdapter, cond_in_channels: 3 } }, + }) + ).toThrow('The Anima inpaint adapter must be a 4-channel Anima ControlNet-LLLite model.'); + expect(() => compile(animaModel, 'outpaint', { settings: withAdapter(10.5) })).toThrow( + 'Anima inpaint adapter weight must be between -10 and 10.' + ); + // Installs that predate `cond_in_channels` are inpaint adapters (legacy isAnimaInpaintControlNetModelConfig). + expect(() => + compile(animaModel, 'inpaint', { + settings: { animaInpaintAdapterModel: { ...inpaintAdapter, cond_in_channels: null } }, + }) + ).not.toThrow(); + }); + + it('writes the graphs the backend validates against its invocations', async () => { // Ids minted from the clock and Math.random are pinned so the fixture changes only when the graph does. const clock = vi.spyOn(Date, 'now').mockReturnValue(0); const random = vi.spyOn(Math, 'random').mockReturnValue(0.5); // Installed models always carry a hash, which the backend's model identifier requires. const hashed = (config: T, hash: string): T => ({ ...config, hash }); - let backendGraph: BackendGraphContract; + const settings = { + qwen3EncoderModel: hashed(qwen3Encoder, 'blake3:qwen3'), + vae: hashed(qwenImageVae, 'blake3:vae'), + }; + let threeAdapters: BackendGraphContract; + let inpaintAdapterGraph: BackendGraphContract; try { // Inpainting exercises the whole denoise wiring the adapters join; one collector serves any adapter count. - backendGraph = compile(hashed(animaModel, 'blake3:anima'), 'inpaint', { + threeAdapters = compile(hashed(animaModel, 'blake3:anima'), 'inpaint', { controlLayers: [ lliteLayer('sketch-layer', 'sketch', 0.8), lliteLayer('depth-layer', 'depth', -1), lliteLayer('pose-layer', 'pose', 2), ], + settings, + }).backendGraph; + // The inpaint adapter beside a control layer: the masked adapter and the unmasked one share the collector. + inpaintAdapterGraph = compile(hashed(animaModel, 'blake3:anima'), 'outpaint', { + controlLayers: [lliteLayer('sketch-layer', 'sketch', 0.8)], + maskImageName: 'canvas-mask.png', settings: { - qwen3EncoderModel: hashed(qwen3Encoder, 'blake3:qwen3'), - vae: hashed(qwenImageVae, 'blake3:vae'), + ...settings, + animaInpaintAdapterModel: hashed(inpaintAdapter, 'blake3:inpaint'), + animaInpaintAdapterWeight: 0.75, }, }).backendGraph; } finally { @@ -986,8 +1138,9 @@ describe('compileCanvasGraph — Anima ControlNet-LLLite (integration)', () => { } const fixture = { _comment: - 'Generated by compileCanvasGraph.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_frontend_anima_lllite_graphs.py, which validates the graph against the backend invocations.', - inpaint_three_adapters: { ...backendGraph, id: 'anima_lllite_canvas' }, + 'Generated by compileCanvasGraph.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_frontend_anima_lllite_graphs.py, which validates the graphs against the backend invocations.', + inpaint_three_adapters: { ...threeAdapters, id: 'anima_lllite_canvas' }, + outpaint_inpaint_adapter_and_control_layer: { ...inpaintAdapterGraph, id: 'anima_lllite_canvas_inpaint_adapter' }, }; await expect(`${JSON.stringify(fixture, null, 2)}\n`).toMatchFileSnapshot( '../__snapshots__/animaLLLiteCanvasGraphs.json' @@ -995,6 +1148,147 @@ describe('compileCanvasGraph — Anima ControlNet-LLLite (integration)', () => { }); }); +describe('compileCanvasGraph — PiD decode', () => { + const gemma2Encoder: ComponentModelConfig = { base: 'any', key: 'gemma2', name: 'Gemma 2', type: 'gemma2_encoder' }; + const pidSettings = (model: MainModelConfig, pidMode: 'fit' | 'native'): Partial => ({ + gemma2EncoderModel: gemma2Encoder, + pidDecoderModel: { + base: getPidDecoderBaseForMainBase(model.base) as string, + key: `pid-${model.base}`, + name: `PiD ${model.base}`, + type: 'pid_decoder', + }, + pidMode, + pidSteps: 3, + }); + const pidDecodeNode = (graph: BackendGraphContract) => + Object.values(graph.nodes).find((node) => isPidDecodeNodeType(node.type)); + const edgeSources = (graph: BackendGraphContract, targetNodeId: string) => + Object.fromEntries( + graph.edges + .filter((edge) => edge.destination.node_id === targetNodeId) + .map((edge) => [edge.destination.field, [graph.nodes[edge.source.node_id]?.type, edge.source.field]]) + ); + const PID_BASES = [sdxlModel, sd3Model, fluxModel, flux2Model, qwenImageModel, zImageModel]; + + it('txt2img fit: the PiD chain replaces the VAE decode and a resize back to the bbox owns canvas_output', () => { + const { backendGraph } = compile(sdxlModel, 'txt2img', { settings: pidSettings(sdxlModel, 'fit') }); + + expect(getNodeByType(backendGraph, 'l2i')).toBeUndefined(); + const decode = pidDecodeNode(backendGraph); + expect(decode?.type).toBe('sdxl_pid_decode'); + expect(decode?.num_inference_steps).toBe(3); + expect(decode?.is_intermediate).toBe(true); + expect(edgeSources(backendGraph, decode!.id)).toEqual({ + gemma2_encoder: ['gemma2_encoder_loader', 'gemma2_encoder'], + latents: ['denoise_latents', 'latents'], + pid_decoder: ['pid_decoder_loader', 'pid_decoder'], + prompt: ['string', 'value'], + seed: ['integer', 'value'], + vae: ['sdxl_model_loader', 'vae'], + }); + expect(getNodeByType(backendGraph, 'gemma2_encoder_loader')?.gemma2_model).toEqual(gemma2Encoder); + // Fit generates at the bbox; the 4x decode is scaled back to it. + expect(backendGraph.nodes.noise?.width).toBe(bbox.width); + expect(backendGraph.nodes.noise?.height).toBe(bbox.height); + expect(backendGraph.nodes.canvas_output?.type).toBe('img_resize'); + expect(backendGraph.nodes.canvas_output?.width).toBe(bbox.width); + expect(backendGraph.nodes.canvas_output?.height).toBe(bbox.height); + expect(getEdge(backendGraph, 'canvas_output', 'image')?.source.node_id).toBe(decode?.id); + const metadata = getNodeByType(backendGraph, 'core_metadata'); + expect(metadata?.pid_mode).toBe('fit'); + expect(metadata?.pid_steps).toBe(3); + expect(metadata?.width).toBe(bbox.width); + expect(metadata?.height).toBe(bbox.height); + }); + + it('txt2img native: denoises at a quarter of the bbox and the PiD decode owns canvas_output unresized', () => { + const { backendGraph } = compile(fluxModel, 'txt2img', { settings: pidSettings(fluxModel, 'native') }); + + expect(backendGraph.nodes.denoise_latents?.width).toBe(bbox.width / 4); + expect(backendGraph.nodes.denoise_latents?.height).toBe(bbox.height / 4); + expect(backendGraph.nodes.canvas_output?.type).toBe('flux_pid_decode'); + expect(backendGraph.nodes.canvas_output?.is_intermediate).toBe(true); + expect(getNodeByType(backendGraph, 'img_resize')).toBeUndefined(); + expect(getNodeByType(backendGraph, 'core_metadata')?.pid_mode).toBe('native'); + expect(getNodeByType(backendGraph, 'core_metadata')?.width).toBe(bbox.width); + }); + + it('img2img fit: encodes the composite at the bbox with the VAE the PiD decode reads', () => { + const { backendGraph } = compile(sdxlModel, 'img2img', { settings: pidSettings(sdxlModel, 'fit'), strength: 0.5 }); + + const encode = backendGraph.nodes.canvas_i2l; + expect(encode?.type).toBe('i2l'); + expect(encode?.image).toEqual({ image_name: 'canvas-composite.png' }); + expect(getEdge(backendGraph, 'canvas_i2l', 'vae')?.source).toEqual({ field: 'vae', node_id: 'model_loader' }); + expect(getEdge(backendGraph, pidDecodeNode(backendGraph)!.id, 'vae')?.source.node_id).toBe('model_loader'); + expect(getEdge(backendGraph, 'denoise_latents', 'latents')?.source.node_id).toBe('canvas_i2l'); + expect(backendGraph.nodes.denoise_latents?.denoising_start).toBe(0.5); + expect(backendGraph.nodes.canvas_output?.type).toBe('img_resize'); + expect(getNodeByType(backendGraph, 'core_metadata')?.generation_mode).toBe('sdxl_img2img'); + expect(getNodeByType(backendGraph, 'core_metadata')?.strength).toBe(0.5); + }); + + it('img2img native: downscales the composite to the denoise size before encoding and decodes straight up to the bbox (legacy addPidImageToImageNative)', () => { + const { backendGraph } = compile(fluxModel, 'img2img', { settings: pidSettings(fluxModel, 'native') }); + + const resizeIn = backendGraph.nodes.canvas_resize_initial_to_processing; + expect(resizeIn?.type).toBe('img_resize'); + expect(resizeIn?.image).toEqual({ image_name: 'canvas-composite.png' }); + expect(resizeIn?.width).toBe(bbox.width / 4); + expect(resizeIn?.height).toBe(bbox.height / 4); + expect(getEdge(backendGraph, 'canvas_i2l', 'image')?.source.node_id).toBe('canvas_resize_initial_to_processing'); + expect(backendGraph.nodes.canvas_i2l?.type).toBe('flux_vae_encode'); + // flux_pid_decode takes no VAE; the encode reads the model loader's, as the FLUX builder decodes with it. + expect(getEdge(backendGraph, 'canvas_i2l', 'vae')?.source).toEqual({ field: 'vae', node_id: 'model_loader' }); + expect(backendGraph.nodes.denoise_latents?.width).toBe(bbox.width / 4); + expect(backendGraph.nodes.canvas_output?.type).toBe('flux_pid_decode'); + expect(backendGraph.nodes.canvas_resize_generated_to_bbox).toBeUndefined(); + expect(backendGraph.nodes.canvas_processing_output).toBeUndefined(); + }); + + it.each(PID_BASES)('resolves the encode VAE for a $base native img2img', (model) => { + const { backendGraph } = compile(model, 'img2img', { + settings: { ...COMPONENT_OVERRIDES[model.base], ...pidSettings(model, 'native') }, + }); + + const vaeSource = getEdge(backendGraph, 'canvas_i2l', 'vae')?.source; + expect(vaeSource?.field).toBe('vae'); + expect(backendGraph.nodes[vaeSource!.node_id]?.type).toMatch(/model_loader$/); + expect(backendGraph.nodes.canvas_output?.type).toBe(pidDecodeNode(backendGraph)?.type); + }); + + it('rejects inpaint and outpaint with the legacy message (buildSDXLGraph.ts:197)', () => { + for (const mode of ['inpaint', 'outpaint'] as const) { + expect(() => compile(sdxlModel, mode, { settings: pidSettings(sdxlModel, 'fit') })).toThrow( + 'PiD decode currently supports Text to Image and Image to Image only. Disable PiD for Inpaint/Outpaint.' + ); + } + }); + + it('rejects Scale Before Processing when it changes the processing size (buildSDXLGraph.ts:203)', () => { + expect(() => + compile(sdxlModel, 'txt2img', { + scaling: { height: null, method: 'auto', width: null }, + settings: pidSettings(sdxlModel, 'fit'), + }) + ).toThrow('Turn off Scale Before Processing (set it to None) to use PiD decode.'); + expect(() => + compile(sdxlModel, 'img2img', { + scaling: { height: 512, method: 'manual', width: 512 }, + settings: pidSettings(sdxlModel, 'native'), + }) + ).toThrow('Turn off Scale Before Processing (set it to None) to use PiD decode.'); + // A manual size equal to the bbox leaves the generation size alone, which legacy accepted. + expect(() => + compile(sdxlModel, 'img2img', { + scaling: { height: bbox.height, method: 'manual', width: bbox.width }, + settings: pidSettings(sdxlModel, 'native'), + }) + ).not.toThrow(); + }); +}); + describe('compileCanvasGraph — regional guidance', () => { const region = (id: string, overrides: Partial = {}) => ({ autoNegative: false, diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts index 8155895bd1e..6894505ef0a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts @@ -5,13 +5,14 @@ import type { SupportedGenerateBase } from '@features/generation/core/baseGenerationPolicies'; import type { BackendGraphContract, BackendInvocationContract } from '@features/generation/core/contracts'; -import type { GenerateModelConfig, GenerateSettings } from '@features/generation/core/types'; +import type { ComponentModelConfig, GenerateModelConfig, GenerateSettings } from '@features/generation/core/types'; import { getGenerationValidationReasons } from '@features/generation/core/baseGenerationPolicies'; -import { GRAPH_BUILDERS } from '@features/generation/core/graph'; +import { findVaeSource, GRAPH_BUILDERS } from '@features/generation/core/graph'; import { addEdge, addNode, toGraphContract } from '@features/generation/core/graphBuilder'; import { addKrea2ConditioningEnhancers } from '@features/generation/core/krea2Conditioning'; -import { getIsPidSupportedBase } from '@features/generation/core/pid'; +import { getIsPidActive } from '@features/generation/core/pid'; +import { MAX_ANIMA_INPAINT_ADAPTER_WEIGHT, MIN_ANIMA_INPAINT_ADAPTER_WEIGHT } from '@features/generation/core/settings'; import type { CanvasCompositingSettings, @@ -20,9 +21,10 @@ import type { CompiledCanvasGraph, } from './types'; -import { addControlLayers } from './addControlLayers'; +import { addControlLayers, getOrAddLliteCollector } from './addControlLayers'; import { addRegionalGuidance, isRegionalGuidanceSupportedForBase } from './addRegionalGuidance'; import { type CanvasSize, resolveCanvasProcessingSize } from './canvasProcessingSize'; +import { isAnimaInpaintAdapterModel } from './controlValidation'; /** Map bases to encode nodes; SD families share i2l. */ const CANVAS_I2L_NODE_TYPES: Partial> = { @@ -57,9 +59,39 @@ const isImageMode = (mode: CompileCanvasGraphInput['mode']): boolean => mode !== const sizesMatch = (left: CanvasSize, right: CanvasSize): boolean => left.width === right.width && left.height === right.height; +/** A node feeding an input through an edge, or a fixed image field when no node produces it. */ +type ImageSource = { node: BackendInvocationContract; field: string } | { image_name: string }; + +/** Whether an Anima inpaint/outpaint graph gets the adapter: it is picked, and the base is Anima. */ +const hasAnimaInpaintAdapter = (model: GenerateModelConfig, settings: GenerateSettings): boolean => + model.base === 'anima' && settings.animaInpaintAdapterModel !== null; + +/** + * The legacy PiD guards (`buildSDXLGraph.ts:192–205`, repeated in the other builders): only the txt2img and img2img + * graphs decode through PiD, and Scale Before Processing must leave the bbox alone because PiD already decodes at + * 4× the generation size. + */ +const getCanvasPidValidationReasons = (input: CompileCanvasGraphInput, processingSize: CanvasSize): string[] => { + const { bbox, mode, model, settings } = input; + const reasons: string[] = []; + + if (mode === 'inpaint' || mode === 'outpaint') { + reasons.push( + 'PiD decode currently supports Text to Image and Image to Image only. Disable PiD for Inpaint/Outpaint.' + ); + } + + const unscaledSize = resolveCanvasProcessingSize(model, settings.pidMode, bbox, undefined); + if (!sizesMatch(processingSize, unscaledSize)) { + reasons.push('Turn off Scale Before Processing (set it to None) to use PiD decode.'); + } + + return reasons; +}; + /** Canvas-specific validation reasons layered on top of the shared generate ones. */ -const getCanvasValidationReasons = (input: CompileCanvasGraphInput): string[] => { - const { bbox, compositeImageName, maskImageName, mode, model, strength } = input; +const getCanvasValidationReasons = (input: CompileCanvasGraphInput, processingSize: CanvasSize): string[] => { + const { bbox, compositeImageName, maskImageName, mode, model, settings, strength } = input; const reasons: string[] = []; if (model.type === 'external_image_generator') { @@ -67,9 +99,24 @@ const getCanvasValidationReasons = (input: CompileCanvasGraphInput): string[] => return reasons; } - // Reject canvas PiD because its graph lacks the expected VAE/decode seam. - if (input.settings.pidMode !== 'off' && getIsPidSupportedBase(model.base)) { - reasons.push('PiD decoding is not supported on the canvas yet. Turn PiD off to generate here.'); + if (getIsPidActive(settings.pidMode, model.base)) { + reasons.push(...getCanvasPidValidationReasons(input, processingSize)); + } + + if ((mode === 'inpaint' || mode === 'outpaint') && hasAnimaInpaintAdapter(model, settings)) { + if (!isAnimaInpaintAdapterModel(settings.animaInpaintAdapterModel as ComponentModelConfig)) { + reasons.push('The Anima inpaint adapter must be a 4-channel Anima ControlNet-LLLite model.'); + } + const weight = settings.animaInpaintAdapterWeight; + if ( + !Number.isFinite(weight) || + weight < MIN_ANIMA_INPAINT_ADAPTER_WEIGHT || + weight > MAX_ANIMA_INPAINT_ADAPTER_WEIGHT + ) { + reasons.push( + `Anima inpaint adapter weight must be between ${MIN_ANIMA_INPAINT_ADAPTER_WEIGHT} and ${MAX_ANIMA_INPAINT_ADAPTER_WEIGHT}.` + ); + } } if (!Number.isFinite(bbox.width) || !Number.isFinite(bbox.height) || bbox.width <= 0 || bbox.height <= 0) { @@ -117,15 +164,27 @@ const findEdgeSource = ( return edge && node ? { field: edge.source.field, node } : null; }; -/** Resolves the VAE source feeding the graph's decode node (throws if absent). */ +/** Resolves the VAE source the base graph decodes with, for the encode side (throws if absent). */ const requireVaeSource = (graph: BackendGraphContract): { node: BackendInvocationContract; field: string } => { - const source = findEdgeSource(graph, 'canvas_output', 'vae'); + const source = findVaeSource(graph); if (!source) { throw new Error('Canvas generation could not resolve a VAE source in the base graph.'); } return source; }; +/** The size the base graph denoises at: the `noise` node carries it for SD families, the denoise node elsewhere. */ +const getDenoiseSize = (graph: BackendGraphContract, denoise: BackendInvocationContract): CanvasSize => { + const { height, width } = graph.nodes.noise ?? denoise; + if (typeof width !== 'number' || typeof height !== 'number') { + throw new TypeError('Canvas generation could not read the denoise size from the base graph.'); + } + return { height, width }; +}; + +const findMetadataNode = (graph: BackendGraphContract): BackendInvocationContract | undefined => + Object.values(graph.nodes).find((node) => node.type === 'core_metadata'); + /** Rename every matching node ID and edge reference. */ const renameNode = (graph: BackendGraphContract, oldId: string, newId: string): BackendInvocationContract => { const node = graph.nodes[oldId]; @@ -180,7 +239,7 @@ const resizeCanvasOutputToBbox = ( /** Keeps saved-image metadata aligned to the bbox footprint, not the internal processing size. */ const setCanvasMetadataDimensions = (graph: BackendGraphContract, bbox: CompileCanvasGraphInput['bbox']): void => { - const metadata = Object.values(graph.nodes).find((node) => node.type === 'core_metadata'); + const metadata = findMetadataNode(graph); if (metadata) { metadata.width = bbox.width; metadata.height = bbox.height; @@ -189,7 +248,7 @@ const setCanvasMetadataDimensions = (graph: BackendGraphContract, bbox: CompileC /** Sets the metadata `generation_mode` variant + strength (legacy parity). */ const setMetadataMode = (graph: BackendGraphContract, mode: string, strength: number): void => { - const metadata = Object.values(graph.nodes).find((node) => node.type === 'core_metadata'); + const metadata = findMetadataNode(graph); if (metadata) { if (typeof metadata.generation_mode === 'string') { metadata.generation_mode = metadata.generation_mode.replace('txt2img', mode); @@ -258,17 +317,19 @@ const graftImageToImage = ( settings: GenerateSettings, compositeImageName: string, strength: number, - bbox: CompileCanvasGraphInput['bbox'], - processingSize: CanvasSize + bbox: CompileCanvasGraphInput['bbox'] ): void => { const i2lType = requireI2lType(model); const denoise = requireDenoise(graph); const vaeSource = requireVaeSource(graph); - const needsResize = !sizesMatch(bbox, processingSize); + // The source image is encoded at the size the graph denoises at. That is the processing size, except under + // native PiD, which denoises at a quarter of it and decodes straight back up (legacy `addPidImageToImageNative`). + const encodeSize = getDenoiseSize(graph, denoise); + const needsResize = !sizesMatch(bbox, encodeSize); const encode = addEncodeNode(graph, i2lType, settings, needsResize ? null : compositeImageName); if (needsResize) { - const resize = addInputResizeNode(graph, 'canvas_resize_initial_to_processing', compositeImageName, processingSize); + const resize = addInputResizeNode(graph, 'canvas_resize_initial_to_processing', compositeImageName, encodeSize); addEdge(graph, resize, 'image', encode, 'image'); } @@ -310,6 +371,55 @@ const addInfillNode = ( } }; +/** + * Anima's 4-channel ControlNet-LLLite inpaint adapter: it conditions on the raster image and the region to inpaint + * (legacy `addInpaint.ts` / `addOutpaint.ts`). The canvas mask is white = keep, the adapter expects white = + * inpaint, so `img_lerp` with `min: 255, max: 0` inverts it first. The adapter joins the control layers' collector; + * `anima_denoise` applies each LLLite model once, and control layers admit only 3-channel models, so they never + * collide. + */ +const addAnimaInpaintAdapter = ( + graph: BackendGraphContract, + args: { + denoise: BackendInvocationContract; + image: ImageSource; + mask: ImageSource; + model: ComponentModelConfig; + weight: number; + } +): void => { + const { denoise, image, mask, model, weight } = args; + const invertMask = addNode(graph, { + id: 'invert_anima_inpaint_adapter_mask', + ...('image_name' in mask ? { image: mask } : {}), + max: 0, + min: 255, + type: 'img_lerp', + }); + if ('node' in mask) { + addEdge(graph, mask.node, mask.field, invertMask, 'image'); + } + + const adapter = addNode(graph, { + control_model: model, + id: 'anima_inpaint_adapter', + ...('image_name' in image ? { image } : {}), + type: 'anima_lllite', + weight, + }); + if ('node' in image) { + addEdge(graph, image.node, image.field, adapter, 'image'); + } + addEdge(graph, invertMask, 'image', adapter, 'mask'); + addEdge(graph, adapter, 'control', getOrAddLliteCollector(graph, denoise), 'item'); + + const metadata = findMetadataNode(graph); + if (metadata) { + metadata.anima_inpaint_adapter = model; + metadata.anima_inpaint_adapter_weight = weight; + } +}; + /** outputOnlyMaskedRegions selects transparent output versus compositing with the original. */ const graftMaskTail = ( graph: BackendGraphContract, @@ -328,9 +438,11 @@ const graftMaskTail = ( /** Either a fixed mask image field or an edge-fed source node. */ gradientMaskImage?: { image_name: string }; gradientMaskEdge?: { node: BackendInvocationContract; field: string }; + /** The raster image the Anima inpaint adapter conditions on; the composite itself when absent. */ + adapterImageEdge?: { node: BackendInvocationContract; field: string }; } ): void => { - const { compositing, denoise, destination, i2lType, initialImageName, settings, vaeSource } = args; + const { compositing, denoise, destination, i2lType, initialImageName, model, settings, vaeSource } = args; const needsResize = !sizesMatch(args.bbox, args.processingSize); // The final output owns canvas_output; the base decoder becomes intermediate. @@ -362,6 +474,16 @@ const graftMaskTail = ( } addEdge(graph, gradientMask, 'denoise_mask', denoise, 'denoise_mask'); + if (hasAnimaInpaintAdapter(model, settings)) { + addAnimaInpaintAdapter(graph, { + denoise, + image: args.adapterImageEdge ?? { image_name: initialImageName }, + mask: args.gradientMaskEdge ?? (args.gradientMaskImage as { image_name: string }), + model: settings.animaInpaintAdapterModel as ComponentModelConfig, + weight: settings.animaInpaintAdapterWeight, + }); + } + const expandMask = addNode(graph, { fade_size_px: compositing.maskBlur, id: 'expand_mask', @@ -496,6 +618,7 @@ const graftInpaint = ( addEdge(graph, encode, 'latents', denoise, 'latents'); graftMaskTail(graph, { + ...(initialResize ? { adapterImageEdge: { field: 'image', node: initialResize } } : {}), bbox: input.bbox, compositing, denoise, @@ -591,6 +714,7 @@ const graftOutpaint = ( addEdge(graph, encode, 'latents', denoise, 'latents'); graftMaskTail(graph, { + adapterImageEdge: { field: 'image', node: infill }, bbox: input.bbox, compositing, denoise, @@ -614,7 +738,10 @@ export const compileCanvasGraph = (input: CompileCanvasGraphInput): CompiledCanv const processingSize = resolveCanvasProcessingSize(model, input.settings.pidMode, bbox, input.scaling); const settings = withProcessingDimensions(input.settings, processingSize); - const validationReasons = [...getCanvasValidationReasons(input), ...getGenerationValidationReasons(model, settings)]; + const validationReasons = [ + ...getCanvasValidationReasons(input, processingSize), + ...getGenerationValidationReasons(model, settings), + ]; if (validationReasons.length > 0) { throw new Error(validationReasons[0]); @@ -637,7 +764,7 @@ export const compileCanvasGraph = (input: CompileCanvasGraphInput): CompiledCanv // Validation above guarantees the composite/mask images required per mode. if (mode === 'img2img') { - graftImageToImage(backendGraph, model, settings, compositeImageName as string, strength, bbox, processingSize); + graftImageToImage(backendGraph, model, settings, compositeImageName as string, strength, bbox); } else if (mode === 'inpaint') { graftInpaint(backendGraph, input, compositing, processingSize); } else if (mode === 'outpaint') { 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 57fc75aeb4f..a337ed16150 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 @@ -5,6 +5,7 @@ import { createControlValidationSequence, getControlValidationReason, getSuggestedControlKind, + isAnimaInpaintAdapterModel, isControlModelUsableForKind, } from './controlValidation'; @@ -183,6 +184,19 @@ describe('Anima ControlNet-LLLite validation', () => { expect(getControlValidationReason({ ...lllite, weight: -1.01 })).toBe('invalid_adapter_values'); }); + it('admits the inpaint adapter slot to 4-channel and pre-field installs, never to control adapters', () => { + const adapter = { base: 'anima', cond_in_channels: 4, type: 'controlnet' }; + expect(isAnimaInpaintAdapterModel(adapter)).toBe(true); + expect(isAnimaInpaintAdapterModel({ ...adapter, cond_in_channels: null })).toBe(true); + expect(isAnimaInpaintAdapterModel({ base: 'anima', type: 'controlnet' })).toBe(true); + expect(isAnimaInpaintAdapterModel({ ...adapter, cond_in_channels: 3 })).toBe(false); + expect(isAnimaInpaintAdapterModel({ ...adapter, base: 'sdxl' })).toBe(false); + expect(isAnimaInpaintAdapterModel({ ...adapter, type: 'lora' })).toBe(false); + // The two slots partition the installed Anima LLLite models. + expect(isControlModelUsableForKind(adapter, 'anima_lllite')).toBe(false); + expect(isControlModelUsableForKind({ ...adapter, cond_in_channels: 3 }, 'anima_lllite')).toBe(true); + }); + it('offers only models a control layer of the kind can run', () => { const control = { base: 'anima', cond_in_channels: 3, type: 'controlnet' }; expect(isControlModelUsableForKind(control, 'anima_lllite')).toBe(true); 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 5841d39891b..95957ea238d 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/controlValidation.ts @@ -79,6 +79,16 @@ export const getControlModelUnusableReason = ( return null; }; +/** + * Whether an Anima ControlNet-LLLite model is the inpainting adapter the canvas inpaint/outpaint graphs feed with + * the raster image and mask. The 4-channel (RGB + mask) variants are inpaint adapters; an install that predates + * `cond_in_channels` reads null, and only the inpaint variant predates the field, so it counts too (legacy + * `isAnimaInpaintControlNetModelConfig`). Control layers reject the same models through + * {@link getControlModelUnusableReason}. + */ +export const isAnimaInpaintAdapterModel = (model: ControlAdapterModelFacts): boolean => + model.type === 'controlnet' && model.base === 'anima' && model.cond_in_channels !== 3; + export const getControlValidationReason = (params: { adapterModel: ControlAdapterModelFacts | null; beginEndStepPct: [number, number]; diff --git a/invokeai/frontend/webv2/src/features/generation/core/componentCompatibility.ts b/invokeai/frontend/webv2/src/features/generation/core/componentCompatibility.ts index 32b3ed6fd6d..ca7e4e25504 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/componentCompatibility.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/componentCompatibility.ts @@ -1,11 +1,14 @@ import type { ComponentModelConfig, GenerateModelConfig, VaeModelConfig } from './types'; import { getArchitectureCapabilityRow } from './architectureCapabilities'; +import { isAnimaInpaintAdapterModel } from './canvas/controlValidation'; export type GenerateComponentCandidate = { base: string; /** Which Ideogram 4 transformer branch a single file holds. Only `ideogram-4` mains carry it. */ branch?: unknown; + /** Anima ControlNet-LLLite only: 3 = control image, 4 = inpainting; null for installs that predate the field. */ + cond_in_channels?: number | null; format?: string; key?: string; /** VAE latent width. Only `wan` ships more than one, and its two are different decoders. */ @@ -81,6 +84,9 @@ export const isAnimaQwen35Encoder: GenerateComponentFilter = (model) => export const isNonAnimaQwen3Encoder: GenerateComponentFilter = (model) => model.type === 'qwen3_encoder' && model.variant !== 'qwen3_06b'; +/** The optional 4-channel ControlNet-LLLite adapter the canvas feeds during Anima inpainting and outpainting. */ +export const isAnimaInpaintAdapter: GenerateComponentFilter = (model) => isAnimaInpaintAdapterModel(model); + /** Ministral and Mistral encoders require distinct variants. */ const MINISTRAL_3B_VARIANT = 'ministral3_3b'; diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.ts index 02be3aae118..2fbcdc5045a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.ts @@ -52,7 +52,7 @@ import { toGraphContract, } from './graphBuilder'; import { addKrea2ConditioningEnhancers } from './krea2Conditioning'; -import { addPidDecode, getPidDenoiseSize, getPidMetadata, shouldUsePidDecode } from './pidGraph'; +import { addPidDecode, getPidDenoiseSize, getPidMetadata, isPidDecodeNodeType, shouldUsePidDecode } from './pidGraph'; import { getEffectiveReferenceImage } from './referenceImage'; const getCompatibleComponentSource = ( @@ -529,6 +529,30 @@ const addDecodeOutput = ({ return output; }; +/** + * The node and field a built graph decodes with, for grafting an encode onto it. An ordinary decode reads it on + * `canvas_output.vae`; a PiD decode reads it only when its node type takes a `vae` input. The FLUX and SD-3 PiD + * decodes take none, and those builders decode with the model loader, which the fallback names. Null when no builder + * convention applies. + */ +export const findVaeSource = ( + graph: BackendGraphContract +): { node: BackendInvocationContract; field: string } | null => { + const edge = graph.edges.find( + (candidate) => + candidate.destination.field === 'vae' && + (candidate.destination.node_id === 'canvas_output' || + isPidDecodeNodeType(graph.nodes[candidate.destination.node_id]?.type)) + ); + if (edge) { + const node = graph.nodes[edge.source.node_id]; + return node ? { field: edge.source.field, node } : null; + } + const modelLoader = graph.nodes.model_loader; + const decodesWithPid = Object.values(graph.nodes).some((node) => isPidDecodeNodeType(node.type)); + return modelLoader && decodesWithPid ? { field: 'vae', node: modelLoader } : null; +}; + /** Native PiD denoises at one quarter of the requested target dimensions. */ const getDenoiseSize = (settings: GenerateSettings, model: MainModelConfig): { width: number; height: number } => // Use the model-native grid for divided dimensions, not the UI's PiD-scaled grid. diff --git a/invokeai/frontend/webv2/src/features/generation/core/pidGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/pidGraph.ts index e59c47937ca..03d1ed3fa44 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/pidGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/pidGraph.ts @@ -19,6 +19,11 @@ export const PID_DECODE_NODE_TYPES = { export type PidDecodeNodeType = (typeof PID_DECODE_NODE_TYPES)[keyof typeof PID_DECODE_NODE_TYPES]; +const PID_DECODE_NODE_TYPE_SET: ReadonlySet = new Set(Object.values(PID_DECODE_NODE_TYPES)); + +export const isPidDecodeNodeType = (type: string | undefined): type is PidDecodeNodeType => + type !== undefined && PID_DECODE_NODE_TYPE_SET.has(type); + /** Retain VAE edges for runtime scaling constants even though PiD replaces decode. */ const PID_DECODE_NODES_WITH_VAE_INPUT: ReadonlySet = new Set([ 'flux2_pid_decode', 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 bd621bc83ae..806e03094c8 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts @@ -314,6 +314,30 @@ describe('normalizeGenerateSettings', () => { }); }); +describe('the Anima inpaint adapter settings', () => { + it('defaults the weight, clamps it to the node bounds, and drops a malformed adapter', () => { + expect(normalizeGenerateSettings(legacyStoredValues)).toMatchObject({ + animaInpaintAdapterModel: null, + animaInpaintAdapterWeight: 1, + }); + const adapter = { base: 'anima', cond_in_channels: 4, key: 'inpaint', name: 'Inpainting', type: 'controlnet' }; + expect( + normalizeGenerateSettings({ + ...legacyStoredValues, + animaInpaintAdapterModel: adapter, + animaInpaintAdapterWeight: 12, + }) + ).toMatchObject({ animaInpaintAdapterModel: adapter, animaInpaintAdapterWeight: 10 }); + expect( + normalizeGenerateSettings({ + ...legacyStoredValues, + animaInpaintAdapterModel: { key: 'no-name' }, + animaInpaintAdapterWeight: 'heavy', + }) + ).toMatchObject({ animaInpaintAdapterModel: null, animaInpaintAdapterWeight: 1 }); + }); +}); + describe('Generate widget value snapshots', () => { const model: MainModelConfig = { base: 'flux2', key: 'model', name: 'Old Model', type: 'main' }; const component: ComponentModelConfig = { base: 'any', key: 'qwen3', name: 'Old Qwen3', type: 'qwen3_encoder' }; diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.ts index 4b5cf3c4809..c0a67c156bd 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.ts @@ -98,6 +98,10 @@ export const DEFAULT_HIDIFFUSION_T1_RATIO = 0.4; export const DEFAULT_HIDIFFUSION_T2_RATIO = 0; export const MIN_HIDIFFUSION_T1_RATIO = 0.1; export const MAX_HIDIFFUSION_RATIO = 1; +/** `anima_lllite.weight` bounds; the picker's track spans 0 to 2 like the legacy slider, typed values the full range. */ +export const DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT = 1; +export const MIN_ANIMA_INPAINT_ADAPTER_WEIGHT = -10; +export const MAX_ANIMA_INPAINT_ADAPTER_WEIGHT = 10; export const DEFAULT_LORA_WEIGHT_CONFIG = { coarseStep: 0.05, @@ -472,6 +476,7 @@ export const cloneGenerateWidgetValues = ( qwenVLEncoderModel: values.qwenVLEncoderModel ? { ...values.qwenVLEncoderModel } : null, qwen3VLEncoderModel: values.qwen3VLEncoderModel ? { ...values.qwen3VLEncoderModel } : null, qwen35EncoderModel: values.qwen35EncoderModel ? { ...values.qwen35EncoderModel } : null, + animaInpaintAdapterModel: values.animaInpaintAdapterModel ? { ...values.animaInpaintAdapterModel } : null, wanT5EncoderModel: values.wanT5EncoderModel ? { ...values.wanT5EncoderModel } : null, wanLowNoiseModel: values.wanLowNoiseModel ? { ...values.wanLowNoiseModel } : null, ideogram4UnconditionalModel: values.ideogram4UnconditionalModel ? { ...values.ideogram4UnconditionalModel } : null, @@ -563,6 +568,7 @@ export const syncGenerateWidgetValuesWithModels = ( qwen3EncoderModel: syncModelIdentifierWithModels(values.qwen3EncoderModel, modelsByKey), qwenVLEncoderModel: syncModelIdentifierWithModels(values.qwenVLEncoderModel, modelsByKey), qwen35EncoderModel: syncModelIdentifierWithModels(values.qwen35EncoderModel, modelsByKey), + animaInpaintAdapterModel: syncModelIdentifierWithModels(values.animaInpaintAdapterModel, modelsByKey), referenceImages: syncReferenceImagesWithModels(values.referenceImages, models), t5EncoderModel: syncModelIdentifierWithModels(values.t5EncoderModel, modelsByKey), vae: isVaeModelConfig(vae) ? vae : values.vae, @@ -579,6 +585,7 @@ export const syncGenerateWidgetValuesWithModels = ( nextValues.qwen3EncoderModel === values.qwen3EncoderModel && nextValues.qwenVLEncoderModel === values.qwenVLEncoderModel && nextValues.qwen35EncoderModel === values.qwen35EncoderModel && + nextValues.animaInpaintAdapterModel === values.animaInpaintAdapterModel && nextValues.referenceImages === values.referenceImages && nextValues.componentSourceModel === values.componentSourceModel && nextValues.modelKey === values.modelKey @@ -757,6 +764,14 @@ export const normalizeGenerateSettings = (values: unknown): GenerateSettings | n qwenVLEncoderModel: getModelIdentifierOrNull(values.qwenVLEncoderModel), qwen3VLEncoderModel: getModelIdentifierOrNull(values.qwen3VLEncoderModel), qwen35EncoderModel: getModelIdentifierOrNull(values.qwen35EncoderModel), + animaInpaintAdapterModel: getModelIdentifierOrNull(values.animaInpaintAdapterModel), + animaInpaintAdapterWeight: getClampedNumber( + values, + 'animaInpaintAdapterWeight', + MIN_ANIMA_INPAINT_ADAPTER_WEIGHT, + MAX_ANIMA_INPAINT_ADAPTER_WEIGHT, + DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT + ), wanT5EncoderModel: getModelIdentifierOrNull(values.wanT5EncoderModel), wanLowNoiseModel: getMainModelOrNull(values.wanLowNoiseModel), ideogram4UnconditionalModel: getMainModelOrNull(values.ideogram4UnconditionalModel), diff --git a/invokeai/frontend/webv2/src/features/generation/core/types.ts b/invokeai/frontend/webv2/src/features/generation/core/types.ts index d32d37543de..285150c2352 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/types.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/types.ts @@ -233,6 +233,12 @@ export interface GenerateSettings { qwen3VLEncoderModel: ComponentModelConfig | null; /** Anima-3.8B's second text encoder, read by its bundled semantic connector. */ qwen35EncoderModel: ComponentModelConfig | null; + /** + * Anima's optional 4-channel ControlNet-LLLite inpainting adapter. The canvas feeds it the raster image and the + * inpaint mask while inpainting or outpainting; Generate and the other canvas modes ignore it. + */ + animaInpaintAdapterModel: ComponentModelConfig | null; + animaInpaintAdapterWeight: number; /** Wan 2.2's UMT5-XXL text encoder. */ wanT5EncoderModel: ComponentModelConfig | null; /** The low-noise expert is optional; the selected expert can span the full schedule. */ diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateComponentsSection.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateComponentsSection.tsx index b5ca60d9182..33caaa57d14 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateComponentsSection.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateComponentsSection.tsx @@ -7,9 +7,17 @@ import { type ComponentPolicyContext, type ComponentSlotPolicy, } from '@features/generation/core/baseGenerationPolicies'; -import { isMainModelConfig, isModelIdentifierConfig, isVaeModelConfig } from '@features/generation/core/settings'; +import { + DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT, + isMainModelConfig, + isModelIdentifierConfig, + isVaeModelConfig, + MAX_ANIMA_INPAINT_ADAPTER_WEIGHT, + MIN_ANIMA_INPAINT_ADAPTER_WEIGHT, +} from '@features/generation/core/settings'; import { useExternalStoreSelector } from '@platform/state/selectors'; import { Field } from '@platform/ui'; +import { ScrubberField } from '@platform/ui/ScrubberField'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,6 +35,8 @@ const toComponentModel = (model: ModelConfig | null): ComponentModelConfig | nul isModelIdentifierConfig(model) ? model : null; const selectComponentSettings = pickGenerateSettings([ + 'animaInpaintAdapterModel', + 'animaInpaintAdapterWeight', 'clipEmbedModel', 'clipGEmbedModel', 'clipLEmbedModel', @@ -64,6 +74,7 @@ const getComponentPolicyContext = ( qwenVLEncoderModel: settings.qwenVLEncoderModel, qwen3VLEncoderModel: settings.qwen3VLEncoderModel, qwen35EncoderModel: settings.qwen35EncoderModel, + animaInpaintAdapterModel: settings.animaInpaintAdapterModel, wanT5EncoderModel: settings.wanT5EncoderModel, wanLowNoiseModel: settings.wanLowNoiseModel, ideogram4UnconditionalModel: settings.ideogram4UnconditionalModel, @@ -147,11 +158,50 @@ export const GenerateComponentsSection = ({ draft, onCommit, selectedModel }: Ge {policy.slots.map((slot) => ( ))} + {settings.animaInpaintAdapterModel && policy.slots.some((slot) => slot.key === 'animaInpaintAdapterModel') ? ( + + ) : null} ); }; +/** The legacy slider spanned 0 to 2 with these stops; typed values may use the node's full range. */ +const ANIMA_INPAINT_ADAPTER_WEIGHT_MARKS = [0, 1, 2]; + +const AnimaInpaintAdapterWeightField = ({ + onCommit, + value, +}: { + onCommit: (patch: Partial) => void; + value: number; +}) => { + const { t } = useTranslation(); + const onChange = useCallback( + (weight: number) => { + if (Number.isFinite(weight)) { + onCommit({ animaInpaintAdapterWeight: weight }); + } + }, + [onCommit] + ); + + return ( + + ); +}; + const ComponentPickerRow = ({ commitSlotValue, ctx, 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 54dd28668cd..c6e7f44ec2d 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts @@ -111,6 +111,8 @@ const setModel = (project: Project, base: GenerateWidgetValues['model']['base']) qwenVLEncoderModel: null, qwen3VLEncoderModel: null, qwen35EncoderModel: null, + animaInpaintAdapterModel: null, + animaInpaintAdapterWeight: 1, wanT5EncoderModel: null, wanLowNoiseModel: null, ideogram4UnconditionalModel: null, 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 83d8be78063..e7841ea976a 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -134,6 +134,8 @@ const createValues = (overrides: Partial = {}): GenerateWi qwenVLEncoderModel: null, qwen3VLEncoderModel: null, qwen35EncoderModel: null, + animaInpaintAdapterModel: null, + animaInpaintAdapterWeight: 1, wanT5EncoderModel: null, wanLowNoiseModel: null, ideogram4UnconditionalModel: null, @@ -1204,6 +1206,71 @@ const remixRoundTrip = ({ base, shape }: (typeof generateGraphCases)[number]) => return { expected: keysOf(settings as GenerateWidgetValues), recorded, restored: keysOf(result?.values) }; }; +describe('anima inpaint adapter', () => { + const inpaintAdapter: ComponentModelConfig = { + base: 'anima', + cond_in_channels: 4, + key: 'anima-inpaint', + name: 'Anima LLLite Inpainting', + type: 'controlnet', + }; + const canvasInpaintMetadata = { + anima_inpaint_adapter: { key: inpaintAdapter.key }, + anima_inpaint_adapter_weight: 0.7, + model: { key: animaModel.key }, + qwen3_encoder: { key: animaQwen3.key }, + vae: { key: qwenImageVae.key }, + }; + const models = [animaModel, animaQwen3, qwenImageVae, inpaintAdapter]; + + it.each(['all', 'remix'] as const)( + 'restores the adapter and its weight from a canvas inpaint image on %s', + (kind) => { + const result = buildImageRecallSettings({ + currentValues: createValues(), + image, + kind, + metadata: canvasInpaintMetadata, + models, + supportedModels: [animaModel], + vaeModels: [qwenImageVae], + }); + + expect(result?.values.model).toBe(animaModel); + expect(result?.values.animaInpaintAdapterModel).toBe(inpaintAdapter); + expect(result?.values.animaInpaintAdapterWeight).toBe(0.7); + expect(result?.fields).toEqual(expect.arrayContaining(['components', 'animaInpaintAdapterWeight'])); + } + ); + + it('skips the adapter and weight when the image is not an Anima image or the adapter is a control adapter', () => { + const notAnima = buildImageRecallSettings({ + currentValues: createValues({ animaInpaintAdapterWeight: 1 }), + image, + kind: 'all', + metadata: { ...canvasInpaintMetadata, model: { key: sd1Model.key } }, + models: [sd1Model, inpaintAdapter], + supportedModels: [sd1Model], + vaeModels: [], + }); + expect(notAnima?.values.animaInpaintAdapterModel).toBeNull(); + expect(notAnima?.values.animaInpaintAdapterWeight).toBe(1); + expect(notAnima?.fields).not.toContain('animaInpaintAdapterWeight'); + + const controlAdapter = { ...inpaintAdapter, cond_in_channels: 3 }; + const wrongChannels = buildImageRecallSettings({ + currentValues: createValues(), + image, + kind: 'all', + metadata: canvasInpaintMetadata, + models: [animaModel, animaQwen3, qwenImageVae, controlAdapter], + supportedModels: [animaModel], + vaeModels: [qwenImageVae], + }); + expect(wrongChannels?.values.animaInpaintAdapterModel).toBeNull(); + }); +}); + describe('remix round trip', () => { // Every component a graph records must come back; one recorded under a key Remix does not know fails here. it.each(generateGraphCases)('restores the components $label records', (graphCase) => { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index 829d4119327..e84a954cb30 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -73,7 +73,8 @@ type RecalledField = | 'components' | 'loras' | 'referenceImages' - | 'krea2Rebalance'; + | 'krea2Rebalance' + | 'animaInpaintAdapterWeight'; export type ImageRecallSkipReason = | 'ambiguous' @@ -419,8 +420,26 @@ const getMetadataKrea2Rebalance = ( const hasKrea2Rebalance = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => Object.keys(getMetadataKrea2Rebalance(metadata, model)).length > 0; +/** The canvas records the adapter's weight beside the adapter; only an Anima image can carry it. */ +const getMetadataAnimaInpaintAdapterWeight = ( + metadata: unknown, + model: GenerateModelConfig | undefined +): Partial => { + if (model?.base !== 'anima') { + return {}; + } + + const weight = getNumber(metadata, 'anima_inpaint_adapter_weight'); + + return weight === null ? {} : { animaInpaintAdapterWeight: weight }; +}; + +const hasAnimaInpaintAdapterWeight = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => + Object.keys(getMetadataAnimaInpaintAdapterWeight(metadata, model)).length > 0; + type RecalledComponentSetting = keyof Pick< GenerateWidgetValues, + | 'animaInpaintAdapterModel' | 'clipEmbedModel' | 'componentSourceModel' | 'gemma2EncoderModel' @@ -453,6 +472,7 @@ const RECALLED_COMPONENTS: readonly RecalledComponent[] = [ { isMainModel: true, metadataKey: 'wan_component_source', setting: 'componentSourceModel' }, { isMainModel: true, metadataKey: 'ideogram4_unconditional_model', setting: 'ideogram4UnconditionalModel' }, { isMainModel: true, metadataKey: 'wan_transformer_low_noise', setting: 'wanLowNoiseModel' }, + { metadataKey: 'anima_inpaint_adapter', setting: 'animaInpaintAdapterModel' }, { metadataKey: 'clip_embed_model', setting: 'clipEmbedModel' }, { metadataKey: 'gemma2_encoder', setting: 'gemma2EncoderModel' }, { metadataKey: 'mistral_encoder', setting: 'mistralEncoderModel' }, @@ -707,6 +727,7 @@ export const getImageRecallCapabilities = ({ const hasReferenceImages = getMetadataReferenceImages(metadata).length > 0; const hasModel = supportedMetadataModel !== null; const hasRebalance = hasKrea2Rebalance(metadata, clipSkipModel); + const hasAdapterWeight = hasAnimaInpaintAdapterWeight(metadata, clipSkipModel); const hasConcepts = (getMetadataConcepts(metadata, models, clipSkipModel, supportedMetadataModel).loras?.length ?? 0) > 0; const hasAnyMetadata = @@ -721,7 +742,8 @@ export const getImageRecallCapabilities = ({ hasComponents || hasReferenceImages || hasConcepts || - hasRebalance; + hasRebalance || + hasAdapterWeight; const hasNonSeedMetadata = hasModel || hasVae || @@ -733,7 +755,8 @@ export const getImageRecallCapabilities = ({ hasComponents || hasReferenceImages || hasConcepts || - hasRebalance; + hasRebalance || + hasAdapterWeight; return { all: hasAnyMetadata && isImageRecallKindAvailable('all'), @@ -803,6 +826,13 @@ export const buildImageRecallSettings = ({ fields.push('krea2Rebalance'); } + const adapterWeightPatch = getMetadataAnimaInpaintAdapterWeight(metadata, values.model); + + if (Object.keys(adapterWeightPatch).length > 0) { + values = { ...values, ...adapterWeightPatch }; + fields.push('animaInpaintAdapterWeight'); + } + const vae = getMetadataVae(metadata, values.model, vaeModels); if (vae !== undefined) { diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index c9db763eea2..762418334ba 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -239,6 +239,8 @@ const createGenerateValues = (overrides: Partial = {}): Ge qwenVLEncoderModel: null, qwen3VLEncoderModel: null, qwen35EncoderModel: null, + animaInpaintAdapterModel: null, + animaInpaintAdapterWeight: 1, wanT5EncoderModel: null, wanLowNoiseModel: null, ideogram4UnconditionalModel: null, diff --git a/tests/app/invocations/test_frontend_anima_lllite_graphs.py b/tests/app/invocations/test_frontend_anima_lllite_graphs.py index 8678ddd2b39..92dcaa4ac7d 100644 --- a/tests/app/invocations/test_frontend_anima_lllite_graphs.py +++ b/tests/app/invocations/test_frontend_anima_lllite_graphs.py @@ -1,12 +1,13 @@ """The Anima ControlNet-LLLite canvas graphs webv2 compiles are graphs this backend accepts. `animaLLLiteCanvasGraphs.json` is written by webv2's `compileCanvasGraph.test.ts` (regenerate with -`vitest -u`) from the real canvas compiler: a complete inpainting submission with three adapters. The -frontend routes any number of adapters through one collector, so one graph covers the edge types for -every count. This module checks more than names: pydantic validates every node against its invocation (bounds such as -`anima_lllite.weight` in [-10, 10] included), `Graph.validate_self` checks every edge type through the -collector into `anima_denoise.control_lllite`, and the denoiser's own normalization runs on the -fields the adapter nodes would emit. No model is loaded. +`vitest -u`) from the real canvas compiler: a complete inpainting submission with three control-layer +adapters, and an outpainting submission that adds the optional 4-channel inpaint adapter beside a control +layer. The frontend routes any number of adapters through one collector, so these graphs cover the edge +types for every count, masked and unmasked. This module checks more than names: pydantic validates every +node against its invocation (bounds such as `anima_lllite.weight` in [-10, 10] included), +`Graph.validate_self` checks every edge type through the collector into `anima_denoise.control_lllite`, +and the denoiser's own normalization runs on the fields the adapter nodes would emit. No model is loaded. """ import json @@ -16,7 +17,9 @@ import pytest from invokeai.app.invocations.anima.anima_denoise import AnimaDenoiseInvocation -from invokeai.app.invocations.anima.anima_lllite import AnimaLLLiteInvocation +from invokeai.app.invocations.anima.anima_lllite import AnimaLLLiteField, AnimaLLLiteInvocation +from invokeai.app.invocations.fields import ImageField +from invokeai.app.invocations.image import ImageLerpInvocation from invokeai.app.services.shared.graph import Graph FIXTURE_PATH = ( @@ -44,14 +47,35 @@ def graph(request: pytest.FixtureRequest) -> Graph: return graph +INPAINT_ADAPTER_NODE_ID = "anima_inpaint_adapter" + + def _lllite_nodes(graph: Graph) -> list[AnimaLLLiteInvocation]: return [node for node in graph.nodes.values() if isinstance(node, AnimaLLLiteInvocation)] -def test_the_fixture_combines_several_distinct_adapters() -> None: - (graph,) = GRAPHS.values() - keys = [node["control_model"]["key"] for node in graph["nodes"].values() if node["type"] == "anima_lllite"] - assert len(keys) == 3 and len(set(keys)) == 3 +def _control_layer_nodes(graph: Graph) -> list[AnimaLLLiteInvocation]: + return [node for node in _lllite_nodes(graph) if node.id != INPAINT_ADAPTER_NODE_ID] + + +def _control_field(graph: Graph, node: AnimaLLLiteInvocation) -> AnimaLLLiteField: + """The field the node would emit. `invoke` only repackages the node's own fields, so an image or mask that an + edge supplies at run time stands in as the image its source node would produce.""" + fed = {e.destination.field for e in graph.edges if e.destination.node_id == node.id} + runtime = node.model_copy( + update={field: ImageField(image_name=f"{node.id}.{field}") for field in fed if field in {"image", "mask"}} + ) + return runtime.invoke(None).control # type: ignore[arg-type] + + +def test_the_fixtures_combine_several_distinct_adapters() -> None: + keys = { + name: [node["control_model"]["key"] for node in graph["nodes"].values() if node["type"] == "anima_lllite"] + for name, graph in GRAPHS.items() + } + assert len(keys["inpaint_three_adapters"]) == 3 and len(set(keys["inpaint_three_adapters"])) == 3 + assert len(keys["outpaint_inpaint_adapter_and_control_layer"]) == 2 + assert len(set(keys["outpaint_inpaint_adapter_and_control_layer"])) == 2 @pytest.mark.parametrize("name", sorted(GRAPHS)) @@ -61,14 +85,18 @@ def test_every_field_the_frontend_writes_is_one_the_invocation_declares(name: st graph = Graph.model_validate(GRAPHS[name]) for node_id, raw in GRAPHS[name]["nodes"].items(): node = graph.nodes[node_id] + if type(node).model_config.get("extra") == "allow": + # `core_metadata` records whatever the graph gives it, feature fields included, and persists the extras. + continue unknown = set(raw) - set(type(node).model_fields) assert unknown == set(), f"{raw['type']} has no field(s) {sorted(unknown)}" if isinstance(node, AnimaLLLiteInvocation): - assert (node.weight, node.begin_step_percent, node.end_step_percent) == ( - raw["weight"], - raw["begin_step_percent"], - raw["end_step_percent"], - ) + # The inpaint adapter leaves the step range to the node's defaults, as the legacy graph did. + written = { + field: raw[field] for field in ("weight", "begin_step_percent", "end_step_percent") if field in raw + } + assert "weight" in written + assert {field: getattr(node, field) for field in written} == written def test_every_adapter_reaches_control_lllite_through_one_collector(graph: Graph) -> None: @@ -87,27 +115,53 @@ def test_every_adapter_reaches_control_lllite_through_one_collector(graph: Graph def test_control_layers_send_a_control_image_and_no_inpaint_mask(graph: Graph) -> None: - for node in _lllite_nodes(graph): + control_layers = _control_layer_nodes(graph) + assert control_layers + for node in control_layers: assert node.image.image_name assert node.mask is None assert node.control_model.base.value == "anima" assert node.control_model.type.value == "controlnet" - assert not any(isinstance(graph.nodes[e.destination.node_id], AnimaLLLiteInvocation) for e in graph.edges) + control_layer_ids = {node.id for node in control_layers} + assert not any(e.destination.node_id in control_layer_ids for e in graph.edges) + + +def test_the_inpaint_adapter_takes_the_infilled_image_and_an_inverted_mask() -> None: + """The adapter wants white = inpaint; the canvas mask is white = keep, so an `img_lerp` 255 -> 0 feeds its mask.""" + graph = Graph.model_validate(GRAPHS["outpaint_inpaint_adapter_and_control_layer"]) + graph.validate_self() + adapter = graph.nodes[INPAINT_ADAPTER_NODE_ID] + assert isinstance(adapter, AnimaLLLiteInvocation) + assert adapter.weight == 0.75 + assert adapter.control_model.base.value == "anima" + + inputs = {e.destination.field: e.source for e in graph.edges if e.destination.node_id == adapter.id} + assert set(inputs) == {"image", "mask"} + assert graph.nodes[inputs["image"].node_id].get_type() == "infill_lama" + assert inputs["image"].field == "image" + invert = graph.nodes[inputs["mask"].node_id] + assert isinstance(invert, ImageLerpInvocation) + assert (invert.min, invert.max) == (255, 0) + assert inputs["mask"].field == "image" + mask_source = next(e.source for e in graph.edges if e.destination.node_id == invert.id) + assert graph.nodes[mask_source.node_id].get_type() == "mask_combine" def test_the_denoiser_accepts_the_adapters_it_would_receive(graph: Graph) -> None: - # `invoke` only repackages the node's own fields; it reads nothing from the context. - fields = [node.invoke(None).control for node in _lllite_nodes(graph)] # type: ignore[arg-type] + fields = [_control_field(graph, node) for node in _lllite_nodes(graph)] normalized = AnimaDenoiseInvocation._normalize_control_lllite(fields) assert sorted(f.control_model.key for f in normalized) == sorted(f.control_model.key for f in fields) + assert [f.mask_name is not None for f in fields] == [ + node.id == INPAINT_ADAPTER_NODE_ID for node in _lllite_nodes(graph) + ] def test_a_repeated_adapter_model_is_one_the_denoiser_refuses() -> None: """The frontend blocks this case; pin that the backend constraint it mirrors still exists.""" - node = _lllite_nodes(Graph.model_validate(GRAPHS["inpaint_three_adapters"]))[0] - field = node.invoke(None).control # type: ignore[arg-type] + graph = Graph.model_validate(GRAPHS["inpaint_three_adapters"]) + field = _control_field(graph, _lllite_nodes(graph)[0]) with pytest.raises(ValueError, match="used by more than one control input"): AnimaDenoiseInvocation._normalize_control_lllite([field, field]) From d7f8137d06722b3128748273c59c56ec11dd21b9 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sat, 10 Oct 2026 21:51:30 -0600 Subject: [PATCH 2/3] test(canvas): pin adapter mask sources and cover the inpaint adapter picker Compile tests pin the scaled outpaint mask and pre-noise image the Anima inpaint adapter reads, recall keeps an uninstalled adapter pick while restoring the weight, and a browser case covers the Anima-only slot, its 4-channel filter and the weight scrubber. --- .../03.Models/Local Models/anima.mdx | 2 +- .../core/canvas/compileCanvasGraph.test.ts | 60 ++++++++++--- .../src/features/generation/core/graph.ts | 1 + .../ui/GenerateSettingsForm.browser.test.tsx | 85 +++++++++++++++++-- .../image-actions/imageRecall.test.ts | 17 ++++ 5 files changed, 143 insertions(+), 22 deletions(-) diff --git a/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx b/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx index c77f2d143b1..96ce06cdd69 100644 --- a/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/Local Models/anima.mdx @@ -134,7 +134,7 @@ transformer on an image. Each adapter model may be used only once per generation Adapter** in the Generate panel's **Components** section, and set its **Inpaint adapter weight** (1 by default). Invoke then conditions the adapter on the raster image and the region being generated; the setting is ignored for text-to-image and image-to-image. The adapter and its weight are recorded in the - image's metadata and restored by **Recall all** and **Remix**. + image's metadata and restored by **Recall All** and **Remix Image**. - **In the workflow editor**, add an **Anima ControlNet-LLLite** node, give it the conditioning image and a control model, and connect it to the **Control LLLite** input of **Denoise - Anima**, collecting several nodes to combine them. 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 373fc7b75f0..5bc142abe30 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 @@ -1057,19 +1057,55 @@ describe('compileCanvasGraph — Anima ControlNet-LLLite (integration)', () => { ); }); - it.each(['inpaint', 'outpaint'] as const)('leaves the %s graph unchanged without an adapter', (mode) => { - // Only the metadata node id is minted per compile; pin it so the graphs compare structurally. - const pinned = (graph: BackendGraphContract) => { - const metadataId = getNodeByType(graph, 'core_metadata')!.id; - return JSON.parse(JSON.stringify({ edges: graph.edges, nodes: graph.nodes }).replaceAll(metadataId, 'metadata')); - }; - const unset = compile(animaModel, mode, { settings: { animaInpaintAdapterModel: null } }).backendGraph; - const defaults = compile(animaModel, mode).backendGraph; + it.each(['inpaint', 'outpaint'] as const)( + 'adds no adapter nodes, edges or metadata to the %s graph without one', + (mode) => { + const { backendGraph } = compile(animaModel, mode, { settings: { animaInpaintAdapterModel: null } }); + + expect(backendGraph.nodes.anima_inpaint_adapter).toBeUndefined(); + expect(backendGraph.nodes.invert_anima_inpaint_adapter_mask).toBeUndefined(); + expect(backendGraph.nodes.control_lllite_collector).toBeUndefined(); + expect(getEdge(backendGraph, 'denoise_latents', 'control_lllite')).toBeUndefined(); + expect(getNodeByType(backendGraph, 'core_metadata')).not.toHaveProperty('anima_inpaint_adapter'); + expect(getNodeByType(backendGraph, 'core_metadata')).not.toHaveProperty('anima_inpaint_adapter_weight'); + } + ); + + it('outpaint with Scale Before Processing: the adapter reads the resized combined mask (legacy addOutpaint.ts:182)', () => { + const { backendGraph } = compile(animaModel, 'outpaint', { + maskImageName: 'canvas-mask.png', + scaling: { height: 1024, method: 'manual', width: 1024 }, + settings: withAdapter(), + }); + + expect(getEdge(backendGraph, 'canvas_resize_outpaint_mask_to_processing', 'image')?.source.node_id).toBe( + 'mask_combine' + ); + expect(getEdge(backendGraph, 'invert_anima_inpaint_adapter_mask', 'image')?.source.node_id).toBe( + 'canvas_resize_outpaint_mask_to_processing' + ); + expect(getEdge(backendGraph, 'anima_inpaint_adapter', 'image')?.source.node_id).toBe('infill'); + expect(backendGraph.nodes.invert_anima_inpaint_adapter_mask?.image).toBeUndefined(); + }); + + it('inpaint with a noise mask: the adapter reads the image before noise is added (legacy addInpaint.ts:223/316)', () => { + const unscaled = compile(animaModel, 'inpaint', { + noiseMaskImageName: 'noise-mask.png', + settings: withAdapter(), + }).backendGraph; + expect(getEdge(unscaled, 'canvas_i2l', 'image')?.source.node_id).toBe('add_inpaint_noise'); + expect(unscaled.nodes.anima_inpaint_adapter?.image).toEqual({ image_name: 'canvas-composite.png' }); + expect(getEdge(unscaled, 'anima_inpaint_adapter', 'image')).toBeUndefined(); - expect(pinned(unset)).toEqual(pinned(defaults)); - expect(unset.nodes.anima_inpaint_adapter).toBeUndefined(); - expect(getEdge(unset, 'denoise_latents', 'control_lllite')).toBeUndefined(); - expect(getNodeByType(unset, 'core_metadata')?.anima_inpaint_adapter).toBeUndefined(); + const scaled = compile(animaModel, 'inpaint', { + noiseMaskImageName: 'noise-mask.png', + scaling: { height: 1024, method: 'manual', width: 1024 }, + settings: withAdapter(), + }).backendGraph; + expect(getEdge(scaled, 'add_inpaint_noise', 'image')?.source.node_id).toBe('canvas_resize_initial_to_processing'); + expect(getEdge(scaled, 'anima_inpaint_adapter', 'image')?.source.node_id).toBe( + 'canvas_resize_initial_to_processing' + ); }); it('ignores a picked adapter outside inpaint/outpaint and outside Anima', () => { diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.ts index 2fbcdc5045a..1d0400d6611 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.ts @@ -548,6 +548,7 @@ export const findVaeSource = ( const node = graph.nodes[edge.source.node_id]; return node ? { field: edge.source.field, node } : null; } + // The fallback holds because each builder wires exactly one `vae` edge into its decode node, from `model_loader`. const modelLoader = graph.nodes.model_loader; const decodesWithPid = Object.values(graph.nodes).some((node) => isPidDecodeNodeType(node.type)); return modelLoader && decodesWithPid ? { field: 'vae', node: modelLoader } : null; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx index be4d28dd861..64ed5b23819 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.browser.test.tsx @@ -88,16 +88,43 @@ const CATALOG: readonly GenerationModelCatalogItem[] = [MODEL]; const OTHER_SDXL = { ...MODEL, key: 'sdxl-2', name: 'SDXL 2' } as GenerationModelCatalogItem; // A coarser size grid than SDXL's, and no SDXL LoRAs. const FLUX = { ...MODEL, base: 'flux', key: 'flux', name: 'FLUX' } as GenerationModelCatalogItem; +const ANIMA = { ...MODEL, base: 'anima', key: 'anima', name: 'Anima' } as GenerationModelCatalogItem; +const ANIMA_INPAINT_ADAPTER = { + base: 'anima', + cond_in_channels: 4, + key: 'anima-inpaint', + name: 'Anima LLLite Inpainting', + type: 'controlnet', +} as GenerationModelCatalogItem; +const ANIMA_SKETCH_ADAPTER = { + ...ANIMA_INPAINT_ADAPTER, + cond_in_channels: 3, + key: 'anima-sketch', + name: 'Anima LLLite Sketch', +} as GenerationModelCatalogItem; -/** The model card's picker, reduced to buttons that hand a model to its real selection handler. */ -const MainModelPicker = ({ modelTypes, onChange }: GenerationModelSelectProps) => - modelTypes.includes('external_image_generator') - ? [OTHER_SDXL, FLUX].map((model) => ( - - )) - : null; +/** + * The picker, reduced to buttons that hand a model to its real selection handler: the model card's main picks, and + * for a component slot the catalog models of its types that pass its filter. + */ +const MainModelPicker = ({ filter, modelTypes, onChange }: GenerationModelSelectProps) => + modelTypes.includes('external_image_generator') ? ( + [OTHER_SDXL, FLUX].map((model) => ( + + )) + ) : ( +
+ {stableGroups.models.catalog + .filter((model) => modelTypes.includes(model.type) && (filter?.(model) ?? true)) + .map((model) => ( + + ))} +
+ ); const INITIAL_VALUES: Record = { ...getDefaultGenerateSettings(MODEL as never), modelKey: MODEL.key, @@ -273,6 +300,46 @@ afterEach(async () => { accountLifecycle.invalidate(); }); +describe('GenerateSettingsForm Anima inpaint adapter', () => { + const adapterSlot = () => host!.querySelector('[data-component-types="controlnet"]'); + const adapterPicks = () => + [...(adapterSlot()?.querySelectorAll('[data-component-pick]') ?? [])].map((pick) => + pick.getAttribute('data-component-pick') + ); + const weightLabel = 'widgets.generate.animaInpaintAdapterWeight'; + const weightScrubber = () => + [...document.querySelectorAll('[data-scope="scrubber"]')].find( + (candidate) => candidate.querySelector('[data-part="label"]')?.textContent === weightLabel + ); + + it('offers the slot to Anima only, lists only 4-channel adapters, and shows the weight once one is picked', async () => { + stableGroups.models.catalog = [MODEL, ANIMA, ANIMA_INPAINT_ADAPTER, ANIMA_SKETCH_ADAPTER]; + await renderAdapter(buildAdapter()); + + expect(adapterSlot()).toBeNull(); + + await settle(() => + storedValues().patchSnapshot({ ...getDefaultGenerateSettings(ANIMA as never), modelKey: ANIMA.key }) + ); + + expect(adapterPicks()).toEqual([ANIMA_INPAINT_ADAPTER.key]); + expect(weightScrubber()).toBeUndefined(); + + await settle(() => host!.querySelector('[data-component-pick="anima-inpaint"]')?.click()); + await settle(flushGenerateDrafts); + + expect(storedValues().getSnapshot()).toMatchObject({ animaInpaintAdapterModel: ANIMA_INPAINT_ADAPTER }); + const slider = sliderNamed(weightLabel); + expect(slider.getAttribute('aria-valuenow')).toBe('1'); + expect(document.getElementById(slider.getAttribute('aria-labelledby') ?? '')?.textContent).toBe(weightLabel); + + await pressRight(sliderNamed(weightLabel)); + await settle(flushGenerateDrafts); + + expect(storedValues().getSnapshot()).toMatchObject({ animaInpaintAdapterWeight: 1.05 }); + }); +}); + describe('GenerateSettingsForm render isolation', () => { it('preserves a settled weight draft when removing another concept from an already open menu', async () => { const first: LoraModelConfig = { base: 'sdxl', key: 'ink', name: 'Ink', type: 'lora' }; 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 e7841ea976a..45dc748a728 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -1243,6 +1243,23 @@ describe('anima inpaint adapter', () => { } ); + it('leaves the slot alone but still recalls the weight when the recorded adapter is not installed', () => { + const result = buildImageRecallSettings({ + currentValues: createValues({ animaInpaintAdapterModel: inpaintAdapter, animaInpaintAdapterWeight: 1 }), + image, + kind: 'all', + metadata: { ...canvasInpaintMetadata, anima_inpaint_adapter: { key: 'gone' } }, + models: [animaModel, animaQwen3, qwenImageVae], + supportedModels: [animaModel], + vaeModels: [qwenImageVae], + }); + + // Recall clones the current values, so the untouched slot keeps the same pick by value. + expect(result?.values.animaInpaintAdapterModel).toEqual(inpaintAdapter); + expect(result?.values.animaInpaintAdapterWeight).toBe(0.7); + expect(result?.fields).toContain('animaInpaintAdapterWeight'); + }); + it('skips the adapter and weight when the image is not an Anima image or the adapter is a control adapter', () => { const notAnima = buildImageRecallSettings({ currentValues: createValues({ animaInpaintAdapterWeight: 1 }), From 9b892f1b62f91614887732f212afd6558535472d Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Fri, 16 Oct 2026 13:16:29 -0600 Subject: [PATCH 3/3] fix(canvas): resize PiD Fit output once and restore the adapter slot on recall PiD Fit graphs on an off-grid bbox now resize the decoded image straight to the bbox instead of twice. Recall All and Remix clear the Anima inpaint adapter when an Anima inpaint or outpaint record carries none, and clamp a recorded adapter weight to the setting's range. Covers SDXL Native encode size, the off-grid Fit resize, and the inpaint adapter in the backend fixture test. --- .../animaLLLiteCanvasGraphs.json | 483 +++++++++++++++++- .../core/canvas/compileCanvasGraph.test.ts | 50 +- .../core/canvas/compileCanvasGraph.ts | 22 +- .../src/features/generation/core/settings.ts | 14 +- .../webv2/src/features/generation/settings.ts | 1 + .../image-actions/imageRecall.test.ts | 62 +++ .../workbench/image-actions/imageRecall.ts | 31 +- .../test_frontend_anima_lllite_graphs.py | 68 ++- 8 files changed, 705 insertions(+), 26 deletions(-) diff --git a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json index 6341918c2d5..8315b8ad6bd 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json +++ b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/animaLLLiteCanvasGraphs.json @@ -1,5 +1,486 @@ { "_comment": "Generated by compileCanvasGraph.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_frontend_anima_lllite_graphs.py, which validates the graphs against the backend invocations.", + "inpaint_inpaint_adapter": { + "edges": [ + { + "destination": { + "field": "transformer", + "node_id": "denoise_latents" + }, + "source": { + "field": "transformer", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "qwen3_encoder", + "node_id": "pos_cond" + }, + "source": { + "field": "qwen3_encoder", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "vae", + "node_id": "canvas_l2i" + }, + "source": { + "field": "vae", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "prompt", + "node_id": "pos_cond" + }, + "source": { + "field": "value", + "node_id": "positive_prompt" + } + }, + { + "destination": { + "field": "item", + "node_id": "pos_cond_collect" + }, + "source": { + "field": "conditioning", + "node_id": "pos_cond" + } + }, + { + "destination": { + "field": "positive_conditioning", + "node_id": "denoise_latents" + }, + "source": { + "field": "collection", + "node_id": "pos_cond_collect" + } + }, + { + "destination": { + "field": "qwen3_encoder", + "node_id": "neg_cond" + }, + "source": { + "field": "qwen3_encoder", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "prompt", + "node_id": "neg_cond" + }, + "source": { + "field": "value", + "node_id": "negative_prompt" + } + }, + { + "destination": { + "field": "item", + "node_id": "neg_cond_collect" + }, + "source": { + "field": "conditioning", + "node_id": "neg_cond" + } + }, + { + "destination": { + "field": "negative_conditioning", + "node_id": "denoise_latents" + }, + "source": { + "field": "collection", + "node_id": "neg_cond_collect" + } + }, + { + "destination": { + "field": "seed", + "node_id": "denoise_latents" + }, + "source": { + "field": "value", + "node_id": "seed" + } + }, + { + "destination": { + "field": "latents", + "node_id": "canvas_l2i" + }, + "source": { + "field": "latents", + "node_id": "denoise_latents" + } + }, + { + "destination": { + "field": "seed", + "node_id": "core_metadata_0_i" + }, + "source": { + "field": "value", + "node_id": "seed" + } + }, + { + "destination": { + "field": "positive_prompt", + "node_id": "core_metadata_0_i" + }, + "source": { + "field": "value", + "node_id": "positive_prompt" + } + }, + { + "destination": { + "field": "negative_prompt", + "node_id": "core_metadata_0_i" + }, + "source": { + "field": "value", + "node_id": "negative_prompt" + } + }, + { + "destination": { + "field": "metadata", + "node_id": "canvas_output" + }, + "source": { + "field": "metadata", + "node_id": "core_metadata_0_i" + } + }, + { + "destination": { + "field": "vae", + "node_id": "canvas_i2l" + }, + "source": { + "field": "vae", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "latents", + "node_id": "denoise_latents" + }, + "source": { + "field": "latents", + "node_id": "canvas_i2l" + } + }, + { + "destination": { + "field": "vae", + "node_id": "create_gradient_mask" + }, + "source": { + "field": "vae", + "node_id": "model_loader" + } + }, + { + "destination": { + "field": "denoise_mask", + "node_id": "denoise_latents" + }, + "source": { + "field": "denoise_mask", + "node_id": "create_gradient_mask" + } + }, + { + "destination": { + "field": "mask", + "node_id": "anima_inpaint_adapter" + }, + "source": { + "field": "image", + "node_id": "invert_anima_inpaint_adapter_mask" + } + }, + { + "destination": { + "field": "control_lllite", + "node_id": "denoise_latents" + }, + "source": { + "field": "collection", + "node_id": "control_lllite_collector" + } + }, + { + "destination": { + "field": "item", + "node_id": "control_lllite_collector" + }, + "source": { + "field": "control", + "node_id": "anima_inpaint_adapter" + } + }, + { + "destination": { + "field": "mask", + "node_id": "expand_mask" + }, + "source": { + "field": "expanded_mask_area", + "node_id": "create_gradient_mask" + } + }, + { + "destination": { + "field": "layer_upper", + "node_id": "canvas_output" + }, + "source": { + "field": "image", + "node_id": "canvas_l2i" + } + }, + { + "destination": { + "field": "mask", + "node_id": "canvas_output" + }, + "source": { + "field": "image", + "node_id": "expand_mask" + } + } + ], + "id": "anima_lllite_canvas_inpaint_adapter", + "nodes": { + "negative_prompt": { + "is_intermediate": true, + "use_cache": true, + "id": "negative_prompt", + "type": "string" + }, + "positive_prompt": { + "is_intermediate": true, + "use_cache": true, + "id": "positive_prompt", + "type": "string" + }, + "seed": { + "is_intermediate": true, + "use_cache": true, + "id": "seed", + "type": "integer" + }, + "model_loader": { + "is_intermediate": true, + "use_cache": true, + "id": "model_loader", + "model": { + "base": "anima", + "key": "anima-model", + "name": "Anima", + "type": "main", + "hash": "blake3:anima" + }, + "qwen3_encoder_model": { + "base": "any", + "key": "qwen3", + "name": "Qwen3 Encoder", + "type": "qwen3_encoder", + "variant": "qwen3_06b", + "hash": "blake3:qwen3" + }, + "type": "anima_model_loader", + "vae_model": { + "base": "qwen-image", + "key": "qwen-vae", + "name": "Qwen VAE", + "type": "vae", + "hash": "blake3:vae" + } + }, + "pos_cond": { + "is_intermediate": true, + "use_cache": true, + "id": "pos_cond", + "type": "anima_text_encoder" + }, + "pos_cond_collect": { + "is_intermediate": true, + "use_cache": true, + "id": "pos_cond_collect", + "type": "collect" + }, + "neg_cond": { + "is_intermediate": true, + "use_cache": true, + "id": "neg_cond", + "type": "anima_text_encoder" + }, + "neg_cond_collect": { + "is_intermediate": true, + "use_cache": true, + "id": "neg_cond_collect", + "type": "collect" + }, + "denoise_latents": { + "is_intermediate": true, + "use_cache": true, + "denoising_end": 1, + "denoising_start": 0.4, + "guidance_scale": 4.5, + "height": 1024, + "id": "denoise_latents", + "scheduler": "euler", + "steps": 35, + "type": "anima_denoise", + "width": 768 + }, + "core_metadata_0_i": { + "is_intermediate": true, + "use_cache": true, + "cfg_scale": 4.5, + "cfg_rescale_multiplier": 0, + "generation_mode": "anima_inpaint", + "height": 1024, + "id": "core_metadata_0_i", + "model": { + "base": "anima", + "key": "anima-model", + "name": "Anima", + "type": "main", + "hash": "blake3:anima" + }, + "rand_device": "cpu", + "scheduler": "euler", + "seamless_x": false, + "seamless_y": false, + "steps": 35, + "type": "core_metadata", + "vae": { + "base": "qwen-image", + "key": "qwen-vae", + "name": "Qwen VAE", + "type": "vae", + "hash": "blake3:vae" + }, + "width": 768, + "qwen3_encoder": { + "base": "any", + "key": "qwen3", + "name": "Qwen3 Encoder", + "type": "qwen3_encoder", + "variant": "qwen3_06b", + "hash": "blake3:qwen3" + }, + "clip_skip": 0, + "anima_inpaint_adapter": { + "base": "anima", + "cond_in_channels": 4, + "key": "anima-inpaint", + "name": "Anima LLLite Inpainting", + "type": "controlnet", + "hash": "blake3:inpaint" + }, + "anima_inpaint_adapter_weight": 0.75, + "strength": 0.6 + }, + "canvas_i2l": { + "is_intermediate": true, + "use_cache": true, + "id": "canvas_i2l", + "type": "anima_i2l", + "image": { + "image_name": "canvas-composite.png" + } + }, + "canvas_l2i": { + "is_intermediate": true, + "use_cache": false, + "id": "canvas_l2i", + "type": "anima_l2i" + }, + "create_gradient_mask": { + "is_intermediate": true, + "use_cache": true, + "coherence_mode": "Gaussian Blur", + "edge_radius": 16, + "fp32": false, + "id": "create_gradient_mask", + "image": { + "image_name": "canvas-composite.png" + }, + "minimum_denoise": 0, + "type": "create_gradient_mask", + "mask": { + "image_name": "canvas-mask.png" + } + }, + "invert_anima_inpaint_adapter_mask": { + "is_intermediate": true, + "use_cache": true, + "id": "invert_anima_inpaint_adapter_mask", + "image": { + "image_name": "canvas-mask.png" + }, + "max": 0, + "min": 255, + "type": "img_lerp" + }, + "anima_inpaint_adapter": { + "is_intermediate": true, + "use_cache": true, + "control_model": { + "base": "anima", + "cond_in_channels": 4, + "key": "anima-inpaint", + "name": "Anima LLLite Inpainting", + "type": "controlnet", + "hash": "blake3:inpaint" + }, + "id": "anima_inpaint_adapter", + "image": { + "image_name": "canvas-composite.png" + }, + "type": "anima_lllite", + "weight": 0.75 + }, + "control_lllite_collector": { + "is_intermediate": true, + "use_cache": true, + "id": "control_lllite_collector", + "type": "collect" + }, + "expand_mask": { + "is_intermediate": true, + "use_cache": true, + "fade_size_px": 16, + "id": "expand_mask", + "type": "expand_mask_with_fade" + }, + "canvas_output": { + "is_intermediate": true, + "use_cache": false, + "id": "canvas_output", + "layer_base": { + "image_name": "canvas-composite.png" + }, + "type": "invokeai_img_blend" + } + } + }, "inpaint_three_adapters": { "edges": [ { @@ -833,7 +1314,7 @@ } } ], - "id": "anima_lllite_canvas_inpaint_adapter", + "id": "anima_lllite_canvas_outpaint_adapter", "nodes": { "negative_prompt": { "is_intermediate": true, 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 5bc142abe30..3b2abc5ea33 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 @@ -1148,6 +1148,7 @@ describe('compileCanvasGraph — Anima ControlNet-LLLite (integration)', () => { }; let threeAdapters: BackendGraphContract; let inpaintAdapterGraph: BackendGraphContract; + let outpaintAdapterGraph: BackendGraphContract; try { // Inpainting exercises the whole denoise wiring the adapters join; one collector serves any adapter count. threeAdapters = compile(hashed(animaModel, 'blake3:anima'), 'inpaint', { @@ -1158,8 +1159,16 @@ describe('compileCanvasGraph — Anima ControlNet-LLLite (integration)', () => { ], settings, }).backendGraph; + // The inpaint adapter alone: it reads the composite and the uploaded mask as fixed image fields. + inpaintAdapterGraph = compile(hashed(animaModel, 'blake3:anima'), 'inpaint', { + settings: { + ...settings, + animaInpaintAdapterModel: hashed(inpaintAdapter, 'blake3:inpaint'), + animaInpaintAdapterWeight: 0.75, + }, + }).backendGraph; // The inpaint adapter beside a control layer: the masked adapter and the unmasked one share the collector. - inpaintAdapterGraph = compile(hashed(animaModel, 'blake3:anima'), 'outpaint', { + outpaintAdapterGraph = compile(hashed(animaModel, 'blake3:anima'), 'outpaint', { controlLayers: [lliteLayer('sketch-layer', 'sketch', 0.8)], maskImageName: 'canvas-mask.png', settings: { @@ -1175,8 +1184,12 @@ describe('compileCanvasGraph — Anima ControlNet-LLLite (integration)', () => { const fixture = { _comment: 'Generated by compileCanvasGraph.test.ts; regenerate with `vitest -u`. Consumed by tests/app/invocations/test_frontend_anima_lllite_graphs.py, which validates the graphs against the backend invocations.', + inpaint_inpaint_adapter: { ...inpaintAdapterGraph, id: 'anima_lllite_canvas_inpaint_adapter' }, inpaint_three_adapters: { ...threeAdapters, id: 'anima_lllite_canvas' }, - outpaint_inpaint_adapter_and_control_layer: { ...inpaintAdapterGraph, id: 'anima_lllite_canvas_inpaint_adapter' }, + outpaint_inpaint_adapter_and_control_layer: { + ...outpaintAdapterGraph, + id: 'anima_lllite_canvas_outpaint_adapter', + }, }; await expect(`${JSON.stringify(fixture, null, 2)}\n`).toMatchFileSnapshot( '../__snapshots__/animaLLLiteCanvasGraphs.json' @@ -1238,6 +1251,24 @@ describe('compileCanvasGraph — PiD decode', () => { expect(metadata?.height).toBe(bbox.height); }); + it('txt2img fit with an off-grid bbox: resizes the 4x decode once, straight to the bbox', () => { + // SDXL snaps 1000x700 to 1000x704 for denoising; legacy resizes the decode once to the final size. + const { backendGraph } = compile(sdxlModel, 'txt2img', { + bbox: { height: 700, width: 1000, x: 0, y: 0 }, + settings: pidSettings(sdxlModel, 'fit'), + }); + + expect(backendGraph.nodes.noise).toMatchObject({ height: 704, width: 1000 }); + const resizes = Object.values(backendGraph.nodes).filter((node) => node.type === 'img_resize'); + expect(resizes).toHaveLength(1); + expect(backendGraph.nodes.canvas_output).toMatchObject({ height: 700, type: 'img_resize', width: 1000 }); + expect(getEdge(backendGraph, 'canvas_output', 'image')?.source.node_id).toBe(pidDecodeNode(backendGraph)?.id); + expect(backendGraph.nodes.canvas_processing_output).toBeUndefined(); + const metadata = getNodeByType(backendGraph, 'core_metadata'); + expect(metadata).toMatchObject({ height: 700, width: 1000 }); + expect(getEdge(backendGraph, 'canvas_output', 'metadata')?.source.node_id).toBe(metadata?.id); + }); + it('txt2img native: denoises at a quarter of the bbox and the PiD decode owns canvas_output unresized', () => { const { backendGraph } = compile(fluxModel, 'txt2img', { settings: pidSettings(fluxModel, 'native') }); @@ -1283,6 +1314,21 @@ describe('compileCanvasGraph — PiD decode', () => { expect(backendGraph.nodes.canvas_processing_output).toBeUndefined(); }); + it('img2img native (SDXL): downscales the composite to the quarter size the noise node denoises at', () => { + const { backendGraph } = compile(sdxlModel, 'img2img', { settings: pidSettings(sdxlModel, 'native') }); + + expect(backendGraph.nodes.noise).toMatchObject({ height: bbox.height / 4, width: bbox.width / 4 }); + expect(backendGraph.nodes.canvas_resize_initial_to_processing).toMatchObject({ + height: bbox.height / 4, + image: { image_name: 'canvas-composite.png' }, + type: 'img_resize', + width: bbox.width / 4, + }); + expect(getEdge(backendGraph, 'canvas_i2l', 'image')?.source.node_id).toBe('canvas_resize_initial_to_processing'); + expect(backendGraph.nodes.canvas_output?.type).toBe('sdxl_pid_decode'); + expect(backendGraph.nodes.canvas_processing_output).toBeUndefined(); + }); + it.each(PID_BASES)('resolves the encode VAE for a $base native img2img', (model) => { const { backendGraph } = compile(model, 'img2img', { settings: { ...COMPONENT_OVERRIDES[model.base], ...pidSettings(model, 'native') }, diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts index 6894505ef0a..421d52ec681 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts @@ -12,6 +12,7 @@ import { findVaeSource, GRAPH_BUILDERS } from '@features/generation/core/graph'; import { addEdge, addNode, toGraphContract } from '@features/generation/core/graphBuilder'; import { addKrea2ConditioningEnhancers } from '@features/generation/core/krea2Conditioning'; import { getIsPidActive } from '@features/generation/core/pid'; +import { shouldUsePidDecode } from '@features/generation/core/pidGraph'; import { MAX_ANIMA_INPAINT_ADAPTER_WEIGHT, MIN_ANIMA_INPAINT_ADAPTER_WEIGHT } from '@features/generation/core/settings'; import type { @@ -210,12 +211,23 @@ const resizeCanvasOutputToBbox = ( graph: BackendGraphContract, bbox: CompileCanvasGraphInput['bbox'], processingSize: CanvasSize, - destination: CompileCanvasGraphInput['destination'] + destination: CompileCanvasGraphInput['destination'], + /** PiD Fit ends in an `img_resize` of the 4x decode to the processing size (`addPidDecode`). */ + isPidFit: boolean ): void => { if (sizesMatch(bbox, processingSize)) { return; } + // Retarget PiD Fit's resize so the output is resampled once, straight to the bbox (legacy parity), rather + // than through the processing size. + const terminalResize = graph.nodes.canvas_output; + if (isPidFit && terminalResize?.type === 'img_resize') { + terminalResize.width = bbox.width; + terminalResize.height = bbox.height; + return; + } + const processingOutput = renameNode(graph, 'canvas_output', 'canvas_processing_output'); processingOutput.is_intermediate = true; @@ -807,7 +819,13 @@ export const compileCanvasGraph = (input: CompileCanvasGraphInput): CompiledCanv } if (mode === 'txt2img' || mode === 'img2img') { - resizeCanvasOutputToBbox(backendGraph, bbox, processingSize, destination); + resizeCanvasOutputToBbox( + backendGraph, + bbox, + processingSize, + destination, + shouldUsePidDecode(settings, model.base) && settings.pidMode === 'fit' + ); } setCanvasMetadataDimensions(backendGraph, bbox); diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.ts index c0a67c156bd..4f1eb5be30d 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.ts @@ -103,6 +103,10 @@ export const DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT = 1; export const MIN_ANIMA_INPAINT_ADAPTER_WEIGHT = -10; export const MAX_ANIMA_INPAINT_ADAPTER_WEIGHT = 10; +/** Persisted and recalled weights share this clamp, so an out-of-range value never blocks canvas validation. */ +export const clampAnimaInpaintAdapterWeight = (weight: number): number => + Math.min(Math.max(weight, MIN_ANIMA_INPAINT_ADAPTER_WEIGHT), MAX_ANIMA_INPAINT_ADAPTER_WEIGHT); + export const DEFAULT_LORA_WEIGHT_CONFIG = { coarseStep: 0.05, initial: 0.75, @@ -765,12 +769,10 @@ export const normalizeGenerateSettings = (values: unknown): GenerateSettings | n qwen3VLEncoderModel: getModelIdentifierOrNull(values.qwen3VLEncoderModel), qwen35EncoderModel: getModelIdentifierOrNull(values.qwen35EncoderModel), animaInpaintAdapterModel: getModelIdentifierOrNull(values.animaInpaintAdapterModel), - animaInpaintAdapterWeight: getClampedNumber( - values, - 'animaInpaintAdapterWeight', - MIN_ANIMA_INPAINT_ADAPTER_WEIGHT, - MAX_ANIMA_INPAINT_ADAPTER_WEIGHT, - DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT + animaInpaintAdapterWeight: clampAnimaInpaintAdapterWeight( + hasFiniteNumber(values, 'animaInpaintAdapterWeight') + ? (values.animaInpaintAdapterWeight as number) + : DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT ), wanT5EncoderModel: getModelIdentifierOrNull(values.wanT5EncoderModel), wanLowNoiseModel: getMainModelOrNull(values.wanLowNoiseModel), diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index 8561bbc1a9b..f79d7daacb3 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -2,6 +2,7 @@ export { ASPECT_RATIO_MAP, calculateNewSize, + clampAnimaInpaintAdapterWeight, clampDimension, cloneGenerateWidgetValues, DEFAULT_LORA_WEIGHT_CONFIG, 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 45dc748a728..ff88676be2e 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -1260,6 +1260,68 @@ describe('anima inpaint adapter', () => { expect(result?.fields).toContain('animaInpaintAdapterWeight'); }); + it('clears a stale adapter from an Anima inpaint record that lacks the key, which the backend persists for none', () => { + const currentValues = createValues({ animaInpaintAdapterModel: inpaintAdapter, animaInpaintAdapterWeight: 0.5 }); + const { + anima_inpaint_adapter: _adapter, + anima_inpaint_adapter_weight: _weight, + ...withoutAdapter + } = canvasInpaintMetadata; + const recall = (metadata: Record, supportedModels = [animaModel]) => + buildImageRecallSettings({ + currentValues, + image, + kind: 'all', + metadata, + models: [...models, sd1Model], + supportedModels, + vaeModels: [qwenImageVae], + }); + + for (const generation_mode of ['anima_inpaint', 'anima_outpaint']) { + const cleared = recall({ ...withoutAdapter, generation_mode }); + expect(cleared?.values.animaInpaintAdapterModel).toBeNull(); + expect(cleared?.values.animaInpaintAdapterWeight).toBe(0.5); + expect(cleared?.fields).toContain('components'); + expect(cleared?.fields).not.toContain('animaInpaintAdapterWeight'); + } + + // A record from a mode that never writes the key, or none at all, says nothing about the adapter. + expect(recall(withoutAdapter)?.values.animaInpaintAdapterModel).toEqual(inpaintAdapter); + expect(recall({ ...withoutAdapter, generation_mode: 'anima_img2img' })?.values.animaInpaintAdapterModel).toEqual( + inpaintAdapter + ); + const notAnima = recall({ generation_mode: 'inpaint', model: { key: sd1Model.key } }, [sd1Model, animaModel]); + expect(notAnima?.values.model).toBe(sd1Model); + expect(notAnima?.fields).not.toContain('components'); + + // The key present on such a record still sets the adapter. + const restored = recall({ ...canvasInpaintMetadata, generation_mode: 'anima_inpaint' }); + expect(restored?.values.animaInpaintAdapterModel).toBe(inpaintAdapter); + expect(restored?.values.animaInpaintAdapterWeight).toBe(0.7); + }); + + it.each([ + [25, 10], + [-25, -10], + ])( + 'clamps a recorded weight of %s to %s instead of applying a value canvas validation rejects', + (recorded, applied) => { + const result = buildImageRecallSettings({ + currentValues: createValues(), + image, + kind: 'all', + metadata: { ...canvasInpaintMetadata, anima_inpaint_adapter_weight: recorded }, + models, + supportedModels: [animaModel], + vaeModels: [qwenImageVae], + }); + + expect(result?.values.animaInpaintAdapterWeight).toBe(applied); + expect(result?.fields).toContain('animaInpaintAdapterWeight'); + } + ); + it('skips the adapter and weight when the image is not an Anima image or the adapter is a control adapter', () => { const notAnima = buildImageRecallSettings({ currentValues: createValues({ animaInpaintAdapterWeight: 1 }), diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index e84a954cb30..9d72e8ec608 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -10,6 +10,7 @@ import type { } from '@features/generation/contracts'; import { + clampAnimaInpaintAdapterWeight, clampDimension, cloneGenerateWidgetValues, deriveAspectRatioId, @@ -420,7 +421,10 @@ const getMetadataKrea2Rebalance = ( const hasKrea2Rebalance = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => Object.keys(getMetadataKrea2Rebalance(metadata, model)).length > 0; -/** The canvas records the adapter's weight beside the adapter; only an Anima image can carry it. */ +/** + * The canvas records the adapter's weight beside the adapter; only an Anima image can carry it. An out-of-range + * weight is clamped rather than applied, which would block every inpaint until the user edits it. + */ const getMetadataAnimaInpaintAdapterWeight = ( metadata: unknown, model: GenerateModelConfig | undefined @@ -431,7 +435,7 @@ const getMetadataAnimaInpaintAdapterWeight = ( const weight = getNumber(metadata, 'anima_inpaint_adapter_weight'); - return weight === null ? {} : { animaInpaintAdapterWeight: weight }; + return weight === null ? {} : { animaInpaintAdapterWeight: clampAnimaInpaintAdapterWeight(weight) }; }; const hasAnimaInpaintAdapterWeight = (metadata: unknown, model: GenerateModelConfig | undefined): boolean => @@ -460,6 +464,12 @@ interface RecalledComponent { setting: RecalledComponentSetting; /** The setting holds a main model (a component source or second transformer), not an encoder. */ isMainModel?: true; + /** + * The `generation_mode` values whose graphs record the key whenever the component ran. `core_metadata` drops + * `null` on write, so on these records an absent key means "none" and clears the slot; elsewhere it means + * "unknown" and keeps the current pick. + */ + absentMeansNoneIn?: readonly string[]; } /** @@ -472,7 +482,11 @@ const RECALLED_COMPONENTS: readonly RecalledComponent[] = [ { isMainModel: true, metadataKey: 'wan_component_source', setting: 'componentSourceModel' }, { isMainModel: true, metadataKey: 'ideogram4_unconditional_model', setting: 'ideogram4UnconditionalModel' }, { isMainModel: true, metadataKey: 'wan_transformer_low_noise', setting: 'wanLowNoiseModel' }, - { metadataKey: 'anima_inpaint_adapter', setting: 'animaInpaintAdapterModel' }, + { + absentMeansNoneIn: ['anima_inpaint', 'anima_outpaint'], + metadataKey: 'anima_inpaint_adapter', + setting: 'animaInpaintAdapterModel', + }, { metadataKey: 'clip_embed_model', setting: 'clipEmbedModel' }, { metadataKey: 'gemma2_encoder', setting: 'gemma2EncoderModel' }, { metadataKey: 'mistral_encoder', setting: 'mistralEncoderModel' }, @@ -489,10 +503,17 @@ const getRecalledComponent = ( metadata: unknown, component: RecalledComponent, models: readonly ComponentModelConfig[] -): ComponentModelConfig | null | undefined => - component.isMainModel +): ComponentModelConfig | null | undefined => { + if (isRecord(metadata) && !(component.metadataKey in metadata) && component.absentMeansNoneIn) { + const mode = getString(metadata, 'generation_mode'); + + return mode !== null && component.absentMeansNoneIn.includes(mode) ? null : undefined; + } + + return component.isMainModel ? getMetadataMainModel(metadata, component.metadataKey, models) : getMetadataComponent(metadata, component.metadataKey, models); +}; /** Recorded components that resolve to installed models; `null` clears a slot the image left empty. */ const getComponentPatch = ( diff --git a/tests/app/invocations/test_frontend_anima_lllite_graphs.py b/tests/app/invocations/test_frontend_anima_lllite_graphs.py index 92dcaa4ac7d..4127cf5d710 100644 --- a/tests/app/invocations/test_frontend_anima_lllite_graphs.py +++ b/tests/app/invocations/test_frontend_anima_lllite_graphs.py @@ -2,12 +2,13 @@ `animaLLLiteCanvasGraphs.json` is written by webv2's `compileCanvasGraph.test.ts` (regenerate with `vitest -u`) from the real canvas compiler: a complete inpainting submission with three control-layer -adapters, and an outpainting submission that adds the optional 4-channel inpaint adapter beside a control -layer. The frontend routes any number of adapters through one collector, so these graphs cover the edge -types for every count, masked and unmasked. This module checks more than names: pydantic validates every -node against its invocation (bounds such as `anima_lllite.weight` in [-10, 10] included), -`Graph.validate_self` checks every edge type through the collector into `anima_denoise.control_lllite`, -and the denoiser's own normalization runs on the fields the adapter nodes would emit. No model is loaded. +adapters, an inpainting submission with the optional 4-channel inpaint adapter alone, and an outpainting +submission that adds that adapter beside a control layer. The frontend routes any number of adapters through +one collector, so these graphs cover the edge types for every count, masked and unmasked. This module checks +more than names: pydantic validates every node against its invocation (bounds such as `anima_lllite.weight` in +[-10, 10] included), `Graph.validate_self` checks every edge type through the collector into +`anima_denoise.control_lllite`, the denoiser's own normalization runs on the fields the adapter nodes would +emit, and `core_metadata` is serialised the way the gallery persists it. No model is loaded. """ import json @@ -20,6 +21,7 @@ from invokeai.app.invocations.anima.anima_lllite import AnimaLLLiteField, AnimaLLLiteInvocation from invokeai.app.invocations.fields import ImageField from invokeai.app.invocations.image import ImageLerpInvocation +from invokeai.app.invocations.metadata import CoreMetadataInvocation from invokeai.app.services.shared.graph import Graph FIXTURE_PATH = ( @@ -74,6 +76,7 @@ def test_the_fixtures_combine_several_distinct_adapters() -> None: for name, graph in GRAPHS.items() } assert len(keys["inpaint_three_adapters"]) == 3 and len(set(keys["inpaint_three_adapters"])) == 3 + assert len(keys["inpaint_inpaint_adapter"]) == 1 assert len(keys["outpaint_inpaint_adapter_and_control_layer"]) == 2 assert len(set(keys["outpaint_inpaint_adapter_and_control_layer"])) == 2 @@ -116,7 +119,7 @@ def test_every_adapter_reaches_control_lllite_through_one_collector(graph: Graph def test_control_layers_send_a_control_image_and_no_inpaint_mask(graph: Graph) -> None: control_layers = _control_layer_nodes(graph) - assert control_layers + assert len(control_layers) == len(_lllite_nodes(graph)) - (INPAINT_ADAPTER_NODE_ID in graph.nodes) for node in control_layers: assert node.image.image_name assert node.mask is None @@ -126,14 +129,34 @@ def test_control_layers_send_a_control_image_and_no_inpaint_mask(graph: Graph) - assert not any(e.destination.node_id in control_layer_ids for e in graph.edges) -def test_the_inpaint_adapter_takes_the_infilled_image_and_an_inverted_mask() -> None: - """The adapter wants white = inpaint; the canvas mask is white = keep, so an `img_lerp` 255 -> 0 feeds its mask.""" - graph = Graph.model_validate(GRAPHS["outpaint_inpaint_adapter_and_control_layer"]) +def _inpaint_adapter(name: str) -> tuple[Graph, AnimaLLLiteInvocation]: + graph = Graph.model_validate(GRAPHS[name]) graph.validate_self() adapter = graph.nodes[INPAINT_ADAPTER_NODE_ID] assert isinstance(adapter, AnimaLLLiteInvocation) assert adapter.weight == 0.75 assert adapter.control_model.base.value == "anima" + return graph, adapter + + +def test_the_inpaint_adapter_takes_the_composite_and_the_inverted_uploaded_mask_when_inpainting() -> None: + """Without Scale Before Processing the canvas uploads are read as fixed image fields, not through resize nodes.""" + graph, adapter = _inpaint_adapter("inpaint_inpaint_adapter") + + assert adapter.image is not None and adapter.image.image_name == "canvas-composite.png" + inputs = {e.destination.field: e.source for e in graph.edges if e.destination.node_id == adapter.id} + assert set(inputs) == {"mask"} + invert = graph.nodes[inputs["mask"].node_id] + assert isinstance(invert, ImageLerpInvocation) + assert (invert.min, invert.max) == (255, 0) + assert invert.image.image_name == "canvas-mask.png" + assert inputs["mask"].field == "image" + assert not any(e.destination.node_id == invert.id for e in graph.edges) + + +def test_the_inpaint_adapter_takes_the_infilled_image_and_an_inverted_mask_when_outpainting() -> None: + """The adapter wants white = inpaint; the canvas mask is white = keep, so an `img_lerp` 255 -> 0 feeds its mask.""" + graph, adapter = _inpaint_adapter("outpaint_inpaint_adapter_and_control_layer") inputs = {e.destination.field: e.source for e in graph.edges if e.destination.node_id == adapter.id} assert set(inputs) == {"image", "mask"} @@ -147,6 +170,31 @@ def test_the_inpaint_adapter_takes_the_infilled_image_and_an_inverted_mask() -> assert graph.nodes[mask_source.node_id].get_type() == "mask_combine" +def _persisted_metadata(name: str) -> dict[str, Any]: + graph = Graph.model_validate(GRAPHS[name]) + metadata = next(node for node in graph.nodes.values() if isinstance(node, CoreMetadataInvocation)) + return metadata.invoke(None).metadata.root # type: ignore[arg-type] + + +def test_persisted_metadata_names_the_inpaint_adapter_only_when_one_ran() -> None: + """`core_metadata` drops `None` on write, so a graph cannot record "no adapter" explicitly. Recall therefore + reads an absent key on an `anima_inpaint`/`anima_outpaint` record as none, which holds only while the compiler + writes the key whenever an adapter ran.""" + without_adapter = _persisted_metadata("inpaint_three_adapters") + assert without_adapter["generation_mode"] == "anima_inpaint" + assert "anima_inpaint_adapter" not in without_adapter + assert "anima_inpaint_adapter_weight" not in without_adapter + + for name, mode in ( + ("inpaint_inpaint_adapter", "anima_inpaint"), + ("outpaint_inpaint_adapter_and_control_layer", "anima_outpaint"), + ): + persisted = _persisted_metadata(name) + assert persisted["generation_mode"] == mode + assert persisted["anima_inpaint_adapter"]["key"] == "anima-inpaint" + assert persisted["anima_inpaint_adapter_weight"] == 0.75 + + def test_the_denoiser_accepts_the_adapters_it_would_receive(graph: Graph) -> None: fields = [_control_field(graph, node) for node in _lllite_nodes(graph)]