diff --git a/docs/src/content/docs/Users Guide/03.Models/Local Models/sdxl.mdx b/docs/src/content/docs/Users Guide/03.Models/Local Models/sdxl.mdx index 84a8e26b810..3d89326a1c6 100644 --- a/docs/src/content/docs/Users Guide/03.Models/Local Models/sdxl.mdx +++ b/docs/src/content/docs/Users Guide/03.Models/Local Models/sdxl.mdx @@ -181,8 +181,14 @@ image on its own. - **Installing** — install **SDXL Refiner** from the starter models, or any refiner checkpoint or Diffusers folder; InvokeAI recognizes refiners automatically and lists them under their own **SDXL Refiner** family. -- **Using it** — the refiner is not selectable in the Generate panel or on the Canvas. Use it in the - workflow editor. +- **Using it** — with an SDXL main model selected, the Generate panel shows a **Refiner** section. + Pick the refiner there and set **Refiner start** (the point where the base model hands over, 0.8 + by default), its steps, CFG, scheduler and the positive and negative **aesthetic scores**. The + same settings apply to SDXL generations from the Canvas, including inpainting and outpainting; there + the base pass cannot start after the hand-over, so the effective denoising strength is capped at + 1 − Refiner start. The refiner is never offered as a main model, and it cannot be combined with PiD + decoding. An image made with a refiner records it in its metadata, so **Recall All** and + **Remix Image** restore it. The workflow editor offers the same nodes for custom graphs. A refiner workflow runs two denoise passes over the same latents: diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx index 5b6afb8f116..1ec9cbe5742 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx @@ -61,6 +61,10 @@ A negative prompt lists what you do *not* want in the image, such as *blurry, wa Not every model uses a negative prompt. SD 1.5, SDXL, SD 3.5 and CogView4 always use it. Several newer models, including Z-Image, Qwen Image, Qwen-Image-2.1 and Krea-2, use it only when CFG is above 1, and the box says so. FLUX.1, FLUX.2 and Ideogram 4 do not use a negative prompt, and the box is not shown for them. +### Refiner + +The **Refiner** section appears for SDXL models. Pick an **SDXL Refiner** model to have it finish the last part of the denoising schedule; the base model stops at **Refiner start** (0.8 by default, so the refiner handles the final 20%) and the refiner continues from there with its own **Refiner steps**, **Refiner CFG** and **Refiner scheduler**. The **Positive** and **Negative aesthetic score** condition the refiner on the aesthetic rating it was trained with. A **Refiner start** of 1 turns the refiner **Off** and disables its tuning controls. Lower **Refiner start** to reactivate the selected refiner; the start control and model picker stay available while it is off. The controls are disabled until a refiner is selected, and refiner models are only offered here, never as the main model. The refiner also runs for SDXL generations from the Canvas. It cannot be combined with **PiD Decode**. See [SDXL](/users-guide/models/local-models/sdxl/#sdxl-refiner). + ### Advanced settings The **Advanced** section appears for models with extra options, and its header counts the settings you have changed. Depending on the model it can include: @@ -79,7 +83,7 @@ Two output filters live in **Settings** under **Project** rather than in the pan Each model has recommended settings, which you can change in the Model Manager. **Reset all to model defaults**, the ↺ button in the Generate panel's title bar, applies them: size, steps, CFG or guidance, scheduler and VAE settings. Your prompts are left alone. The button is disabled when your settings already match the defaults. -To return to the settings of an earlier image instead, right-click it in the Gallery or Preview and open **Recall Metadata**. **Recall All** restores its model, concepts, prompts, seed, size and render settings; **Remix Image** does the same but keeps a new seed; and **Use Prompt**, **Use Seed** and **Use Size** recall one thing at a time. Recalled concepts replace the ones in the panel at the weights the image used; any that are not installed or do not fit the model are listed in a notice. If the image's model is not installed, or the image was saved from the canvas, your current concepts are kept. +To return to the settings of an earlier image instead, right-click it in the Gallery or Preview and open **Recall Metadata**. **Recall All** restores its model, concepts, prompts, seed, size, render and refiner settings; **Remix Image** does the same but keeps a new seed; and **Use Prompt**, **Use Seed** and **Use Size** recall one thing at a time. Recalled concepts replace the ones in the panel at the weights the image used; any that are not installed or do not fit the model are listed in a notice. If the image's model is not installed, or the image was saved from the canvas, your current concepts are kept. To keep a combination of settings for later, use **Presets** in the panel's title bar to save the current settings under a name and apply them again. diff --git a/docs/src/content/docs/development/Guides/recall-api.mdx b/docs/src/content/docs/development/Guides/recall-api.mdx index 38e463e86a0..8478a3fe4a9 100644 --- a/docs/src/content/docs/development/Guides/recall-api.mdx +++ b/docs/src/content/docs/development/Guides/recall-api.mdx @@ -513,11 +513,26 @@ administrators' sessions ignore other users' recalls. | `loras` | Replaces the LoRA list. Entries that are not installed or not compatible with the effective model are dropped and reported. | | `ip_adapters`, `reference_images` | Feed the reference image list, retargeted to the effective model's reference-image type. Files no longer in the gallery are dropped and reported; if none of the recalled images exist the project keeps its current list. `append=true` adds behind the existing entries without re-checking them; the model's reference-image limit is enforced (newest entries are dropped first). | | `control_layers` | **Not applied.** Control layers live on the Canvas in webv2, not in the Generate panel. | -| `denoise_strength`, `refiner_*` | **Not applied.** The Generate panel has no image-to-image strength or SDXL refiner settings. | +| `refiner_model`, `refiner_steps`, `refiner_cfg_scale`, `refiner_denoise_start`, `refiner_positive_aesthetic_score`, `refiner_negative_aesthetic_score` | Applied for an effective SDXL main model. The refiner must be an installed `sdxl-refiner` main model, matched by key or name. Steps must be a whole number ≥1, CFG ≥1, denoising start 0–1, and aesthetic scores 1–10. | +| `denoise_strength` | **Not applied.** The Generate panel has no image-to-image strength setting. | + +A refiner identifier that reaches the frontend but is missing, invalid or +incompatible is reported; after any main-model compatibility transition, the +current refiner selection and tuning are kept together, and the request's +refiner tuning is skipped. Invalid tuning values +are reported and leave their current values unchanged. If a requested main-model +key is unavailable in the frontend catalog, its refiner selection and tuning are +also skipped. This differs from an unresolved main-model name dropped by the +backend: as described in [Model Name Resolution](#model-name-resolution), the +remaining parameters then apply to the current model. In `strict` mode, omitted scalar parameters reset to the effective model's defaults (empty prompts, randomized seed, default steps/CFG/scheduler/size), -and omitted lists clear. An omitted `model` keeps the current model. +and omitted lists clear. An omitted `model` keeps the current model. An omitted +`refiner_model` clears the refiner slot, and the five omitted refiner tuning +fields listed above reset to their defaults, unless a failed model lookup +prevents the refiner update. The refiner scheduler is unchanged; this API has no +`refiner_scheduler` parameter. Events are applied in arrival order; an `append` request that overlaps a still-loading replace sees the replaced list. The handler is loaded on the diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 8d2183f5cc8..7a451ab4c18 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -563,6 +563,8 @@ "source:src/features/generation/core/promptTemplates.ts", "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", + "source:src/features/generation/core/sdxlRefiner.ts", + "source:src/features/generation/core/sdxlRefinerGraph.ts", "source:src/features/generation/core/settings.ts", "source:src/features/generation/core/supportedBases.ts", "source:src/features/generation/data/architectureCapabilitiesApi.ts", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 718797b8620..fd47504b034 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -990,6 +990,8 @@ "source:src/features/generation/core/promptTemplates.ts", "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", + "source:src/features/generation/core/sdxlRefiner.ts", + "source:src/features/generation/core/sdxlRefinerGraph.ts", "source:src/features/generation/core/settings.ts", "source:src/features/generation/core/supportedBases.ts", "source:src/features/generation/core/systemPrompts.ts", @@ -1020,6 +1022,7 @@ "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", "source:src/features/generation/ui/GeneratePresetsPopover.tsx", + "source:src/features/generation/ui/GenerateRefinerSection.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -2080,6 +2083,8 @@ "source:src/features/generation/core/promptTemplates.ts", "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", + "source:src/features/generation/core/sdxlRefiner.ts", + "source:src/features/generation/core/sdxlRefinerGraph.ts", "source:src/features/generation/core/settings.ts", "source:src/features/generation/core/supportedBases.ts", "source:src/features/generation/core/systemPrompts.ts", @@ -2110,6 +2115,7 @@ "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", "source:src/features/generation/ui/GeneratePresetsPopover.tsx", + "source:src/features/generation/ui/GenerateRefinerSection.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", @@ -3329,6 +3335,8 @@ "source:src/features/generation/core/promptTemplates.ts", "source:src/features/generation/core/referenceImage.ts", "source:src/features/generation/core/resolveGenerateWidgetValues.ts", + "source:src/features/generation/core/sdxlRefiner.ts", + "source:src/features/generation/core/sdxlRefinerGraph.ts", "source:src/features/generation/core/settings.ts", "source:src/features/generation/core/supportedBases.ts", "source:src/features/generation/core/systemPrompts.ts", @@ -3359,6 +3367,7 @@ "source:src/features/generation/ui/GenerateHeaderActions.tsx", "source:src/features/generation/ui/GenerateModelCard.tsx", "source:src/features/generation/ui/GeneratePresetsPopover.tsx", + "source:src/features/generation/ui/GenerateRefinerSection.tsx", "source:src/features/generation/ui/GenerateRenderSection.tsx", "source:src/features/generation/ui/GenerateSettingsForm.tsx", "source:src/features/generation/ui/GenerateWidgetView.tsx", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 5908bfc964f..7188c71fc4a 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1108,6 +1108,39 @@ "width": { "heading": "Width", "paragraphs": ["Width of the generated image. Must be a multiple of 8."] + }, + "refinerModel": { + "heading": "Refiner model", + "paragraphs": [ + "An SDXL Refiner model that takes over the last part of the denoising schedule from the SDXL base model, adding fine detail.", + "Only SDXL Refiner models are listed here; they cannot be used as the main model." + ] + }, + "refinerSteps": { + "heading": "Refiner steps", + "paragraphs": ["Number of steps the refiner schedule is divided into. Only the part after Refiner start runs."] + }, + "refinerCfgScale": { + "heading": "Refiner CFG", + "paragraphs": ["How strictly the refiner follows the prompt during its pass, like the main CFG."] + }, + "refinerScheduler": { + "heading": "Refiner scheduler", + "paragraphs": ["Scheduler used for the refiner pass, independent of the base model's scheduler."] + }, + "refinerStart": { + "heading": "Refiner start", + "paragraphs": [ + "Where in the schedule the base model hands over to the refiner.", + "0.8 means the base model denoises the first 80% and the refiner finishes the last 20%; 0 hands the whole schedule to the refiner, and 1 leaves it nothing to do, so the refiner is not run.", + "On the Canvas the base pass cannot start after the hand-over, so an image-to-image strength above 1 − Refiner start is capped there." + ] + }, + "refinerAestheticScore": { + "heading": "Aesthetic score", + "paragraphs": [ + "The refiner was trained with an aesthetic rating from 1 to 10. The positive score steers the result toward images rated that highly; the negative score steers it away from images rated that low." + ] } }, "models": { @@ -2750,6 +2783,18 @@ "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.", + "presetSettingsCleared": "Settings cleared by preset", + "presetSettingsClearedDescription": "Cleared: {{labels}}.", + "refiner": "Refiner", + "refinerModel": "Refiner model", + "refinerNone": "None", + "refinerModelsScope": "SDXL Refiner models", + "refinerSteps": "Refiner steps", + "refinerCfgScale": "Refiner CFG", + "refinerScheduler": "Refiner scheduler", + "refinerStart": "Refiner start", + "refinerPositiveAestheticScore": "Positive aesthetic score", + "refinerNegativeAestheticScore": "Negative aesthetic score", "animaInpaintAdapterWeight": "Inpaint adapter weight" }, "upscale": { @@ -4347,6 +4392,7 @@ "nothingRecalled": "No recallable image data", "partiallyRecalled": "Some image data was not recalled", "unnamedConcept": "unnamed concept", + "unnamedRefiner": "unnamed refiner", "skippedConcept": { "ambiguous": "{{name}} (matches more than one installed model)", "duplicate": "{{name}} (listed more than once)", @@ -4354,6 +4400,12 @@ "invalid": "{{name}} (unreadable metadata)", "modelUnavailable": "{{name}} (the image's model is not available)", "unresolved": "{{name}} (not installed)" + }, + "skippedRefiner": { + "ambiguous": "Refiner not restored: {{name}} matches more than one installed model; the current refiner settings were kept.", + "invalid": "Refiner not restored: unreadable metadata; the current refiner settings were kept.", + "modelUnavailable": "Refiner not restored: the image's model is not available; the current refiner settings were kept.", + "unresolved": "Refiner not restored: {{name}} is not installed; the current refiner settings were kept." } }, "star": { diff --git a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs index b29a99707e6..2103c11d90e 100644 --- a/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs +++ b/invokeai/frontend/webv2/scripts/mock-backend-fixtures.mjs @@ -395,14 +395,20 @@ const createQueueItems = (count) => const MODEL_BASES = ['sd-1', 'sdxl', 'flux', 'flux2', 'qwen-image']; const MODEL_TYPES = ['main', 'lora', 'controlnet', 'vae', 'embedding']; +// Main models cycle through their own bases so Generate sees SDXL mains and refiners, not only SD 1.5. +const MAIN_MODEL_BASES = ['sd-1', 'sdxl', 'sdxl-refiner']; const createModels = (count) => range(count, (index) => { const id = ordinal(index); const type = MODEL_TYPES[index % MODEL_TYPES.length]; + const base = + type === 'main' + ? MAIN_MODEL_BASES[Math.floor(index / MODEL_TYPES.length) % MAIN_MODEL_BASES.length] + : MODEL_BASES[index % MODEL_BASES.length]; return { - base: MODEL_BASES[index % MODEL_BASES.length], + base, cover_image: null, default_settings: type === 'main' ? { cfg_scale: 7, height: 1024, steps: 30, width: 1024 } : null, description: `Deterministic representative ${type} model ${id}.`, diff --git a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json index 61bcecb9982..b27beed6356 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json +++ b/invokeai/frontend/webv2/src/features/generation/core/__snapshots__/generateGraphNodeTypes.json @@ -44,7 +44,8 @@ "sdxl": { "componentsFilled": { "diffusers": [], - "standalone-components": [] + "standalone-components": [], + "diffusers-with-refiner": [] }, "nodeTypes": [ "collect", @@ -57,6 +58,8 @@ "noise", "sdxl_compel_prompt", "sdxl_model_loader", + "sdxl_refiner_compel_prompt", + "sdxl_refiner_model_loader", "string" ] }, @@ -676,6 +679,25 @@ "cpu" ], "ref_images": [], + "refiner_cfg_scale": [ + 7.5 + ], + "refiner_model": [], + "refiner_negative_aesthetic_score": [ + 2.5 + ], + "refiner_positive_aesthetic_score": [ + 6 + ], + "refiner_scheduler": [ + "euler" + ], + "refiner_start": [ + 0.8 + ], + "refiner_steps": [ + 20 + ], "scheduler": [ "euler_a", "euler" @@ -731,6 +753,7 @@ }, "denoise_latents": { "inputs": [ + "latents", "negative_conditioning", "noise", "positive_conditioning", @@ -741,13 +764,16 @@ 0 ], "cfg_scale": [ - 7 + 7, + 7.5 ], "denoising_end": [ + 0.8, 1 ], "denoising_start": [ - 0 + 0, + 0.8 ], "hidiffusion": [ false @@ -762,9 +788,11 @@ true ], "scheduler": [ - "euler_a" + "euler_a", + "euler" ], "steps": [ + 20, 30 ], "use_cache": [ @@ -1741,6 +1769,43 @@ "vae" ] }, + "sdxl_refiner_compel_prompt": { + "inputs": [ + "clip2", + "style" + ], + "literalInputs": { + "aesthetic_score": [ + 2.5, + 6 + ], + "is_intermediate": [ + true + ], + "use_cache": [ + true + ] + }, + "outputs": [ + "conditioning" + ] + }, + "sdxl_refiner_model_loader": { + "inputs": [], + "literalInputs": { + "is_intermediate": [ + true + ], + "model": [], + "use_cache": [ + true + ] + }, + "outputs": [ + "clip2", + "unet" + ] + }, "string": { "inputs": [], "literalInputs": { 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 6d7e77b5766..d4b75ad47ac 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.test.ts @@ -1710,3 +1710,99 @@ describe('getGenerationDimensions and the variant it dispatches on', () => { expect(getGenerationDimensions({ base: 'flux', type: 'main' }).optimal).toBe(1024); }); }); + +describe('SDXL Refiner policy', () => { + const refiner: MainModelConfig = { base: 'sdxl-refiner', key: 'refiner', name: 'SDXL Refiner', type: 'main' }; + + it('offers the refiner section for SDXL only and never lists refiners as main models', () => { + expect(getGenerationUiPolicy(createModel('sdxl')).refinerVisible).toBe(true); + expect(getGenerationUiPolicy(createModel('sd-1')).refinerVisible).toBe(false); + expect(getGenerationUiPolicy(createModel('flux')).refinerVisible).toBe(false); + expect(isGenerateModelSelectable(refiner)).toBe(false); + expect(isSupportedGenerateModel(refiner)).toBe(false); + }); + + it('clears the refiner when leaving SDXL but keeps its tuning for the next SDXL pick', () => { + const result = getGenerateModelSelectionResult({ + currentValues: createSettings(createModel('sdxl'), { refinerModel: refiner, refinerStart: 0.6 }), + model: createModel('flux'), + models: [], + }); + + expect(result.settings.refinerModel).toBeNull(); + expect(result.settings.refinerStart).toBe(0.6); + expect(result.clearedLabels).toContain('Refiner'); + + const kept = getGenerateModelSelectionResult({ + currentValues: createSettings(createModel('sdxl'), { refinerModel: refiner }), + model: createModel('sdxl', { key: 'sdxl-other' }), + models: [], + }); + + expect(kept.settings.refinerModel).toEqual(refiner); + expect(kept.clearedLabels).toEqual([]); + }); + + it('validates the refiner only on SDXL, rejecting PiD and out-of-range tuning', () => { + const sdxl = createModel('sdxl'); + const withRefiner = (overrides: Partial = {}) => + getGenerationValidationReasons(sdxl, createSettings(sdxl, { refinerModel: refiner, ...overrides })); + + expect(withRefiner()).toEqual([]); + expect(withRefiner({ refinerStart: 1.2, refinerSteps: 0 })).toEqual([ + 'Refiner steps must be a whole number of at least 1.', + 'Refiner start must be between 0 and 1.', + ]); + expect(withRefiner({ refinerNegativeAestheticScore: 0 })).toEqual([ + 'Refiner negative aesthetic score must be between 1 and 10.', + ]); + expect( + withRefiner({ + gemma2EncoderModel: { base: 'any', key: 'gemma2', name: 'Gemma 2', type: 'gemma2_encoder' }, + pidDecoderModel: { base: 'sdxl', key: 'pid', name: 'PiD SDXL', type: 'pid_decoder' }, + pidMode: 'fit', + }) + ).toContain('PiD decode is not compatible with the SDXL Refiner. Turn PiD off or clear the refiner model.'); + expect(withRefiner({ refinerModel: sdxl })).toEqual([ + `${sdxl.name} is not an SDXL Refiner model. Pick a refiner or clear the slot.`, + ]); + + const sd1 = createModel('sd-1'); + + expect( + getGenerationValidationReasons(sd1, createSettings(sd1, { refinerModel: refiner, refinerStart: 5 })) + ).toEqual([]); + }); + + it('ignores unavailable models and invalid tuning while the refiner is off, then validates reactivation', () => { + const sdxl = createModel('sdxl'); + const settings = createSettings(sdxl, { + refinerModel: refiner, + refinerStart: 1, + refinerSteps: 0, + refinerCfgScale: Number.NaN, + refinerPositiveAestheticScore: 0, + refinerNegativeAestheticScore: 20, + }); + + expect(getGenerationValidationReasons(sdxl, settings)).toEqual([]); + expect(getGenerationModelAvailabilityReasons(sdxl, settings, [sdxl as never])).toEqual([]); + const reactivated = { ...settings, refinerStart: 0.8 }; + expect(getGenerationValidationReasons(sdxl, reactivated)).toContain( + 'Refiner steps must be a whole number of at least 1.' + ); + expect(getGenerationModelAvailabilityReasons(sdxl, reactivated, [sdxl as never])).toEqual([ + 'Refiner "SDXL Refiner" is no longer installed.', + ]); + }); + + it('reports an uninstalled refiner', () => { + const sdxl = createModel('sdxl'); + const settings = createSettings(sdxl, { refinerModel: refiner }); + + expect(getGenerationModelAvailabilityReasons(sdxl, settings, [sdxl as never])).toEqual([ + 'Refiner "SDXL Refiner" is no longer installed.', + ]); + expect(getGenerationModelAvailabilityReasons(sdxl, settings, [sdxl as never, refiner as never])).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts index f57ebb0435b..c70f7ca260a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/baseGenerationPolicies.ts @@ -56,6 +56,17 @@ import { getPidDecoderBaseForMainBase, getPidDimensionOverrides, } from './pid'; +import { + DEFAULT_REFINER_CFG_SCALE, + DEFAULT_REFINER_NEGATIVE_AESTHETIC_SCORE, + DEFAULT_REFINER_POSITIVE_AESTHETIC_SCORE, + DEFAULT_REFINER_SCHEDULER, + DEFAULT_REFINER_START, + DEFAULT_REFINER_STEPS, + getRefinerValidationReasons, + getActiveRefiner, + isRefinerSupported, +} from './sdxlRefiner'; import { clampDimension, DEFAULT_ANIMA_INPAINT_ADAPTER_WEIGHT, @@ -363,6 +374,7 @@ export const getGenerationUiPolicy = (model: GenerateModelConfig | undefined) => seedVisible, // Show PiD controls on supported bases even while PiD is disabled. pidVisible: getIsPidSupportedBase(model?.base), + refinerVisible: isRefinerSupported(model), // Both Qwen-Image text encoders take the quantization choice; no other family's encoder does. encoderQuantizationVisible: model?.type === 'main' && model.base === 'qwen-image', }; @@ -498,6 +510,13 @@ export const getDefaultGenerateSettings = (model?: GenerateModelConfig): Generat gemma2EncoderModel: null, pidSteps: DEFAULT_PID_STEPS, referenceImages: [], + refinerModel: null, + refinerSteps: DEFAULT_REFINER_STEPS, + refinerCfgScale: DEFAULT_REFINER_CFG_SCALE, + refinerScheduler: DEFAULT_REFINER_SCHEDULER, + refinerPositiveAestheticScore: DEFAULT_REFINER_POSITIVE_AESTHETIC_SCORE, + refinerNegativeAestheticScore: DEFAULT_REFINER_NEGATIVE_AESTHETIC_SCORE, + refinerStart: DEFAULT_REFINER_START, scheduler: defaults.scheduler, seamlessXAxis: false, seamlessYAxis: false, @@ -1521,6 +1540,12 @@ const getSettingsWithCompatibleModelSelections = ( addClearedLabel(clearedLabels, 'HiDiffusion'); } + // The refiner only ever follows an SDXL base; its tuning values stay for the next SDXL pick. + if (!uiPolicy.refinerVisible && nextSettings.refinerModel) { + nextSettings.refinerModel = null; + addClearedLabel(clearedLabels, 'Refiner'); + } + if (!uiPolicy.seamlessVisible && (nextSettings.seamlessXAxis || nextSettings.seamlessYAxis)) { nextSettings.seamlessXAxis = false; nextSettings.seamlessYAxis = false; @@ -1587,6 +1612,11 @@ export const getGenerationModelAvailabilityReasons = ( } } + const refiner = getActiveRefiner(settings, model); + if (refiner && !hasModelKey(models, refiner.key, 'main')) { + reasons.push(`Refiner "${refiner.name}" is no longer installed.`); + } + for (const lora of settings.loras) { if (!hasModelKey(models, lora.model.key, 'lora')) { reasons.push(`LoRA "${lora.model.name}" is no longer installed.`); @@ -1796,6 +1826,7 @@ export const getGenerationValidationReasons = (model: GenerateModelConfig, setti } reasons.push(...getModelFamilyValidationReasons(model, settings)); + reasons.push(...getRefinerValidationReasons(model, settings)); return reasons; }; 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 433f8e0fe94..661cc95bd71 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 @@ -1513,6 +1513,143 @@ describe('compileCanvasGraph — regional guidance', () => { }); }); +describe('compileCanvasGraph — SDXL Refiner', () => { + const refinerModel: MainModelConfig = { + base: 'sdxl-refiner', + key: 'sdxl-refiner-model', + name: 'SDXL Refiner', + type: 'main', + }; + const refinerSettings: Partial = { refinerModel, refinerStart: 0.8, refinerSteps: 10 }; + + it('hands txt2img latents to the refiner before the resize to the bbox', () => { + const { backendGraph } = compile(sdxlModel, 'txt2img', { settings: refinerSettings }); + + expect(backendGraph.nodes.denoise_latents).toMatchObject({ denoising_end: 0.8, denoising_start: 0 }); + expect(backendGraph.nodes.refiner_denoise_latents).toMatchObject({ denoising_end: 1, denoising_start: 0.8 }); + expect(getEdge(backendGraph, 'refiner_denoise_latents', 'latents')?.source.node_id).toBe('denoise_latents'); + expect(getEdge(backendGraph, 'canvas_output', 'latents')?.source.node_id).toBe('refiner_denoise_latents'); + }); + + it('stops the img2img base pass at the hand-over and keeps its start below it', () => { + const { backendGraph } = compile(sdxlModel, 'img2img', { settings: refinerSettings, strength: 0.6 }); + + // 1 - 0.6 = 0.4 is below the 0.8 hand-over, so the base pass runs 0.4 → 0.8. + expect(backendGraph.nodes.denoise_latents?.denoising_start).toBeCloseTo(0.4); + expect(backendGraph.nodes.denoise_latents?.denoising_end).toBe(0.8); + expect(backendGraph.nodes.refiner_denoise_latents).toMatchObject({ denoising_end: 1, denoising_start: 0.8 }); + expect(getEdge(backendGraph, 'denoise_latents', 'latents')?.source.node_id).toBe('canvas_i2l'); + expect(getEdge(backendGraph, 'refiner_denoise_latents', 'latents')?.source.node_id).toBe('denoise_latents'); + expect(getNodeByType(backendGraph, 'core_metadata')).toMatchObject({ + generation_mode: 'sdxl_img2img', + refiner_model: { key: 'sdxl-refiner-model' }, + refiner_start: 0.8, + strength: 0.6, + }); + }); + + it('caps a low-strength img2img start at the hand-over so the base pass is not inverted', () => { + const { backendGraph } = compile(sdxlModel, 'img2img', { settings: refinerSettings, strength: 0.1 }); + + expect(backendGraph.nodes.denoise_latents).toMatchObject({ denoising_end: 0.8, denoising_start: 0.8 }); + }); + + it.each(['inpaint', 'outpaint'] as const)( + 'masks the base pass and constrains the decoded refiner with pasteback for %s', + (mode) => { + const { backendGraph } = compile(sdxlModel, mode, { settings: refinerSettings, strength: 0.5 }); + + expect(backendGraph.nodes.denoise_latents).toMatchObject({ denoising_end: 0.8, denoising_start: 0.5 }); + expect(getEdge(backendGraph, 'denoise_latents', 'denoise_mask')?.source.node_id).toBe('create_gradient_mask'); + expect(getEdge(backendGraph, 'refiner_denoise_latents', 'denoise_mask')).toBeUndefined(); + expect(backendGraph.nodes.canvas_output).toMatchObject({ type: 'invokeai_img_blend' }); + expect(getEdge(backendGraph, 'canvas_output', 'layer_upper')?.source.node_id).toBe('canvas_l2i'); + expect(getEdge(backendGraph, 'canvas_output', 'mask')?.source.node_id).toBe('expand_mask'); + expect(getEdge(backendGraph, 'expand_mask', 'mask')?.source).toEqual({ + field: 'expanded_mask_area', + node_id: 'create_gradient_mask', + }); + expect(getEdge(backendGraph, 'refiner_denoise_latents', 'latents')?.source.node_id).toBe('denoise_latents'); + expect(getEdge(backendGraph, 'canvas_l2i', 'latents')?.source.node_id).toBe('refiner_denoise_latents'); + expect(getNodeByType(backendGraph, 'core_metadata')?.generation_mode).toBe(`sdxl_${mode}`); + } + ); + + it.each(['txt2img', 'img2img', 'inpaint', 'outpaint'] as const)( + 'never lets the %s graft restore the full base schedule while a refiner is active', + (mode) => { + const { backendGraph } = compile(sdxlModel, mode, { settings: refinerSettings, strength: 0.9 }); + + // The canvas re-derives the base range from the strength after the refiner stage is grafted. + expect(backendGraph.nodes.denoise_latents?.denoising_end).toBe(0.8); + expect(backendGraph.nodes.refiner_denoise_latents?.denoising_start).toBe(0.8); + } + ); + + it('keeps regional guidance on the base pass', () => { + const { backendGraph } = compile(sdxlModel, 'txt2img', { + regionalGuidance: [ + { + autoNegative: false, + id: 'r1', + maskImageName: 'r1-mask.png', + negativePrompt: null, + positivePrompt: 'a cat', + referenceImages: [], + }, + ], + settings: refinerSettings, + }); + + const collected = backendGraph.edges + .filter((edge) => edge.destination.node_id === 'pos_cond_collect') + .map((edge) => edge.source.node_id); + expect(collected).toEqual(['pos_cond', 'rg_pos_cond_r1']); + expect(getEdge(backendGraph, 'denoise_latents', 'positive_conditioning')?.source.node_id).toBe('pos_cond_collect'); + expect(getEdge(backendGraph, 'refiner_denoise_latents', 'positive_conditioning')?.source.node_id).toBe( + 'refiner_pos_cond' + ); + expect( + backendGraph.edges + .filter((edge) => edge.destination.node_id === 'refiner_denoise_latents') + .map((edge) => edge.destination.field) + ).toEqual(['unet', 'positive_conditioning', 'negative_conditioning', 'latents']); + }); + + it('attaches control layers to the base pass, not the refiner', () => { + const { backendGraph } = compile(sdxlModel, 'txt2img', { + controlLayers: [ + { + beginEndStepPct: [0, 1], + controlMode: 'balanced', + id: 'control-1', + imageName: 'control.png', + kind: 'controlnet', + model: { base: 'sdxl', key: 'sdxl-controlnet', name: 'SDXL ControlNet', type: 'controlnet' }, + weight: 1, + }, + ], + settings: refinerSettings, + }); + + expect(getEdge(backendGraph, 'denoise_latents', 'control')).toBeDefined(); + expect(getEdge(backendGraph, 'refiner_denoise_latents', 'control')).toBeUndefined(); + }); + + it('refuses PiD on the canvas when a refiner is selected', () => { + expect(() => + compile(sdxlModel, 'img2img', { + settings: { + ...refinerSettings, + 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 decode is not compatible with the SDXL Refiner. Turn PiD off or clear the refiner model.'); + }); +}); + describe('compileCanvasGraph — output filters', () => { const metadataTarget = (graph: BackendGraphContract) => graph.edges.find((edge) => edge.destination.field === 'metadata')?.destination.node_id; 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 4260197de7f..029ccde6d49 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts @@ -19,6 +19,7 @@ import { import { addKrea2ConditioningEnhancers } from '@features/generation/core/krea2Conditioning'; import { getIsPidActive } from '@features/generation/core/pid'; import { shouldUsePidDecode } from '@features/generation/core/pidGraph'; +import { getBaseDenoisingRange } from '@features/generation/core/sdxlRefiner'; import { MAX_ANIMA_INPAINT_ADAPTER_WEIGHT, MIN_ANIMA_INPAINT_ADAPTER_WEIGHT } from '@features/generation/core/settings'; import type { @@ -61,6 +62,16 @@ const canvasDenoisingStart = (model: GenerateModelConfig, strength: number): num return 1 - strength ** (usesOptimizedCurve ? 0.2 : 1); }; +/** Re-derives the base denoise range from the strength; the refiner hand-over rule stays with its owner. */ +const setCanvasDenoisingRange = ( + denoise: BackendInvocationContract, + model: GenerateModelConfig, + settings: GenerateSettings, + strength: number +): void => { + Object.assign(denoise, getBaseDenoisingRange(settings, model, canvasDenoisingStart(model, strength))); +}; + /** True for the image-referencing modes (everything but pure txt2img). */ const isImageMode = (mode: CompileCanvasGraphInput['mode']): boolean => mode !== 'txt2img'; @@ -323,8 +334,7 @@ const graftImageToImage = ( addEdge(graph, vaeSource.node, vaeSource.field, encode, 'vae'); addEdge(graph, encode, 'latents', denoise, 'latents'); - denoise.denoising_start = canvasDenoisingStart(model, strength); - denoise.denoising_end = 1; + setCanvasDenoisingRange(denoise, model, settings, strength); setMetadataMode(graph, 'img2img', strength); }; @@ -571,8 +581,7 @@ const graftInpaint = ( const strength = input.strength; const needsResize = !sizesMatch(input.bbox, processingSize); - denoise.denoising_start = canvasDenoisingStart(model, strength); - denoise.denoising_end = 1; + setCanvasDenoisingRange(denoise, model, input.settings, strength); const initialResize = needsResize ? addInputResizeNode(graph, 'canvas_resize_initial_to_processing', initialImageName, processingSize) @@ -637,8 +646,7 @@ const graftOutpaint = ( const strength = input.strength; const needsResize = !sizesMatch(input.bbox, processingSize); - denoise.denoising_start = canvasDenoisingStart(model, strength); - denoise.denoising_end = 1; + setCanvasDenoisingRange(denoise, model, input.settings, strength); // Infill the transparent region before encode (legacy `getInfill`). const infill = addInfillNode(graph, compositing.infillMethod, compositing); diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts index 4a97ab2594f..a70cebf8087 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.test.ts @@ -1358,6 +1358,196 @@ describe('PiD decode', () => { }); }); +describe('SDXL Refiner', () => { + const refinerModel: MainModelConfig = { + base: 'sdxl-refiner', + hash: 'blake3:refiner', + key: 'sdxl-refiner-model', + name: 'SDXL Refiner', + type: 'main', + }; + const refinerSettings: Partial = { + refinerCfgScale: 5, + refinerModel, + refinerNegativeAestheticScore: 2, + refinerPositiveAestheticScore: 7, + refinerScheduler: 'dpmpp_2m', + refinerStart: 0.7, + refinerSteps: 12, + }; + + it('leaves the SDXL graph unchanged without a refiner', () => { + const graph = compile(sdxlModel); + + expect(graph.nodes.refiner_denoise_latents).toBeUndefined(); + expect(graph.nodes.refiner_model_loader).toBeUndefined(); + expect(graph.nodes.denoise_latents).toMatchObject({ denoising_end: 1, denoising_start: 0 }); + expect(getEdge(graph, 'canvas_output', 'latents')?.source.node_id).toBe('denoise_latents'); + expect(getNodeByType(graph, 'core_metadata')).not.toHaveProperty('refiner_model'); + }); + + it('hands the base latents to a refiner stage that feeds the decode', () => { + const graph = compile(sdxlModel, refinerSettings); + const refinerDenoise = graph.nodes.refiner_denoise_latents; + + expect(graph.nodes.denoise_latents).toMatchObject({ denoising_end: 0.7, denoising_start: 0 }); + expect(graph.nodes.refiner_model_loader).toMatchObject({ + model: { base: 'sdxl-refiner', hash: 'blake3:refiner', key: 'sdxl-refiner-model', type: 'main' }, + type: 'sdxl_refiner_model_loader', + }); + expect(refinerDenoise).toMatchObject({ + cfg_scale: 5, + denoising_end: 1, + denoising_start: 0.7, + scheduler: 'dpmpp_2m', + steps: 12, + type: 'denoise_latents', + }); + expect(graph.nodes.refiner_pos_cond).toMatchObject({ aesthetic_score: 7, type: 'sdxl_refiner_compel_prompt' }); + expect(graph.nodes.refiner_neg_cond).toMatchObject({ aesthetic_score: 2, type: 'sdxl_refiner_compel_prompt' }); + + expect(getEdge(graph, 'refiner_denoise_latents', 'unet')?.source).toEqual({ + field: 'unet', + node_id: 'refiner_model_loader', + }); + expect(getEdge(graph, 'refiner_pos_cond', 'clip2')?.source.node_id).toBe('refiner_model_loader'); + expect(getEdge(graph, 'refiner_neg_cond', 'clip2')?.source.node_id).toBe('refiner_model_loader'); + expect(getEdge(graph, 'refiner_pos_cond', 'style')?.source.node_id).toBe('positive_prompt'); + expect(getEdge(graph, 'refiner_neg_cond', 'style')?.source.node_id).toBe('negative_prompt'); + expect(getEdge(graph, 'refiner_denoise_latents', 'positive_conditioning')?.source.node_id).toBe('refiner_pos_cond'); + expect(getEdge(graph, 'refiner_denoise_latents', 'negative_conditioning')?.source.node_id).toBe('refiner_neg_cond'); + expect(getEdge(graph, 'refiner_denoise_latents', 'latents')?.source.node_id).toBe('denoise_latents'); + expect(getEdge(graph, 'canvas_output', 'latents')?.source.node_id).toBe('refiner_denoise_latents'); + // The base VAE still decodes; the refiner has none of its own. + expect(getEdge(graph, 'canvas_output', 'vae')?.source.node_id).toBe('model_loader'); + }); + + it('records the refiner and its tuning in metadata', () => { + const graph = compile(sdxlModel, refinerSettings); + + expect(getNodeByType(graph, 'core_metadata')).toMatchObject({ + refiner_cfg_scale: 5, + refiner_model: { base: 'sdxl-refiner', hash: 'blake3:refiner', key: 'sdxl-refiner-model', type: 'main' }, + refiner_negative_aesthetic_score: 2, + refiner_positive_aesthetic_score: 7, + refiner_scheduler: 'dpmpp_2m', + refiner_start: 0.7, + refiner_steps: 12, + }); + }); + + it('wraps the refiner UNet in its own seamless node when tiling is on', () => { + const graph = compile(sdxlModel, { ...refinerSettings, seamlessXAxis: true }); + + expect(graph.nodes.refiner_seamless).toMatchObject({ seamless_x: true, seamless_y: false, type: 'seamless' }); + expect(getEdge(graph, 'refiner_seamless', 'unet')?.source.node_id).toBe('refiner_model_loader'); + expect(getEdge(graph, 'refiner_denoise_latents', 'unet')?.source.node_id).toBe('refiner_seamless'); + }); + + it('runs the refiner pass with the base HiDiffusion settings and coerces its scheduler', () => { + const graph = compile(sdxlModel, { + ...refinerSettings, + hiDiffusionEnabled: true, + hiDiffusionT1Ratio: 0.3, + refinerScheduler: 'not-a-scheduler', + }); + + expect(graph.nodes.refiner_denoise_latents).toMatchObject({ + hidiffusion: true, + hidiffusion_t1_ratio: 0.3, + scheduler: 'euler_a', + }); + expect(getNodeByType(graph, 'core_metadata')?.refiner_scheduler).toBe('euler_a'); + }); + + it('ignores a stored refiner on a non-SDXL main model', () => { + const graph = compile(sd1Model, refinerSettings); + + expect(graph.nodes.refiner_denoise_latents).toBeUndefined(); + expect(getNodeByType(graph, 'core_metadata')).not.toHaveProperty('refiner_model'); + }); + + it('rejects a refiner together with PiD decoding', () => { + const gemma2: ComponentModelConfig = { base: 'any', key: 'gemma2', name: 'Gemma 2 2B', type: 'gemma2_encoder' }; + + expect(() => + compile(sdxlModel, { + ...refinerSettings, + gemma2EncoderModel: gemma2, + pidDecoderModel: { base: 'sdxl', key: 'pid-sdxl', name: 'PiD SDXL', type: 'pid_decoder' }, + pidMode: 'fit', + }) + ).toThrow('PiD decode is not compatible with the SDXL Refiner.'); + }); + + it('refuses a non-refiner model stored in the refiner slot', () => { + expect(() => compile(sdxlModel, { ...refinerSettings, refinerModel: sdxlModel })).toThrow( + 'SDXL is not an SDXL Refiner model.' + ); + }); + + it('hands the whole schedule to the refiner at a start of 0', () => { + const graph = compile(sdxlModel, { ...refinerSettings, refinerStart: 0 }); + + // The base pass only seeds the latents from noise; the refiner denoises every step. + expect(graph.nodes.denoise_latents).toMatchObject({ denoising_end: 0, denoising_start: 0 }); + expect(graph.nodes.refiner_denoise_latents).toMatchObject({ denoising_end: 1, denoising_start: 0 }); + expect(getEdge(graph, 'canvas_output', 'latents')?.source.node_id).toBe('refiner_denoise_latents'); + expect(getNodeByType(graph, 'core_metadata')?.refiner_start).toBe(0); + }); + + it('runs without the refiner at a start of 1, where its pass would be empty', () => { + const graph = compile(sdxlModel, { + ...refinerSettings, + gemma2EncoderModel: { base: 'any', key: 'gemma2', name: 'Gemma 2 2B', type: 'gemma2_encoder' }, + pidDecoderModel: { base: 'sdxl', key: 'pid-sdxl', name: 'PiD SDXL', type: 'pid_decoder' }, + pidMode: 'fit', + refinerStart: 1, + }); + + // Neither the refiner model nor its PiD conflict applies: the graph is the plain SDXL graph. + expect(graph.nodes.refiner_model_loader).toBeUndefined(); + expect(graph.nodes.refiner_denoise_latents).toBeUndefined(); + expect(graph.nodes.denoise_latents).toMatchObject({ denoising_end: 1, denoising_start: 0 }); + expect(getNodeByType(graph, 'core_metadata')).not.toHaveProperty('refiner_model'); + }); + + it('keeps IP Adapter reference images on the base pass', () => { + const sdxlIpAdapter: ComponentModelConfig = { + base: 'sdxl', + key: 'sdxl-ip-adapter', + name: 'SDXL IP Adapter', + type: 'ip_adapter', + }; + const graph = compile(sdxlModel, { + ...refinerSettings, + referenceImages: [ + { + id: 'ref-1', + isEnabled: true, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: refImage, + method: 'full', + model: sdxlIpAdapter, + type: 'ip_adapter', + weight: 1, + }, + }, + ], + }); + + expect(getEdge(graph, 'denoise_latents', 'ip_adapter')?.source.node_id).toContain('ip_adapter_collector'); + expect(getEdge(graph, 'refiner_denoise_latents', 'ip_adapter')).toBeUndefined(); + expect( + graph.edges + .filter((edge) => edge.destination.node_id === 'refiner_denoise_latents') + .map((e) => e.destination.field) + ).toEqual(['unet', 'positive_conditioning', 'negative_conditioning', 'latents']); + }); +}); + describe('model-family tuning', () => { const fluxComponents: Partial = { clipEmbedModel: clipEmbed, diff --git a/invokeai/frontend/webv2/src/features/generation/core/graph.ts b/invokeai/frontend/webv2/src/features/generation/core/graph.ts index 13009af1045..8b6ec987446 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graph.ts @@ -55,6 +55,8 @@ import { import { addKrea2ConditioningEnhancers } from './krea2Conditioning'; import { addPidDecode, getPidDenoiseSize, getPidMetadata, isPidDecodeNodeType, shouldUsePidDecode } from './pidGraph'; import { getEffectiveReferenceImage } from './referenceImage'; +import { getActiveRefiner, getBaseDenoisingRange } from './sdxlRefiner'; +import { addSDXLRefinerStage, getRefinerMetadata } from './sdxlRefinerGraph'; const getCompatibleComponentSource = ( settings: GenerateSettings, @@ -341,8 +343,7 @@ const buildSDGraph = ( const denoise = addNode(graph, { cfg_rescale_multiplier: settings.cfgRescaleMultiplier, cfg_scale: settings.cfgScale, - denoising_end: 1, - denoising_start: 0, + ...getBaseDenoisingRange(settings, model, 0), id: 'denoise_latents', ...(supportsHiDiffusion ? { @@ -431,9 +432,14 @@ const buildSDGraph = ( addEdge(graph, unetSource, 'unet', denoise, 'unet'); addEdge(graph, noise, 'noise', denoise, 'noise'); addIPAdapterReferenceImages(graph, settings, model, denoise); + // Validation rejects PiD alongside a refiner, so the refiner always feeds the ordinary VAE decode. + const refiner = getActiveRefiner(settings, model); + const refinerDenoise = refiner + ? addSDXLRefinerStage({ denoise, graph, model, negativePrompt, positivePrompt, refiner, seamless, settings }) + : null; // Create decode only after resolving the final VAE source. const output = addDecodeOutput({ - denoise, + denoise: refinerDenoise ?? denoise, graph, l2iProps: { color_compensation: colorCompensation, fp32: settings.vaePrecision === 'fp32' }, l2iType: 'l2i', @@ -456,6 +462,7 @@ const buildSDGraph = ( } : {}), ...(shouldUsePidDecode(settings, model.base) ? getPidMetadata(settings) : {}), + ...(refiner ? getRefinerMetadata(settings, model, refiner) : {}), scheduler, }); addReferenceImageMetadata(graph, output, settings); diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts index 468e6b7478e..ee15380530b 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphCoverage.test.ts @@ -6,7 +6,7 @@ import { seedArchitectureCapabilities } from '@features/generation/core/architec import { describe, expect, it } from 'vitest'; import type { GenerateComponentValueKey, SupportedGenerateBase } from './baseGenerationPolicies'; -import type { ModelIdentifierConfig, VaeModelConfig } from './types'; +import type { MainModelConfig, ModelIdentifierConfig, VaeModelConfig } from './types'; import { getComponentSectionPolicy, @@ -43,6 +43,14 @@ const compileForShape = ( return { filled, graph: compileGenerateGraph(settings, model, 'gallery', projectSettings).backendGraph }; }; +const SDXL_REFINER_SHAPE: ModelShape = { label: 'diffusers-with-refiner', overrides: { format: 'diffusers' } }; +const SDXL_REFINER_MODEL: MainModelConfig = { + base: 'sdxl-refiner', + key: 'sdxl-refiner', + name: 'SDXL Refiner', + type: 'main', +}; + seedArchitectureCapabilities(); describe('generate graph coverage', () => { @@ -171,10 +179,31 @@ describe('generate graph coverage', () => { const fieldsFor = (nodeType: string) => (fields[nodeType] ??= { inputs: new Set(), literals: new Map(), outputs: new Set() }); + // The refiner is a per-image choice, not a component slot, so it needs its own SDXL case. + const refinerCase = () => { + const { filled, model, settings } = satisfiedSettingsFor('sdxl', SDXL_REFINER_SHAPE); + const withRefiner = { ...settings, refinerModel: SDXL_REFINER_MODEL }; + + expect(getGenerationValidationReasons(model, withRefiner)).toEqual([]); + + return { + base: 'sdxl' as const, + filled, + graph: compileGenerateGraph(withRefiner, model, 'gallery', { + useCpuNoise: true, + useNsfwChecker: false, + useWatermark: false, + }).backendGraph, + shape: SDXL_REFINER_SHAPE, + }; + }; // Each shape compiles twice: at its defaults, and with every optional tuning and output filter switched on. - const compilations = generateGraphCases.flatMap(({ base, shape }) => - [false, true].map((tuned) => ({ base, shape, ...compileForShape(base, shape, tuned) })) - ); + const compilations = [ + ...generateGraphCases.flatMap(({ base, shape }) => + [false, true].map((tuned) => ({ base, shape, ...compileForShape(base, shape, tuned) })) + ), + refinerCase(), + ]; for (const { base, filled, graph, shape } of compilations) { const entry = (byBase[base] ??= { componentsFilled: {}, nodeTypes: [] }); diff --git a/invokeai/frontend/webv2/src/features/generation/core/sdxlRefiner.test.ts b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefiner.test.ts new file mode 100644 index 00000000000..02b449f6f89 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefiner.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import type { MainModelConfig } from './types'; + +import { getActiveRefiner, getBaseDenoisingRange, getRefinerDenoisingSplit, isRefinerModel } from './sdxlRefiner'; + +const sdxl: MainModelConfig = { base: 'sdxl', key: 'sdxl', name: 'SDXL', type: 'main' }; +const refiner: MainModelConfig = { base: 'sdxl-refiner', key: 'refiner', name: 'Refiner', type: 'main' }; +const withRefiner = { refinerModel: refiner, refinerStart: 0.8 }; + +describe('sdxlRefiner', () => { + it('activates a refiner only for a refiner-family model on an SDXL main', () => { + expect(getActiveRefiner(withRefiner, sdxl)).toBe(refiner); + expect(getActiveRefiner(withRefiner, { ...sdxl, base: 'sd-1' })).toBeNull(); + expect(getActiveRefiner({ ...withRefiner, refinerModel: sdxl }, sdxl)).toBeNull(); + expect(getActiveRefiner({ ...withRefiner, refinerModel: null }, sdxl)).toBeNull(); + expect(isRefinerModel({ base: 'sdxl-refiner', type: 'lora' })).toBe(false); + }); + + it('treats a start of 1 as off, since the refiner would have no schedule left', () => { + expect(getActiveRefiner({ ...withRefiner, refinerStart: 1 }, sdxl)).toBeNull(); + expect(getActiveRefiner({ ...withRefiner, refinerStart: 0 }, sdxl)).toBe(refiner); + }); + + it('ends the base pass at the hand-over and never starts it later than that', () => { + expect(getRefinerDenoisingSplit(0.8)).toEqual({ denoising_end: 0.8, denoising_start: 0 }); + expect(getRefinerDenoisingSplit(0.8, 0.3)).toEqual({ denoising_end: 0.8, denoising_start: 0.3 }); + expect(getRefinerDenoisingSplit(0.8, 0.95)).toEqual({ denoising_end: 0.8, denoising_start: 0.8 }); + }); + + it('owns the base range for every caller: a strength-derived start never restores the full schedule', () => { + expect(getBaseDenoisingRange(withRefiner, sdxl, 0)).toEqual({ denoising_end: 0.8, denoising_start: 0 }); + expect(getBaseDenoisingRange(withRefiner, sdxl, 0.4)).toEqual({ denoising_end: 0.8, denoising_start: 0.4 }); + expect(getBaseDenoisingRange(withRefiner, sdxl, 0.95)).toEqual({ denoising_end: 0.8, denoising_start: 0.8 }); + expect(getBaseDenoisingRange({ ...withRefiner, refinerModel: null }, sdxl, 0.4)).toEqual({ + denoising_end: 1, + denoising_start: 0.4, + }); + expect(getBaseDenoisingRange({ ...withRefiner, refinerStart: 1 }, sdxl, 0.4)).toEqual({ + denoising_end: 1, + denoising_start: 0.4, + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/sdxlRefiner.ts b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefiner.ts new file mode 100644 index 00000000000..ca2a625bfdd --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefiner.ts @@ -0,0 +1,122 @@ +/** SDXL Refiner policy: which models may fill the slot, its value ranges, and the denoise hand-over. */ + +import type { GenerateModelConfig, GenerateSettings, MainModelConfig } from './types'; + +export const DEFAULT_REFINER_STEPS = 20; +export const DEFAULT_REFINER_CFG_SCALE = 7.5; +export const DEFAULT_REFINER_SCHEDULER = 'euler'; +export const DEFAULT_REFINER_POSITIVE_AESTHETIC_SCORE = 6; +export const DEFAULT_REFINER_NEGATIVE_AESTHETIC_SCORE = 2.5; +export const DEFAULT_REFINER_START = 0.8; + +export const MIN_REFINER_STEPS = 1; +export const MIN_REFINER_CFG_SCALE = 1; +export const MIN_REFINER_AESTHETIC_SCORE = 1; +export const MAX_REFINER_AESTHETIC_SCORE = 10; +export const MIN_REFINER_START = 0; +export const MAX_REFINER_START = 1; + +export type RefinerSettings = Pick< + GenerateSettings, + | 'refinerModel' + | 'refinerSteps' + | 'refinerCfgScale' + | 'refinerScheduler' + | 'refinerPositiveAestheticScore' + | 'refinerNegativeAestheticScore' + | 'refinerStart' +>; + +/** The refiner is its own model family; only it may fill the slot, and never the main-model picker. */ +export const isRefinerModel = (model: { base: string; type: string } | null | undefined): boolean => + model?.type === 'main' && model.base === 'sdxl-refiner'; + +/** Only the SDXL base hands its latents to a refiner. */ +export const isRefinerSupported = (model: GenerateModelConfig | undefined): boolean => + model?.type === 'main' && model.base === 'sdxl'; + +/** + * The refiner that will run: a refiner model selected on an SDXL main model. A start of 1 leaves the refiner no + * schedule, so it is treated as off rather than loading a model for an empty pass. + */ +export const getActiveRefiner = ( + settings: Pick, + model: GenerateModelConfig | undefined +): MainModelConfig | null => + isRefinerSupported(model) && isRefinerModel(settings.refinerModel) && settings.refinerStart < MAX_REFINER_START + ? settings.refinerModel + : null; + +/** + * The base model's denoise range when a refiner finishes the schedule. An image-to-image start past the hand-over + * would leave the base model nothing to do, so the start is capped at the hand-over point (legacy parity). + */ +export const getRefinerDenoisingSplit = ( + refinerStart: number, + denoisingStart = 0 +): { denoising_start: number; denoising_end: number } => ({ + denoising_start: Math.min(refinerStart, denoisingStart), + denoising_end: refinerStart, +}); + +/** + * The single owner of the base denoise range. `denoisingStart` is the strength-derived start (0 for text-to-image); + * with an active refiner the base pass ends at the hand-over, otherwise it runs to the end of the schedule. + */ +export const getBaseDenoisingRange = ( + settings: Pick, + model: GenerateModelConfig, + denoisingStart: number +): { denoising_start: number; denoising_end: number } => + getActiveRefiner(settings, model) + ? getRefinerDenoisingSplit(settings.refinerStart, denoisingStart) + : { denoising_end: 1, denoising_start: denoisingStart }; + +export const getRefinerValidationReasons = ( + model: GenerateModelConfig, + settings: RefinerSettings & Pick +): string[] => { + if (!settings.refinerModel || !isRefinerSupported(model) || settings.refinerStart === MAX_REFINER_START) { + return []; + } + + if (!isRefinerModel(settings.refinerModel)) { + return [`${settings.refinerModel.name} is not an SDXL Refiner model. Pick a refiner or clear the slot.`]; + } + + const reasons: string[] = []; + + // PiD replaces the VAE decode the refiner's second denoise stage feeds; the two cannot share a graph. + if (settings.pidMode !== 'off' && settings.refinerStart < MAX_REFINER_START) { + reasons.push('PiD decode is not compatible with the SDXL Refiner. Turn PiD off or clear the refiner model.'); + } + + if (!Number.isInteger(settings.refinerSteps) || settings.refinerSteps < MIN_REFINER_STEPS) { + reasons.push(`Refiner steps must be a whole number of at least ${MIN_REFINER_STEPS}.`); + } + + if (!Number.isFinite(settings.refinerCfgScale) || settings.refinerCfgScale < MIN_REFINER_CFG_SCALE) { + reasons.push(`Refiner CFG must be at least ${MIN_REFINER_CFG_SCALE}.`); + } + + if ( + !Number.isFinite(settings.refinerStart) || + settings.refinerStart < MIN_REFINER_START || + settings.refinerStart > MAX_REFINER_START + ) { + reasons.push(`Refiner start must be between ${MIN_REFINER_START} and ${MAX_REFINER_START}.`); + } + + for (const [label, value] of [ + ['positive', settings.refinerPositiveAestheticScore], + ['negative', settings.refinerNegativeAestheticScore], + ] as const) { + if (!Number.isFinite(value) || value < MIN_REFINER_AESTHETIC_SCORE || value > MAX_REFINER_AESTHETIC_SCORE) { + reasons.push( + `Refiner ${label} aesthetic score must be between ${MIN_REFINER_AESTHETIC_SCORE} and ${MAX_REFINER_AESTHETIC_SCORE}.` + ); + } + } + + return reasons; +}; diff --git a/invokeai/frontend/webv2/src/features/generation/core/sdxlRefinerGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefinerGraph.ts new file mode 100644 index 00000000000..18a25c3feb1 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/core/sdxlRefinerGraph.ts @@ -0,0 +1,103 @@ +/** The SDXL Refiner's second denoise stage, grafted between the base denoise and the VAE decode. */ + +import type { BackendGraphContract, BackendInvocationContract } from '@features/generation/core/contracts'; + +import type { GenerateSettings, MainModelConfig } from './types'; + +import { coerceSchedulerForGraph } from './baseGenerationPolicies'; +import { addEdge, addNode, toModelIdentifier } from './graphBuilder'; + +export const REFINER_DENOISE_NODE_ID = 'refiner_denoise_latents'; + +interface RefinerStageInput { + graph: BackendGraphContract; + settings: GenerateSettings; + /** The SDXL main model; the refiner shares its scheduler set. */ + model: MainModelConfig; + refiner: MainModelConfig; + /** The base denoise, whose latents the refiner finishes; `getBaseDenoisingRange` already ends it at the hand-over. */ + denoise: BackendInvocationContract; + positivePrompt: BackendInvocationContract; + negativePrompt: BackendInvocationContract; + /** Set when the base graph tiles seamlessly; the refiner UNet gets the same wrapping. */ + seamless: BackendInvocationContract | null; +} + +/** Returns the refiner denoise, which replaces the base denoise as the decode's latents source. */ +export const addSDXLRefinerStage = ({ + denoise, + graph, + model, + negativePrompt, + positivePrompt, + refiner, + seamless, + settings, +}: RefinerStageInput): BackendInvocationContract => { + const scheduler = coerceSchedulerForGraph(model, settings.refinerScheduler); + const modelLoader = addNode(graph, { + id: 'refiner_model_loader', + model: toModelIdentifier(refiner), + type: 'sdxl_refiner_model_loader', + }); + const posCond = addNode(graph, { + aesthetic_score: settings.refinerPositiveAestheticScore, + id: 'refiner_pos_cond', + type: 'sdxl_refiner_compel_prompt', + }); + const negCond = addNode(graph, { + aesthetic_score: settings.refinerNegativeAestheticScore, + id: 'refiner_neg_cond', + type: 'sdxl_refiner_compel_prompt', + }); + const refinerDenoise = addNode(graph, { + cfg_scale: settings.refinerCfgScale, + denoising_end: 1, + denoising_start: settings.refinerStart, + id: REFINER_DENOISE_NODE_ID, + // HiDiffusion is a UNet-level patch, so the refiner pass runs with the same settings as the base pass. + hidiffusion: denoise.hidiffusion, + hidiffusion_raunet: denoise.hidiffusion_raunet, + hidiffusion_window_attn: denoise.hidiffusion_window_attn, + hidiffusion_t1_ratio: denoise.hidiffusion_t1_ratio, + hidiffusion_t2_ratio: denoise.hidiffusion_t2_ratio, + scheduler, + steps: settings.refinerSteps, + type: 'denoise_latents', + }); + + if (seamless) { + const refinerSeamless = addNode(graph, { + id: 'refiner_seamless', + seamless_x: seamless.seamless_x, + seamless_y: seamless.seamless_y, + type: 'seamless', + }); + + addEdge(graph, modelLoader, 'unet', refinerSeamless, 'unet'); + addEdge(graph, refinerSeamless, 'unet', refinerDenoise, 'unet'); + } else { + addEdge(graph, modelLoader, 'unet', refinerDenoise, 'unet'); + } + + // The refiner only has the second text encoder; the prompts condition it as style text. + addEdge(graph, modelLoader, 'clip2', posCond, 'clip2'); + addEdge(graph, modelLoader, 'clip2', negCond, 'clip2'); + addEdge(graph, positivePrompt, 'value', posCond, 'style'); + addEdge(graph, negativePrompt, 'value', negCond, 'style'); + addEdge(graph, posCond, 'conditioning', refinerDenoise, 'positive_conditioning'); + addEdge(graph, negCond, 'conditioning', refinerDenoise, 'negative_conditioning'); + addEdge(graph, denoise, 'latents', refinerDenoise, 'latents'); + + return refinerDenoise; +}; + +export const getRefinerMetadata = (settings: GenerateSettings, model: MainModelConfig, refiner: MainModelConfig) => ({ + refiner_cfg_scale: settings.refinerCfgScale, + refiner_model: toModelIdentifier(refiner), + refiner_negative_aesthetic_score: settings.refinerNegativeAestheticScore, + refiner_positive_aesthetic_score: settings.refinerPositiveAestheticScore, + refiner_scheduler: coerceSchedulerForGraph(model, settings.refinerScheduler), + refiner_start: settings.refinerStart, + refiner_steps: settings.refinerSteps, +}); 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 acd73b2f713..db1cef340ac 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.test.ts @@ -691,3 +691,66 @@ describe('planSeedSubmission', () => { ).toMatchObject({ lastSeed: 45, nextSeed: 46, sequenceLength: 4 }); }); }); + +describe('normalizeGenerateSettings refiner fields', () => { + const refiner: MainModelConfig = { base: 'sdxl-refiner', key: 'refiner', name: 'SDXL Refiner', type: 'main' }; + + it('defaults values saved before the refiner existed', () => { + expect(normalizeGenerateSettings(legacyStoredValues)).toMatchObject({ + refinerCfgScale: 7.5, + refinerModel: null, + refinerNegativeAestheticScore: 2.5, + refinerPositiveAestheticScore: 6, + refinerScheduler: 'euler', + refinerStart: 0.8, + refinerSteps: 20, + }); + }); + + it('keeps a refiner-family model and drops any other model stored in the slot', () => { + const stored = { ...legacyStoredValues, refinerModel: refiner }; + + expect(normalizeGenerateSettings(stored)?.refinerModel).toEqual(refiner); + expect( + normalizeGenerateSettings({ + ...legacyStoredValues, + refinerModel: { base: 'sdxl', key: 'sdxl', name: 'SDXL', type: 'main' }, + })?.refinerModel + ).toBeNull(); + expect( + normalizeGenerateSettings({ ...legacyStoredValues, refinerModel: { key: 'refiner' } })?.refinerModel + ).toBeNull(); + }); + + it('clamps the refiner tuning to its ranges and rounds steps', () => { + const normalized = normalizeGenerateSettings({ + ...legacyStoredValues, + refinerCfgScale: 0, + refinerNegativeAestheticScore: -3, + refinerPositiveAestheticScore: 42, + refinerScheduler: 7, + refinerStart: 1.5, + refinerSteps: 2.6, + }); + + expect(normalized).toMatchObject({ + refinerCfgScale: 1, + refinerNegativeAestheticScore: 1, + refinerPositiveAestheticScore: 10, + refinerScheduler: 'euler', + refinerStart: 1, + refinerSteps: 3, + }); + expect(normalized && isGenerateSettings(normalized)).toBe(true); + expect(isGenerateSettings({ ...normalized, refinerStart: 2 })).toBe(false); + }); + + it('refreshes a stale refiner snapshot from the installed catalog', () => { + const sdxl: MainModelConfig = { base: 'sdxl', key: 'legacy-model', name: 'SDXL', type: 'main' }; + const values = { ...normalizeGenerateSettings(legacyStoredValues)!, model: sdxl, refinerModel: refiner }; + const renamed = { ...refiner, name: 'SDXL Refiner 1.0' }; + + expect(syncGenerateWidgetValuesWithModels(values, [sdxl, renamed]).refinerModel).toEqual(renamed); + expect(syncGenerateWidgetValuesWithModels(values, [sdxl, refiner])).toBe(values); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/core/settings.ts b/invokeai/frontend/webv2/src/features/generation/core/settings.ts index b7609dc38dc..4135ee97914 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/settings.ts @@ -33,6 +33,21 @@ import { isDynamicPromptsSeedBehaviour, sanitizeMaxPrompts, sanitizeSampleSeed } import { clampPidSteps, DEFAULT_PID_STEPS, isPidMode } from './pid'; import { isCanonicalPromptTemplateSnapshot, sanitizePromptTemplateSnapshot } from './promptTemplates'; import { cloneCroppableImage, normalizeCroppableImage } from './referenceImage'; +import { + DEFAULT_REFINER_CFG_SCALE, + DEFAULT_REFINER_NEGATIVE_AESTHETIC_SCORE, + DEFAULT_REFINER_POSITIVE_AESTHETIC_SCORE, + DEFAULT_REFINER_SCHEDULER, + DEFAULT_REFINER_START, + DEFAULT_REFINER_STEPS, + isRefinerModel, + MAX_REFINER_AESTHETIC_SCORE, + MAX_REFINER_START, + MIN_REFINER_AESTHETIC_SCORE, + MIN_REFINER_CFG_SCALE, + MIN_REFINER_START, + MIN_REFINER_STEPS, +} from './sdxlRefiner'; /** Preset ratios, with the preset to switch to when dimensions are swapped. */ export const ASPECT_RATIO_MAP: Record, { ratio: number; inverseId: AspectRatioId }> = { @@ -200,6 +215,10 @@ const getModelIdentifierOrNull = (value: unknown): ComponentModelConfig | null = const getMainModelOrNull = (value: unknown): MainModelConfig | null => (isMainModelConfig(value) ? value : null); +/** Only a refiner-family model may persist in the slot; anything else stored there is dropped. */ +const getRefinerModelOrNull = (value: unknown): MainModelConfig | null => + isMainModelConfig(value) && isRefinerModel(value) ? value : null; + const isVaePrecision = (value: unknown): value is VaePrecision => value === 'fp16' || value === 'fp32'; /** Backend defaults from `Ideogram4DenoiseInvocation` / `Krea2*Invocation`. */ @@ -538,6 +557,7 @@ export const cloneGenerateWidgetValues = ( wanT5EncoderModel: values.wanT5EncoderModel ? { ...values.wanT5EncoderModel } : null, wanLowNoiseModel: values.wanLowNoiseModel ? { ...values.wanLowNoiseModel } : null, ideogram4UnconditionalModel: values.ideogram4UnconditionalModel ? { ...values.ideogram4UnconditionalModel } : null, + refinerModel: values.refinerModel ? { ...values.refinerModel } : null, ideogram4ColorPalette: [...values.ideogram4ColorPalette], referenceImages: cloneReferenceImages(values.referenceImages), t5EncoderModel: values.t5EncoderModel ? { ...values.t5EncoderModel } : null, @@ -614,6 +634,7 @@ export const syncGenerateWidgetValuesWithModels = ( : values.model; const vae = syncModelIdentifierWithModels(values.vae, modelsByKey); const componentSourceModel = syncModelIdentifierWithModels(values.componentSourceModel, modelsByKey); + const refinerModel = syncModelIdentifierWithModels(values.refinerModel, modelsByKey); const nextValues: GenerateWidgetValues = { ...values, clipEmbedModel: syncModelIdentifierWithModels(values.clipEmbedModel, modelsByKey), @@ -629,6 +650,7 @@ export const syncGenerateWidgetValuesWithModels = ( qwen35EncoderModel: syncModelIdentifierWithModels(values.qwen35EncoderModel, modelsByKey), animaInpaintAdapterModel: syncModelIdentifierWithModels(values.animaInpaintAdapterModel, modelsByKey), referenceImages: syncReferenceImagesWithModels(values.referenceImages, models), + refinerModel: isMainModelConfig(refinerModel) ? refinerModel : values.refinerModel, t5EncoderModel: syncModelIdentifierWithModels(values.t5EncoderModel, modelsByKey), vae: isVaeModelConfig(vae) ? vae : values.vae, }; @@ -647,6 +669,7 @@ export const syncGenerateWidgetValuesWithModels = ( nextValues.animaInpaintAdapterModel === values.animaInpaintAdapterModel && nextValues.referenceImages === values.referenceImages && nextValues.componentSourceModel === values.componentSourceModel && + nextValues.refinerModel === values.refinerModel && nextValues.modelKey === values.modelKey ? values : nextValues; @@ -895,6 +918,29 @@ export const normalizeGenerateSettings = (values: unknown): GenerateSettings | n pidDecoderModel: getModelIdentifierOrNull(values.pidDecoderModel), gemma2EncoderModel: getModelIdentifierOrNull(values.gemma2EncoderModel), pidSteps: clampPidSteps(hasFiniteNumber(values, 'pidSteps') ? (values.pidSteps as number) : DEFAULT_PID_STEPS), + refinerModel: getRefinerModelOrNull(values.refinerModel), + refinerSteps: hasFiniteNumber(values, 'refinerSteps') + ? Math.max(MIN_REFINER_STEPS, Math.round(values.refinerSteps as number)) + : DEFAULT_REFINER_STEPS, + refinerCfgScale: hasFiniteNumber(values, 'refinerCfgScale') + ? Math.max(MIN_REFINER_CFG_SCALE, values.refinerCfgScale as number) + : DEFAULT_REFINER_CFG_SCALE, + refinerScheduler: typeof values.refinerScheduler === 'string' ? values.refinerScheduler : DEFAULT_REFINER_SCHEDULER, + refinerPositiveAestheticScore: getClampedNumber( + values, + 'refinerPositiveAestheticScore', + MIN_REFINER_AESTHETIC_SCORE, + MAX_REFINER_AESTHETIC_SCORE, + DEFAULT_REFINER_POSITIVE_AESTHETIC_SCORE + ), + refinerNegativeAestheticScore: getClampedNumber( + values, + 'refinerNegativeAestheticScore', + MIN_REFINER_AESTHETIC_SCORE, + MAX_REFINER_AESTHETIC_SCORE, + DEFAULT_REFINER_NEGATIVE_AESTHETIC_SCORE + ), + refinerStart: getClampedNumber(values, 'refinerStart', MIN_REFINER_START, MAX_REFINER_START, DEFAULT_REFINER_START), width, }; }; diff --git a/invokeai/frontend/webv2/src/features/generation/core/types.ts b/invokeai/frontend/webv2/src/features/generation/core/types.ts index fb0b372ecfc..a2ba70b4727 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/types.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/types.ts @@ -292,6 +292,16 @@ export interface GenerateSettings { /** The shared Gemma-2 caption encoder PiD conditions its decode on. */ gemma2EncoderModel: ComponentModelConfig | null; pidSteps: number; + /** SDXL only: a refiner-family main model that finishes the schedule from `refinerStart`; null runs none. */ + refinerModel: MainModelConfig | null; + refinerSteps: number; + refinerCfgScale: number; + refinerScheduler: string; + /** Aesthetic-score conditioning for the refiner's positive and negative prompts, 1 to 10. */ + refinerPositiveAestheticScore: number; + refinerNegativeAestheticScore: number; + /** Fraction of the schedule at which the base model hands its latents to the refiner. */ + refinerStart: number; } /** diff --git a/invokeai/frontend/webv2/src/features/generation/settings.ts b/invokeai/frontend/webv2/src/features/generation/settings.ts index f27c74fd937..26baa285373 100644 --- a/invokeai/frontend/webv2/src/features/generation/settings.ts +++ b/invokeai/frontend/webv2/src/features/generation/settings.ts @@ -87,6 +87,17 @@ export { type RebalancePreset, } from './core/conditioningRebalance'; export { MIN_BATCH_COUNT, sanitizeBatchCount } from './core/batch'; +export { + getActiveRefiner, + isRefinerModel, + isRefinerSupported, + MAX_REFINER_AESTHETIC_SCORE, + MAX_REFINER_START, + MIN_REFINER_AESTHETIC_SCORE, + MIN_REFINER_CFG_SCALE, + MIN_REFINER_START, + MIN_REFINER_STEPS, +} from './core/sdxlRefiner'; export { createDynamicPromptsSampleSeed, DYNAMIC_PROMPTS_DEFAULT_MAX_PROMPTS, diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.browser.test.tsx index c8439c231a0..a1b4075f1d0 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.browser.test.tsx @@ -1,6 +1,9 @@ +import type { MainModelConfig } from '@features/generation/core/types'; import type { GenerationUiAdapter } from '@features/generation/ui/GenerationUiContext'; import { ChakraProvider } from '@chakra-ui/react'; +import { seedArchitectureCapabilities } from '@features/generation/core/architectureCapabilities.testing'; +import { getDefaultGenerateSettings } from '@features/generation/core/baseGenerationPolicies'; import { GenerationUiProvider } from '@features/generation/ui/GenerationUiContext'; import { closingFrames, recordDialogExit } from '@platform/ui/dialogExit.testing'; import { system } from '@theme/system'; @@ -14,7 +17,23 @@ import { userEvent } from 'vitest/browser'; import { GeneratePresetsPopover } from './GeneratePresetsPopover'; const i18n = i18next.createInstance(); -await i18n.use(initReactI18next).init({ fallbackLng: 'en', lng: 'en' }); +await i18n.use(initReactI18next).init({ + fallbackLng: 'en', + lng: 'en', + resources: { + en: { + translation: { + widgets: { + generate: { + presetSettingsCleared: 'Settings cleared by preset', + presetSettingsClearedDescription: 'Cleared: {{labels}}.', + }, + }, + }, + }, + }, +}); +seedArchitectureCapabilities(); let host: HTMLDivElement | null = null; let root: Root | null = null; @@ -52,21 +71,25 @@ const openRename = async (index: number) => { await expect.poll(() => modalDialog()?.getAttribute('data-state')).toBe('open'); }; -beforeEach(async () => { - host = document.createElement('div'); - document.body.append(host); - root = createRoot(host); +const renderAdapter = async (adapter: GenerationUiAdapter) => { await act(() => root?.render( - + ) ); +}; + +beforeEach(async () => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await renderAdapter(ADAPTER); }); afterEach(async () => { @@ -75,6 +98,38 @@ afterEach(async () => { }); describe('GeneratePresetsPopover', () => { + it.each(['omitted', 'null'] as const)( + 'clears an active refiner when applying a preset with %s refiner and names it in the clearing notice', + async (slot) => { + const model: MainModelConfig = { base: 'sdxl', key: 'sdxl', name: 'SDXL', type: 'main' }; + const refiner: MainModelConfig = { base: 'sdxl-refiner', key: 'refiner', name: 'Refiner', type: 'main' }; + const olderPreset: Record = { ...getDefaultGenerateSettings(model) }; + if (slot === 'omitted') { + delete olderPreset.refinerModel; + } + const patchGenerateSettings = vi.fn(); + const info = vi.fn(); + const adapter = { + ...ADAPTER, + generateValues: { getSnapshot: () => currentValues, subscribe: () => () => {} }, + models: { ...ADAPTER.models, catalog: [model, refiner] }, + presets: { ...ADAPTER.presets, presets: [{ id: 'older', label: 'Older preset', values: olderPreset }] }, + notifications: { ...ADAPTER.notifications, info }, + settings: { ...ADAPTER.settings, patchGenerateSettings }, + } as unknown as GenerationUiAdapter; + const currentValues = { ...olderPreset, model, refinerModel: refiner }; + await renderAdapter(adapter); + await act(() => userEvent.click(document.querySelector('[aria-label="widgets.generate.presets"]')!)); + const presetButton = [...document.querySelectorAll('button')].find( + (button) => button.textContent === 'Older preset' + ); + await act(() => presetButton!.click()); + + expect(patchGenerateSettings).toHaveBeenCalledWith(expect.objectContaining({ refinerModel: null }), 'project-1'); + expect(info).toHaveBeenCalledWith('Settings cleared by preset', 'Cleared: Refiner.'); + } + ); + it('animates the rename dialog out instead of unmounting it on close', async () => { await openRename(0); expect(renameInput()?.value).toBe('Portrait'); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.tsx index 7f8c3fa991e..9c92a0c5fd4 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GeneratePresetsPopover.tsx @@ -211,16 +211,21 @@ export const GeneratePresetsPopover = () => { } ui.settings.patchGenerateSettings(getGenerateFormCommitPatch(resolved.values), projectId); + const clearedLabels = + settings?.refinerModel && !resolved.values.refinerModel && !result.clearedLabels.includes('Refiner') + ? [...result.clearedLabels, 'Refiner'] + : result.clearedLabels; notifyGenerateModelSelectionCleared({ - clearedLabels: result.clearedLabels, + clearedLabels, locale: i18n.resolvedLanguage, modelName: model.name, notifications: ui.notifications, + reason: 'preset', t, }); setIsOpen(false); }, - [i18n.resolvedLanguage, models, projectId, supportedModels, t, ui.notifications, ui.settings] + [i18n.resolvedLanguage, models, projectId, settings, supportedModels, t, ui.notifications, ui.settings] ); const openSaveDialog = useCallback(() => setDialogSubject({ mode: 'save' }), []); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRefinerSection.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRefinerSection.browser.test.tsx new file mode 100644 index 00000000000..782d6fe09c9 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRefinerSection.browser.test.tsx @@ -0,0 +1,206 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-array-as-prop */ +import type { GenerationModelCatalogItem } from '@features/generation/contracts'; +import type { GenerateSettings, MainModelConfig } from '@features/generation/core/types'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { getDefaultGenerateSettings } from '@features/generation/core/baseGenerationPolicies'; +import { createExternalStoreCore } from '@platform/state/externalStoreCore'; +import { system } from '@theme/system'; +import { createInstance } from 'i18next'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GenerationModelSelectProps } from './GenerationUiContext'; + +import { GenerateRefinerSection } from './GenerateRefinerSection'; + +const sdxlModel: MainModelConfig = { base: 'sdxl', key: 'sdxl', name: 'SDXL', type: 'main' }; +const fluxModel: MainModelConfig = { base: 'flux', key: 'flux', name: 'FLUX.1 dev', type: 'main' }; +const refinerModel: MainModelConfig = { base: 'sdxl-refiner', key: 'refiner', name: 'SDXL Refiner', type: 'main' }; +const CATALOG: readonly GenerationModelCatalogItem[] = [sdxlModel, refinerModel]; + +vi.mock('./GenerationUiContext', () => ({ + /** The picker reduced to one button per catalog model that passes the section's filter. */ + GenerationModelSelect: ({ filter, onChange, scopeLabel, value }: GenerationModelSelectProps) => ( +
+ {CATALOG.filter((model) => filter?.(model) ?? true).map((model) => ( + + ))} +
+ ), + useGenerationUi: () => ({ + sectionPreferences: { sectionsOpen: { refiner: true }, setSectionOpen: vi.fn() }, + }), +})); + +const i18n = createInstance(); +void i18n.use(initReactI18next).init({ + fallbackLng: 'en', + initAsync: false, + lng: 'en', + resources: { + en: { + translation: { + commandPalette: { settings: { off: 'Off' } }, + widgets: { + generate: { + refiner: 'Refiner', + refinerCfgScale: 'Refiner CFG', + refinerModel: 'Refiner model', + refinerModelsScope: 'SDXL Refiner models', + refinerNegativeAestheticScore: 'Negative aesthetic score', + refinerNone: 'None', + refinerPositiveAestheticScore: 'Positive aesthetic score', + refinerScheduler: 'Refiner scheduler', + refinerStart: 'Refiner start', + refinerSteps: 'Refiner steps', + }, + }, + }, + }, + }, +}); + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const settle = (action: () => void): Promise => + act(async () => { + action(); + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 50); + }); + }); + +const render = async (model: MainModelConfig, options: Partial = {}) => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + const settings = { ...getDefaultGenerateSettings(model), ...options }; + const draft = createExternalStoreCore(settings); + const onCommit = vi.fn((patch: Partial) => draft.patchSnapshot(patch)); + const onCommitImmediate = vi.fn((patch: Partial) => draft.patchSnapshot(patch)); + + await settle(() => { + root?.render( + + + + + + ); + }); + + return { onCommit, onCommitImmediate }; +}; + +/** Sliders are named by `aria-labelledby`, so resolve them through their label element. */ +const sliderByLabel = (label: string): HTMLElement | null => + [...(host?.querySelectorAll('[role="slider"]') ?? [])].find((control) => { + const labelId = control.getAttribute('aria-labelledby'); + + return labelId !== null && host?.querySelector(`#${CSS.escape(labelId)}`)?.textContent === label; + }) ?? null; + +const schedulerInput = (): HTMLInputElement | null => + host?.querySelector('input[aria-label="Refiner scheduler"]') ?? null; + +const pickerButton = (name: string): HTMLButtonElement | null => + [...(host?.querySelectorAll('[data-testid="refiner-picker"] button') ?? [])].find( + (button) => button.textContent === name + ) ?? null; + +afterEach(async () => { + await settle(() => root?.unmount()); + host?.remove(); + host = null; + root = null; +}); + +describe('GenerateRefinerSection', () => { + it('renders for SDXL only', async () => { + await render(fluxModel); + + expect(host?.textContent).toBe(''); + + await settle(() => root?.unmount()); + host?.remove(); + + await render(sdxlModel); + + expect(host?.textContent).toContain('Refiner'); + expect(host?.querySelector('[data-testid="refiner-picker"]')).not.toBeNull(); + }); + + it('offers only refiner models and keeps the tuning disabled until one is picked', async () => { + const { onCommitImmediate } = await render(sdxlModel); + + expect(pickerButton('SDXL')).toBeNull(); + expect(pickerButton('SDXL Refiner')).not.toBeNull(); + expect(sliderByLabel('Refiner start')?.getAttribute('aria-disabled')).toBe('true'); + expect(sliderByLabel('Refiner steps')?.getAttribute('aria-disabled')).toBe('true'); + expect(sliderByLabel('Refiner CFG')?.getAttribute('aria-disabled')).toBe('true'); + expect(schedulerInput()?.disabled).toBe(true); + + await settle(() => pickerButton('SDXL Refiner')?.click()); + + expect(onCommitImmediate).toHaveBeenCalledWith({ refinerModel: refinerModel }); + }); + + it('enables the tuning once a refiner is selected and shows it in the header', async () => { + await render(sdxlModel, { refinerModel }); + + expect(host?.textContent).toContain('SDXL Refiner'); + expect(host?.querySelector('[data-testid="refiner-picker"]')?.getAttribute('data-value')).toBe('refiner'); + expect(sliderByLabel('Refiner start')?.getAttribute('aria-disabled')).not.toBe('true'); + expect(sliderByLabel('Refiner CFG')?.getAttribute('aria-disabled')).not.toBe('true'); + expect(schedulerInput()?.disabled).toBe(false); + expect(sliderByLabel('Positive aesthetic score')?.getAttribute('aria-valuenow')).toBe('6'); + expect(sliderByLabel('Negative aesthetic score')?.getAttribute('aria-valuenow')).toBe('2.5'); + }); + + it('shows Off at start 1, disables tuning, and lets the start control reactivate the refiner', async () => { + const { onCommit } = await render(sdxlModel, { refinerModel, refinerStart: 1 }); + + expect(host?.querySelector('button[data-scope="collapsible"]')?.textContent).toContain('Off'); + expect(sliderByLabel('Refiner start')?.getAttribute('aria-disabled')).not.toBe('true'); + expect(pickerButton('SDXL Refiner')?.disabled).toBe(false); + expect(sliderByLabel('Refiner steps')?.getAttribute('aria-disabled')).toBe('true'); + expect(sliderByLabel('Refiner CFG')?.getAttribute('aria-disabled')).toBe('true'); + expect(sliderByLabel('Positive aesthetic score')?.getAttribute('aria-disabled')).toBe('true'); + expect(sliderByLabel('Negative aesthetic score')?.getAttribute('aria-disabled')).toBe('true'); + expect(schedulerInput()?.disabled).toBe(true); + + await settle(() => { + const start = sliderByLabel('Refiner start'); + start?.focus(); + start?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + start?.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true })); + }); + + expect(onCommit).toHaveBeenCalledWith({ refinerStart: 0.99 }); + expect(sliderByLabel('Refiner steps')?.getAttribute('aria-disabled')).not.toBe('true'); + expect(schedulerInput()?.disabled).toBe(false); + expect(host?.querySelector('button[data-scope="collapsible"]')?.textContent).toContain('SDXL Refiner'); + }); + + it('leaves the empty and loading states to the picker, scoped to refiner models', async () => { + await render(sdxlModel); + + expect(host?.querySelector('[data-testid="refiner-picker"]')?.getAttribute('data-scope-label')).toBe( + 'SDXL Refiner models' + ); + // No section-level help text duplicates the picker's own empty message or flashes before the catalog loads. + expect(host?.textContent).not.toContain('installed'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateRefinerSection.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRefinerSection.tsx new file mode 100644 index 00000000000..4359c0028e3 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateRefinerSection.tsx @@ -0,0 +1,223 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-jsx-as-prop */ +import type { GenerationModelCatalogItem as ModelConfig } from '@features/generation/contracts'; +import type { GenerateModelConfig, GenerateSettings } from '@features/generation/core/types'; + +import { Badge, Stack } from '@chakra-ui/react'; +import { getGenerationUiPolicy, getSchedulerOptions } from '@features/generation/core/baseGenerationPolicies'; +import { + DEFAULT_REFINER_CFG_SCALE, + DEFAULT_REFINER_NEGATIVE_AESTHETIC_SCORE, + DEFAULT_REFINER_POSITIVE_AESTHETIC_SCORE, + DEFAULT_REFINER_SCHEDULER, + DEFAULT_REFINER_START, + DEFAULT_REFINER_STEPS, + getActiveRefiner, + isRefinerModel, + MAX_REFINER_AESTHETIC_SCORE, + MAX_REFINER_START, + MIN_REFINER_AESTHETIC_SCORE, + MIN_REFINER_CFG_SCALE, + MIN_REFINER_START, + MIN_REFINER_STEPS, +} from '@features/generation/core/sdxlRefiner'; +import { isMainModelConfig } from '@features/generation/core/settings'; +import { useExternalStoreSelector } from '@platform/state/selectors'; +import { Combobox } from '@platform/ui/Combobox'; +import { Field } from '@platform/ui/Field'; +import { ScrubberField } from '@platform/ui/ScrubberField'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { type GenerateDraft, pickGenerateSettings } from './generateDebounce'; +import { GenerationModelSelect as ModelSelect } from './GenerationUiContext'; +import { GenerateCollapsibleSection } from './shared/GenerateCollapsibleSection'; +import { GenerateFieldContextMenu } from './shared/GenerateFieldContextMenu'; + +/** Practical track ranges; typed values may exceed them up to the legacy input limits. */ +const STEPS_SLIDER_MAX = 100; +const STEPS_INPUT_MAX = 500; +const CFG_SLIDER_MAX = 20; +const CFG_INPUT_MAX = 200; + +interface GenerateRefinerSectionProps { + draft: GenerateDraft; + selectedModel: GenerateModelConfig | undefined; + onCommit: (patch: Partial) => void; + onCommitImmediate: (patch: Partial) => void; +} + +const selectRefinerSettings = pickGenerateSettings([ + 'refinerCfgScale', + 'refinerModel', + 'refinerNegativeAestheticScore', + 'refinerPositiveAestheticScore', + 'refinerScheduler', + 'refinerStart', + 'refinerSteps', +]); + +type RefinerNumberKey = + | 'refinerCfgScale' + | 'refinerNegativeAestheticScore' + | 'refinerPositiveAestheticScore' + | 'refinerStart' + | 'refinerSteps'; + +const refinerFilter = (model: ModelConfig) => isRefinerModel(model); +const MAIN_MODEL_TYPES = ['main']; + +export const GenerateRefinerSection = ({ + draft, + onCommit, + onCommitImmediate, + selectedModel, +}: GenerateRefinerSectionProps) => { + const { t } = useTranslation(); + const settings = useExternalStoreSelector(draft.subscribe, draft.getSnapshot, selectRefinerSettings); + const commitRefinerModel = useCallback( + (model: ModelConfig | null) => { + onCommitImmediate({ refinerModel: isMainModelConfig(model) && isRefinerModel(model) ? model : null }); + }, + [onCommitImmediate] + ); + + if (!getGenerationUiPolicy(selectedModel).refinerVisible) { + return null; + } + + const refiner = settings.refinerModel; + const isActive = getActiveRefiner(settings, selectedModel) !== null; + + const commitNumber = (key: RefinerNumberKey, min: number, max: number) => (value: number) => { + if (!Number.isFinite(value)) { + return; + } + + onCommit({ [key]: Math.min(max, Math.max(min, value)) }); + }; + + const numberField = ({ + defaultValue, + hint, + inputMax, + key, + label, + max, + min, + step, + }: { + defaultValue: number; + hint: 'refinerSteps' | 'refinerCfgScale' | 'refinerStart' | 'refinerAestheticScore'; + inputMax?: number; + key: RefinerNumberKey; + label: string; + max: number; + min: number; + step: number; + }) => ( + String(settings[key])} + isAtDefault={(key === 'refinerStart' ? !refiner : !isActive) || settings[key] === defaultValue} + onReset={() => onCommit({ [key]: defaultValue })} + > + + + ); + + return ( + {isActive ? refiner.name : t('commandPalette.settings.off')} : null} + sectionId="refiner" + > + + + {/* The picker owns the loading and empty states, so no refiner installed reads as one message. */} + + + {numberField({ + defaultValue: DEFAULT_REFINER_STEPS, + hint: 'refinerSteps', + inputMax: STEPS_INPUT_MAX, + key: 'refinerSteps', + label: t('widgets.generate.refinerSteps'), + max: STEPS_SLIDER_MAX, + min: MIN_REFINER_STEPS, + step: 1, + })} + {numberField({ + defaultValue: DEFAULT_REFINER_CFG_SCALE, + hint: 'refinerCfgScale', + inputMax: CFG_INPUT_MAX, + key: 'refinerCfgScale', + label: t('widgets.generate.refinerCfgScale'), + max: CFG_SLIDER_MAX, + min: MIN_REFINER_CFG_SCALE, + step: 0.5, + })} + settings.refinerScheduler} + isAtDefault={!isActive || settings.refinerScheduler === DEFAULT_REFINER_SCHEDULER} + onReset={() => onCommitImmediate({ refinerScheduler: DEFAULT_REFINER_SCHEDULER })} + > + + onCommitImmediate({ refinerScheduler })} + /> + + + {numberField({ + defaultValue: DEFAULT_REFINER_START, + hint: 'refinerStart', + key: 'refinerStart', + label: t('widgets.generate.refinerStart'), + max: MAX_REFINER_START, + min: MIN_REFINER_START, + step: 0.01, + })} + {numberField({ + defaultValue: DEFAULT_REFINER_POSITIVE_AESTHETIC_SCORE, + hint: 'refinerAestheticScore', + key: 'refinerPositiveAestheticScore', + label: t('widgets.generate.refinerPositiveAestheticScore'), + max: MAX_REFINER_AESTHETIC_SCORE, + min: MIN_REFINER_AESTHETIC_SCORE, + step: 0.5, + })} + {numberField({ + defaultValue: DEFAULT_REFINER_NEGATIVE_AESTHETIC_SCORE, + hint: 'refinerAestheticScore', + key: 'refinerNegativeAestheticScore', + label: t('widgets.generate.refinerNegativeAestheticScore'), + max: MAX_REFINER_AESTHETIC_SCORE, + min: MIN_REFINER_AESTHETIC_SCORE, + step: 0.5, + })} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx index 86d79bbe0c8..981f5770eb3 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx @@ -22,6 +22,7 @@ import { useRegisterGenerateDraftFlusher } from './generateDraftRegistry'; import { getSettingsWithLatestPromptFields } from './generateFormViewModel'; import { GenerateGuidanceSection } from './GenerateGuidanceSection'; import { GenerateModelCard } from './GenerateModelCard'; +import { GenerateRefinerSection } from './GenerateRefinerSection'; import { GenerateRenderSection } from './GenerateRenderSection'; import { GeneratePromptFields } from './promptFields'; @@ -280,6 +281,13 @@ export const GenerateSettingsForm = ({ + + { if (clearedLabels.length === 0) { @@ -24,11 +26,16 @@ export const notifyGenerateModelSelectionCleared = ({ const labels = new Intl.ListFormat(locale, { style: 'long', type: 'conjunction' }).format(clearedLabels); notifications.info( - t('widgets.generate.incompatibleSettingsCleared'), - t('widgets.generate.incompatibleSettingsClearedDescription', { - count: clearedLabels.length, - labels, - name: modelName, - }) + t(reason === 'preset' ? 'widgets.generate.presetSettingsCleared' : 'widgets.generate.incompatibleSettingsCleared'), + t( + reason === 'preset' + ? 'widgets.generate.presetSettingsClearedDescription' + : 'widgets.generate.incompatibleSettingsClearedDescription', + { + count: clearedLabels.length, + labels, + name: modelName, + } + ) ); }; diff --git a/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts b/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts index d1e32155504..dc39ce67ed6 100644 --- a/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts +++ b/invokeai/frontend/webv2/src/platform/ui/hints/hintRegistry.ts @@ -57,6 +57,12 @@ export const FEATURE_HINTS = { href: 'https://support.invoke.ai/support/solutions/articles/151000159340-global-and-regional-reference-images-ip-adapters-', }, referenceImageWeight: {}, + refinerAestheticScore: {}, + refinerCfgScale: {}, + refinerModel: {}, + refinerScheduler: {}, + refinerStart: {}, + refinerSteps: {}, scheduler: { href: 'https://www.youtube.com/watch?v=1OeHEJrsTpI' }, seamlessTiling: { href: SUPPORT_ADVANCED_SETTINGS }, seed: { 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 e85a2530fdd..95b68ba6d44 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/importGalleryImages.test.ts @@ -141,6 +141,13 @@ const setModel = (project: Project, base: GenerateWidgetValues['model']['base']) pidDecoderModel: null, gemma2EncoderModel: null, pidSteps: 4, + refinerModel: null, + refinerSteps: 20, + refinerCfgScale: 7.5, + refinerScheduler: 'euler', + refinerPositiveAestheticScore: 6, + refinerNegativeAestheticScore: 2.5, + refinerStart: 0.8, referenceImages: [], scheduler: 'euler_a', seamlessXAxis: false, diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts index b319e46ea09..1ec37a32b76 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.test.ts @@ -182,6 +182,80 @@ describe('executeImageRecall', () => { } }); + it('reports a recorded refiner that is not installed while applying the rest', async () => { + const { add, commands, setSettings } = createCommands(); + const t = await createRecallTranslator('en'); + + galleryApi.galleryImages.metadata.mockResolvedValue({ + model: { key: model.key }, + refiner_model: { base: 'sdxl-refiner', key: 'gone-refiner', name: 'Gone Refiner' }, + refiner_start: 0.6, + }); + + await expect( + executeImageRecall({ + t, + commands, + generateValues: { modelKey: model.key }, + image: { ...image, imageName: 'missing-refiner.png' }, + kind: 'all', + models: [model], + projectId: 'project-1', + }) + ).resolves.toBe(true); + + expect(setSettings).toHaveBeenCalledWith( + expect.objectContaining({ refinerModel: null, refinerStart: 0.8 }), + 'project-1' + ); + expect(add).toHaveBeenCalledWith({ + kind: 'info', + message: 'Refiner not restored: Gone Refiner is not installed; the current refiner settings were kept.', + title: 'Some image data was not recalled', + }); + }); + + it.each([ + { + metadata: { model: { key: model.key }, refiner_model: {} }, + message: 'Refiner not restored: unreadable metadata; the current refiner settings were kept.', + }, + { + metadata: { generation_mode: 'sdxl_txt2img', model: { key: 'deleted-main' } }, + message: "Refiner not restored: the image's model is not available; the current refiner settings were kept.", + }, + ])('explains retained refiner settings when none is selected', async ({ metadata, message }) => { + const { add, commands, setSettings } = createCommands(); + const t = await createRecallTranslator('en'); + galleryApi.galleryImages.metadata.mockResolvedValue({ + ...metadata, + positive_prompt: 'recalled', + refiner_start: 0.6, + }); + + await expect( + executeImageRecall({ + t, + commands, + generateValues: { modelKey: model.key, refinerModel: null, refinerStart: 0.9 }, + image, + kind: 'all', + models: [model], + projectId: 'project-1', + }) + ).resolves.toBe(true); + + expect(setSettings).toHaveBeenCalledWith( + expect.objectContaining({ positivePrompt: 'recalled', refinerModel: null, refinerStart: 0.9 }), + 'project-1' + ); + expect(add).toHaveBeenCalledWith({ + kind: 'info', + message, + title: 'Some image data was not recalled', + }); + }); + it('explains why nothing was recalled when no recorded concept can be restored', async () => { const { add, commands, setSettings } = createCommands(); const t = await createRecallTranslator('en'); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts index d70411da8ad..3a93d0cacd0 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeImageRecall.ts @@ -42,6 +42,21 @@ import { type ImageRecallSkip, } from './imageRecall'; +const getSkippedRefinerMessage = ({ name, reason }: ImageRecallSkip, t: TFunction): string => { + const values = { name: name ?? t('widgets.gallery.itemActions.recall.unnamedRefiner') }; + + switch (reason) { + case 'ambiguous': + return t('widgets.gallery.itemActions.recall.skippedRefiner.ambiguous', values); + case 'invalid': + return t('widgets.gallery.itemActions.recall.skippedRefiner.invalid', values); + case 'modelUnavailable': + return t('widgets.gallery.itemActions.recall.skippedRefiner.modelUnavailable', values); + default: + return t('widgets.gallery.itemActions.recall.skippedRefiner.unresolved', values); + } +}; + const getSkippedConceptMessage = ({ name, reason }: ImageRecallSkip, t: TFunction): string => { const values = { name: name ?? t('widgets.gallery.itemActions.recall.unnamedConcept') }; @@ -368,12 +383,17 @@ export const executeImageRecall = async ({ } } - const skipMessage = - result.skipped.length > 0 + const skippedConcepts = result.skipped.filter((skipped) => skipped.subject === 'concept'); + const skippedRefiner = result.skipped.find((skipped) => skipped.subject === 'refiner'); + const skipMessages = [ + skippedConcepts.length > 0 ? t('widgets.gallery.itemActions.recall.conceptsNotRestored', { - concepts: result.skipped.map((skipped) => getSkippedConceptMessage(skipped, t)).join('; '), + concepts: skippedConcepts.map((skipped) => getSkippedConceptMessage(skipped, t)).join('; '), }) - : null; + : null, + skippedRefiner ? getSkippedRefinerMessage(skippedRefiner, t) : null, + ].filter((message) => message !== null); + const skipMessage = skipMessages.length > 0 ? skipMessages.join(' ') : null; if (result.fields.length === 0) { commands.notifications.add({ diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/executeRecallParameters.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/executeRecallParameters.test.ts index b5d00003432..50c34f3738c 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/executeRecallParameters.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/executeRecallParameters.test.ts @@ -119,7 +119,7 @@ describe('executeRecallParameters', () => { expect(add).toHaveBeenCalledWith( expect.objectContaining({ kind: 'info', - message: 'refiner_model: not supported by the Generate panel', + message: 'refiner_model: not found (refiner)', title: 'Some recalled parameters were not applied', }) ); 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 ecae9a63c1a..f73f392294e 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -99,6 +99,13 @@ const createValues = (overrides: Partial = {}): GenerateWi pidDecoderModel: null, gemma2EncoderModel: null, pidSteps: 4, + refinerModel: null, + refinerSteps: 20, + refinerCfgScale: 7.5, + refinerScheduler: 'euler', + refinerPositiveAestheticScore: 6, + refinerNegativeAestheticScore: 2.5, + refinerStart: 0.8, cfgRescaleMultiplier: 0, cfgScale: 7, clipEmbedModel: null, @@ -892,8 +899,8 @@ describe('image recall', () => { expect(result?.values.loras).toEqual([{ isEnabled: true, model: staleLora, weight: 0.9 }]); expect(result?.fields).not.toContain('loras'); expect(result?.skipped).toEqual([ - { name: 'Grain', reason: 'modelUnavailable' }, - { name: 'Ink', reason: 'modelUnavailable' }, + { name: 'Grain', reason: 'modelUnavailable', subject: 'concept' }, + { name: 'Ink', reason: 'modelUnavailable', subject: 'concept' }, ]); }); @@ -970,11 +977,11 @@ describe('image recall', () => { expect(result?.values.loras).toEqual([{ isEnabled: true, model: inkLora, weight: 0.5 }]); expect(result?.skipped).toEqual([ - { name: 'Deleted', reason: 'unresolved' }, - { name: 'Pixel', reason: 'incompatible' }, - { name: null, reason: 'invalid' }, - { name: null, reason: 'invalid' }, - { name: 'Ink', reason: 'duplicate' }, + { name: 'Deleted', reason: 'unresolved', subject: 'concept' }, + { name: 'Pixel', reason: 'incompatible', subject: 'concept' }, + { name: null, reason: 'invalid', subject: 'concept' }, + { name: null, reason: 'invalid', subject: 'concept' }, + { name: 'Ink', reason: 'duplicate', subject: 'concept' }, ]); }); @@ -982,7 +989,7 @@ describe('image recall', () => { const result = recallConcepts('all', { loras: 'ink', model: { key: sdxlModel.key }, steps: 20 }); expect(result?.values.loras).toEqual([{ isEnabled: true, model: staleLora, weight: 0.9 }]); - expect(result?.skipped).toEqual([{ name: null, reason: 'invalid' }]); + expect(result?.skipped).toEqual([{ name: null, reason: 'invalid', subject: 'concept' }]); expect(result?.fields).toContain('steps'); }); @@ -999,7 +1006,7 @@ describe('image recall', () => { expect(result?.values.model).toEqual(sd1Model); expect(result?.values.loras).toEqual([{ isEnabled: true, model: sd1Lora, weight: 0.6 }]); - expect(result?.skipped).toEqual([{ name: 'Ink', reason: 'incompatible' }]); + expect(result?.skipped).toEqual([{ name: 'Ink', reason: 'incompatible', subject: 'concept' }]); }); it('resolves a reinstalled LoRA by its content hash, or by name and base when no hash was recorded', () => { @@ -1035,7 +1042,7 @@ describe('image recall', () => { ); expect(result?.values.loras).toEqual([]); - expect(result?.skipped).toEqual([{ name: 'Ink', reason: 'ambiguous' }]); + expect(result?.skipped).toEqual([{ name: 'Ink', reason: 'ambiguous', subject: 'concept' }]); }); it('does not substitute a same-named LoRA whose content differs from the recorded hash', () => { @@ -1049,7 +1056,7 @@ describe('image recall', () => { [sdxlModel, { ...inkLora, hash: 'blake3:retrained', key: 'retrained-ink' }] ); - expect(result?.skipped).toEqual([{ name: 'Ink', reason: 'unresolved' }]); + expect(result?.skipped).toEqual([{ name: 'Ink', reason: 'unresolved', subject: 'concept' }]); }); it('offers Recall All and Remix for metadata that records only concepts it can restore', () => { @@ -1659,6 +1666,260 @@ describe('remix round trip', () => { }); }); +describe('SDXL Refiner recall', () => { + const refiner: MainModelConfig = { + base: 'sdxl-refiner', + hash: 'blake3:refiner', + key: 'refiner-installed', + name: 'SDXL Refiner', + type: 'main', + }; + const recordedRefiner = { + refiner_cfg_scale: 5, + refiner_model: { base: 'sdxl-refiner', hash: 'blake3:refiner', key: 'refiner-recorded', name: 'SDXL Refiner' }, + refiner_negative_aesthetic_score: 2, + refiner_positive_aesthetic_score: 7, + refiner_scheduler: 'dpmpp_2m', + refiner_start: 0.7, + refiner_steps: 12, + }; + const currentRefinerSettings = { + refinerCfgScale: 8, + refinerModel: refiner, + refinerNegativeAestheticScore: 3, + refinerPositiveAestheticScore: 9, + refinerScheduler: 'euler', + refinerStart: 0.9, + refinerSteps: 25, + }; + const recall = ( + metadata: Record, + models: ComponentModelConfig[], + kind: ImageRecallKind = 'all', + currentValues = createValues() + ) => + buildImageRecallSettings({ + currentValues, + image, + kind, + metadata: { model: { key: sdxlModel.key }, ...metadata }, + models, + supportedModels: [sdxlModel], + vaeModels: [], + }); + + it.each(['all', 'remix'] as const)('restores the refiner and its tuning with %s, resolving by hash', (kind) => { + const result = recall(recordedRefiner, [refiner], kind); + + expect(result?.values).toMatchObject({ + refinerCfgScale: 5, + refinerModel: refiner, + refinerNegativeAestheticScore: 2, + refinerPositiveAestheticScore: 7, + refinerScheduler: 'dpmpp_2m', + refinerStart: 0.7, + refinerSteps: 12, + }); + expect(result?.fields).toContain('refiner'); + }); + + it('resolves the refiner by key, then by name and base, and only when that is unambiguous', () => { + const byKey = { ...refiner, hash: 'blake3:other', key: 'refiner-recorded' }; + + expect(recall(recordedRefiner, [byKey, refiner])?.values.refinerModel).toEqual(byKey); + + const unhashed = { ...recordedRefiner, refiner_model: { base: 'sdxl-refiner', name: 'SDXL Refiner' } }; + + expect(recall(unhashed, [refiner])?.values.refinerModel).toEqual(refiner); + expect(recall(unhashed, [refiner])?.skipped).toEqual([]); + }); + + it.each([ + { metadata: recordedRefiner, models: [], name: 'SDXL Refiner', reason: 'unresolved' }, + { + metadata: { ...recordedRefiner, refiner_model: { base: 'sdxl-refiner', name: 'SDXL Refiner' } }, + models: [refiner, { ...refiner, key: 'refiner-twin' }], + name: 'SDXL Refiner', + reason: 'ambiguous', + }, + ...[{}, 'refiner', { key: 42 }, []].map((refiner_model) => ({ + metadata: { ...recordedRefiner, refiner_model }, + models: [refiner], + name: null, + reason: 'invalid', + })), + ])('keeps the model and all tuning together for a $reason refiner', ({ metadata, models, name, reason }) => { + for (const refinerModel of [refiner, null]) { + const current = { ...currentRefinerSettings, refinerModel }; + const result = recall(metadata, models, 'all', createValues(current)); + + expect(result?.values).toMatchObject(current); + expect(result?.fields).not.toContain('refiner'); + expect(result?.skipped).toEqual([{ name, reason, subject: 'refiner' }]); + } + expect( + getImageRecallCapabilities({ + currentValues: createValues(), + image, + metadata, + models, + supportedModels: [sdxlModel], + vaeModels: [], + }) + ).toMatchObject({ all: false, remix: false }); + }); + + it.each(['all', 'remix'] as const)( + 'keeps refiner settings during %s when the recorded main model is unavailable', + (kind) => { + // A persisted selection may still name the deleted main model's key. + for (const key of ['deleted-main', sdxlModel.key]) { + for (const refinerMetadata of [ + recordedRefiner, + { refiner_model: {}, refiner_steps: 12 }, + {}, + { refiner_model: null }, + ]) { + const metadata = { generation_mode: 'sdxl_txt2img', model: { key }, ...refinerMetadata }; + const currentValues = createValues(currentRefinerSettings); + const result = buildImageRecallSettings({ + currentValues, + image, + kind, + metadata, + models: [refiner], + supportedModels: [], + vaeModels: [], + }); + + if ('refiner_model' in refinerMetadata && refinerMetadata.refiner_model) { + expect(result?.values).toMatchObject(currentRefinerSettings); + expect(result?.fields).not.toContain('refiner'); + expect(result?.skipped).toEqual([ + { + name: refinerMetadata === recordedRefiner ? 'SDXL Refiner' : null, + reason: 'modelUnavailable', + subject: 'refiner', + }, + ]); + } else { + // A record that ran without a refiner keeps the slot quietly: nothing to restore, nothing to report. + expect(result).toBeNull(); + } + expect( + getImageRecallCapabilities({ + currentValues, + image, + metadata, + models: [refiner], + supportedModels: [], + vaeModels: [], + }) + ).toMatchObject({ all: false, remix: false }); + } + } + } + ); + + it('does not treat a non-refiner model named by the record as the refiner', () => { + const result = recall({ ...recordedRefiner, refiner_model: { key: sdxlModel.key } }, [sdxlModel, refiner]); + + expect(result?.values.refinerModel).toBeNull(); + expect(result?.skipped).toEqual([{ name: sdxlModel.key, reason: 'unresolved', subject: 'refiner' }]); + }); + + it('clears the refiner for a generation record that ran without one and ignores refiner data on other families', () => { + const cleared = buildImageRecallSettings({ + currentValues: createValues({ refinerModel: refiner }), + image, + kind: 'all', + metadata: { generation_mode: 'sdxl_txt2img', model: { key: sdxlModel.key } }, + models: [refiner], + supportedModels: [sdxlModel], + vaeModels: [], + }); + + expect(cleared?.values.refinerModel).toBeNull(); + expect(cleared?.fields).toContain('refiner'); + + // A canvas save names a model but is not a generation record, so it says nothing about the refiner. + const canvasSave = buildImageRecallSettings({ + currentValues: createValues({ refinerModel: refiner }), + image, + kind: 'all', + metadata: { model: { key: sdxlModel.key }, positive_prompt: 'kept' }, + models: [refiner], + supportedModels: [sdxlModel], + vaeModels: [], + }); + + expect(canvasSave?.values.refinerModel).toEqual(refiner); + expect(canvasSave?.fields).not.toContain('refiner'); + + const sd1 = buildImageRecallSettings({ + currentValues: createValues(), + image, + kind: 'all', + metadata: { model: { key: sd1Model.key }, ...recordedRefiner }, + models: [sd1Model, refiner], + supportedModels: [sd1Model], + vaeModels: [], + }); + + expect(sd1?.values.refinerModel).toBeNull(); + expect(sd1?.fields).not.toContain('refiner'); + }); + + it('drops out-of-range refiner tuning and unknown schedulers', () => { + const result = recall({ ...recordedRefiner, refiner_scheduler: 'bogus', refiner_start: 3, refiner_steps: 0 }, [ + refiner, + ]); + + expect(result?.values).toMatchObject({ + refinerModel: refiner, + refinerScheduler: 'euler', + refinerStart: 0.8, + refinerSteps: 20, + }); + }); + + it('round-trips the refiner a compiled SDXL graph records', () => { + const { model, settings } = satisfiedSettingsFor('sdxl', { + label: 'diffusers', + overrides: { format: 'diffusers' }, + }); + const graph = compileGenerateGraph({ ...settings, refinerModel: refiner, refinerStart: 0.65 }, model, 'gallery', { + useCpuNoise: true, + useNsfwChecker: false, + useWatermark: false, + }).backendGraph; + const metadata = Object.values(graph.nodes).find((node) => node.type === 'core_metadata'); + const result = buildImageRecallSettings({ + currentValues: createValues({ model: sd1Model, modelKey: sd1Model.key }), + image, + kind: 'remix', + metadata, + models: [refiner], + supportedModels: [model], + vaeModels: [], + }); + + expect(result?.values.model).toEqual(model); + expect(result?.values.refinerModel).toEqual(refiner); + expect(result?.values.refinerStart).toBe(0.65); + expect( + getImageRecallCapabilities({ + currentValues: createValues(), + image, + metadata: { refiner_model: metadata?.refiner_model }, + models: [refiner], + supportedModels: [model], + vaeModels: [], + }) + ).toMatchObject({ all: true, remix: true }); + }); +}); + describe('legacy records', () => { const fluxModel: MainModelConfig = { base: 'flux', key: 'flux-model', name: 'FLUX', type: 'main' }; const recall = ( diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index 011e3aeea51..969b347fb37 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -29,6 +29,7 @@ import { isMainModelConfig, isModelIdentifierConfig, isVaeCompatibleWithGenerateModel, + isRefinerModel, isFluxDypePreset, isQwenImageEncoderQuantization, isValidKrea2RebalanceWeights, @@ -36,9 +37,15 @@ import { MAX_FLUX_DYPE_SCALE, MAX_HIDIFFUSION_RATIO, MAX_KREA2_SEED_VARIANCE_STRENGTH, + MAX_REFINER_AESTHETIC_SCORE, + MAX_REFINER_START, MAX_TIMESTEP_SHIFT, MAX_ZIMAGE_SEED_VARIANCE_STRENGTH, MIN_HIDIFFUSION_T1_RATIO, + MIN_REFINER_AESTHETIC_SCORE, + MIN_REFINER_CFG_SCALE, + MIN_REFINER_START, + MIN_REFINER_STEPS, MIN_TIMESTEP_SHIFT, MIN_ZIMAGE_SEED_VARIANCE_RANDOMIZE_PERCENT, normalizeReferenceImages, @@ -84,6 +91,7 @@ type RecalledField = | 'loras' | 'referenceImages' | 'krea2Rebalance' + | 'refiner' | 'krea2SeedVariance' | 'modelTuning' | 'animaInpaintAdapterWeight'; @@ -96,10 +104,11 @@ export type ImageRecallSkipReason = | 'modelUnavailable' | 'unresolved'; -/** A recorded concept recall could not restore; `name` is the best label the record or catalog offers. */ +/** A recorded model recall could not restore; `name` is the best label the record or catalog offers. */ export interface ImageRecallSkip { name: string | null; reason: ImageRecallSkipReason; + subject: 'concept' | 'refiner'; } export interface ImageRecallResult { @@ -736,6 +745,119 @@ const resolveRecordedModel = ( return matches.length > 1 ? 'ambiguous' : (matches[0] ?? null); }; +type RefinerPatch = Partial< + Pick< + GenerateWidgetValues, + | 'refinerModel' + | 'refinerSteps' + | 'refinerCfgScale' + | 'refinerScheduler' + | 'refinerPositiveAestheticScore' + | 'refinerNegativeAestheticScore' + | 'refinerStart' + > +>; + +const getNumberInRange = (metadata: unknown, key: string, min: number, max: number): number | null => { + const value = getNumber(metadata, key); + + return value !== null && value >= min && value <= max ? value : null; +}; + +interface RefinerRecall { + patch: RefinerPatch; + /** The recorded refiner could not be restored; its current model and tuning stay together. */ + skipped: ImageRecallSkip | null; +} + +const isInstalledRefiner = (model: ComponentModelConfig): model is MainModelConfig => + isMainModelConfig(model) && isRefinerModel(model); + +/** + * The refiner an SDXL image ran with, resolved against installed refiner models, plus its tuning. Generation records + * only name a refiner when one ran, so a stamped record without one clears the slot, as a missing `loras` list means + * no concepts. + */ +const getRefinerRecall = ( + metadata: unknown, + model: GenerateModelConfig, + models: readonly ComponentModelConfig[], + sourceModel: GenerateModelConfig | null +): RefinerRecall => { + if (!getGenerationUiPolicy(model).refinerVisible || !isRecord(metadata)) { + return { patch: {}, skipped: null }; + } + + const patch: RefinerPatch = {}; + const ref = toRecordedModelRef(metadata.refiner_model); + + // Refiner presence and tuning describe the recorded main model, including records that ran without a refiner. + if (getRecord(metadata, 'model') && sourceModel?.key !== model.key) { + // As with concepts, only a refiner the record names is reported; a record without one keeps the slot quietly. + const recordsRefiner = Object.entries(metadata).some( + ([key, value]) => key.startsWith('refiner_') && value !== null + ); + + return { + patch: {}, + skipped: recordsRefiner + ? { name: ref?.name ?? ref?.key ?? null, reason: 'modelUnavailable', subject: 'refiner' } + : null, + }; + } + + if ('refiner_model' in metadata) { + const refiner = ref ? resolveRecordedModel(ref, models.filter(isInstalledRefiner)) : null; + + if (metadata.refiner_model === null) { + patch.refinerModel = null; + } else if (refiner === null || refiner === 'ambiguous') { + return { + patch: {}, + skipped: { + name: ref?.name ?? ref?.key ?? null, + reason: !ref ? 'invalid' : refiner === 'ambiguous' ? 'ambiguous' : 'unresolved', + subject: 'refiner', + }, + }; + } else { + patch.refinerModel = refiner; + } + } else if (isGenerationRecord(metadata)) { + patch.refinerModel = null; + } + + const steps = getInteger(metadata, 'refiner_steps'); + const cfgScale = getNumber(metadata, 'refiner_cfg_scale'); + const scheduler = getString(metadata, 'refiner_scheduler'); + const start = getNumberInRange(metadata, 'refiner_start', MIN_REFINER_START, MAX_REFINER_START); + const positiveScore = getNumberInRange( + metadata, + 'refiner_positive_aesthetic_score', + MIN_REFINER_AESTHETIC_SCORE, + MAX_REFINER_AESTHETIC_SCORE + ); + const negativeScore = getNumberInRange( + metadata, + 'refiner_negative_aesthetic_score', + MIN_REFINER_AESTHETIC_SCORE, + MAX_REFINER_AESTHETIC_SCORE + ); + + return { + patch: { + ...patch, + ...(steps !== null && steps >= MIN_REFINER_STEPS ? { refinerSteps: steps } : {}), + ...(cfgScale !== null && cfgScale >= MIN_REFINER_CFG_SCALE ? { refinerCfgScale: cfgScale } : {}), + ...(scheduler !== null && isKnownScheduler(scheduler) ? { refinerScheduler: scheduler } : {}), + ...(start !== null ? { refinerStart: start } : {}), + ...(positiveScore !== null ? { refinerPositiveAestheticScore: positiveScore } : {}), + ...(negativeScore !== null ? { refinerNegativeAestheticScore: negativeScore } : {}), + }, + skipped: null, + }; +}; + interface ConceptRecall { /** The concept list to apply, or null when the metadata says nothing reliable about concepts. */ loras: GenerateLora[] | null; @@ -766,6 +888,7 @@ const getMetadataConcepts = ( skipped: entries.map((entry) => ({ name: toRecordedModelRef(getRecord(entry, 'model') ?? getRecord(entry, 'lora'))?.name ?? null, reason: 'modelUnavailable', + subject: 'concept', })), }; } @@ -775,7 +898,7 @@ const getMetadataConcepts = ( } if (!Array.isArray(recorded)) { - return { loras: null, skipped: [{ name: null, reason: 'invalid' }] }; + return { loras: null, skipped: [{ name: null, reason: 'invalid', subject: 'concept' }] }; } const loraModels = models.filter(isLoraModelConfig); @@ -789,18 +912,22 @@ const getMetadataConcepts = ( const recordedName = ref?.name ?? null; if (!ref || weight === null) { - skipped.push({ name: recordedName, reason: 'invalid' }); + skipped.push({ name: recordedName, reason: 'invalid', subject: 'concept' }); continue; } const installed = resolveRecordedModel(ref, loraModels); if (installed === null || installed === 'ambiguous') { - skipped.push({ name: recordedName, reason: installed === 'ambiguous' ? 'ambiguous' : 'unresolved' }); + skipped.push({ + name: recordedName, + reason: installed === 'ambiguous' ? 'ambiguous' : 'unresolved', + subject: 'concept', + }); } else if (!isLoraCompatibleWithModel(installed, model)) { - skipped.push({ name: installed.name, reason: 'incompatible' }); + skipped.push({ name: installed.name, reason: 'incompatible', subject: 'concept' }); } else if (loras.some((lora) => lora.model.key === installed.key)) { - skipped.push({ name: installed.name, reason: 'duplicate' }); + skipped.push({ name: installed.name, reason: 'duplicate', subject: 'concept' }); } else { // Kept exactly: graphs and model defaults accept weights beyond the panel's typed range, which bounds only edits. loras.push({ isEnabled: true, model: installed, weight }); @@ -937,6 +1064,8 @@ export const getImageRecallCapabilities = ({ const hasModel = supportedMetadataModel !== null; const hasRebalance = hasKrea2Rebalance(metadata, clipSkipModel) || hasKrea2SeedVariance(metadata, clipSkipModel); const hasTuning = hasModelTuning(metadata, clipSkipModel); + const hasRefiner = + Object.keys(getRefinerRecall(metadata, clipSkipModel, models, supportedMetadataModel).patch).length > 0; const hasAdapterWeight = hasAnimaInpaintAdapterWeight(metadata, clipSkipModel); const hasConcepts = (getMetadataConcepts(metadata, models, clipSkipModel, supportedMetadataModel).loras?.length ?? 0) > 0; @@ -953,6 +1082,7 @@ export const getImageRecallCapabilities = ({ hasReferenceImages || hasConcepts || hasRebalance || + hasRefiner || hasTuning || hasAdapterWeight; const hasNonSeedMetadata = @@ -967,6 +1097,7 @@ export const getImageRecallCapabilities = ({ hasReferenceImages || hasConcepts || hasRebalance || + hasRefiner || hasTuning || hasAdapterWeight; @@ -1182,6 +1313,28 @@ export const buildImageRecallSettings = ({ values = { ...values, ...hiDiffusionPatch }; fields.push('hiDiffusion'); } + + // Judged against the model the recall settled on: a non-SDXL main has no refiner to restore. + const refinerRecall = getRefinerRecall( + metadata, + values.model, + models, + getSupportedMetadataModel(metadata, supportedModels) + ); + const refinerPatch = refinerRecall.patch; + const refinerChanged = (Object.keys(refinerPatch) as (keyof RefinerPatch)[]).some((key) => + key === 'refinerModel' + ? refinerPatch.refinerModel?.key !== values.refinerModel?.key + : refinerPatch[key] !== values[key] + ); + + if (refinerChanged) { + values = { ...values, ...refinerPatch }; + fields.push('refiner'); + } + if (refinerRecall.skipped) { + skipped.push(refinerRecall.skipped); + } } if (kind === 'all' || kind === 'remix' || kind === 'clipSkip') { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.test.ts b/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.test.ts index 57d7818289b..2fa06ee3a43 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.test.ts @@ -20,6 +20,9 @@ const makeModel = (key: string, base: string, type: string, extra: Record { expect(result.values.model.key).toBe(sdxl.key); }); + it('restores a refiner on current SDXL without a main-model field and maps API tuning', () => { + const result = build(currentFor(sdxl), { + refiner_model: refiner.key, + refiner_steps: 12, + refiner_cfg_scale: 4.5, + refiner_denoise_start: 0.65, + refiner_positive_aesthetic_score: 7, + refiner_negative_aesthetic_score: 3, + }); + + expect(result.skipped).toEqual([]); + expect(result.fields).toEqual(['refiner']); + expect(result.values).toMatchObject({ + refinerModel: refiner, + refinerSteps: 12, + refinerCfgScale: 4.5, + refinerStart: 0.65, + refinerPositiveAestheticScore: 7, + refinerNegativeAestheticScore: 3, + }); + expect(build(currentFor(sdxl), { refiner_model: refiner.name }).values.refinerModel).toEqual(refiner); + }); + + it('resolves names within the refiner family even when a main model has the same name', () => { + const namedRefiner = makeModel('named-refiner', 'sdxl-refiner', 'main', { name: sdxl.name }); + const currentValues = currentFor(sdxl); + const result = buildRecallParametersSettings({ + currentValues, + models: [...models, namedRefiner], + parameters: { refiner_model: sdxl.name }, + supportedModels, + vaeModels, + }); + + expect(result.skipped).toEqual([]); + expect(result.values.refinerModel).toEqual(namedRefiner); + expect(build(currentValues, { refiner_model: sdxl.key }).skipped).toEqual([ + { key: 'refiner_model', reason: 'incompatible', detail: sdxl.name }, + ]); + }); + + it('preserves omitted refiner settings during partial recall and uses the effective main model', () => { + const current = currentFor(sdxl, { refinerModel: refiner, refinerSteps: 35, refinerStart: 0.7 }); + const partial = build(current, { refiner_cfg_scale: 5 }); + + expect(partial.skipped).toEqual([]); + expect(partial.values).toMatchObject({ + refinerModel: refiner, + refinerSteps: 35, + refinerStart: 0.7, + refinerCfgScale: 5, + }); + expect(build(current, { positive_prompt: 'only prompt' }).values.refinerModel).toEqual(refiner); + + const switched = build(currentFor(flux), { model: sdxl.key, refiner_model: refiner.key, refiner_steps: 15 }); + expect(switched.skipped).toEqual([]); + expect(switched.values).toMatchObject({ model: sdxl, refinerModel: refiner, refinerSteps: 15 }); + + const unsupported = build(currentFor(flux), { refiner_steps: 15, refiner_cfg_scale: 6 }); + expect(unsupported.fields).toEqual([]); + expect(unsupported.skipped).toEqual([ + { key: 'refiner_steps', reason: 'incompatible', detail: flux.name }, + { key: 'refiner_cfg_scale', reason: 'incompatible', detail: flux.name }, + ]); + + const away = build(current, { model: flux.key, refiner_model: refiner.key, refiner_steps: 15 }); + expect(away.values.refinerModel).toBeNull(); + expect(away.values.refinerSteps).toBe(35); + expect(away.skipped.map(({ key }) => key)).toEqual(['refiner_model', 'refiner_steps']); + }); + + it('clears a refiner and resets all provided null tuning without changing omitted settings', () => { + const current = currentFor(sdxl, { + refinerModel: refiner, + refinerSteps: 35, + refinerCfgScale: 4, + refinerStart: 0.6, + refinerPositiveAestheticScore: 9, + refinerNegativeAestheticScore: 8, + refinerScheduler: 'ddim', + }); + const cleared = build(current, { refiner_model: null }); + expect(cleared.values).toMatchObject({ refinerModel: null, refinerSteps: 35, refinerStart: 0.6 }); + + const reset = build(current, { + refiner_model: null, + refiner_steps: null, + refiner_cfg_scale: null, + refiner_denoise_start: null, + refiner_positive_aesthetic_score: null, + refiner_negative_aesthetic_score: null, + }); + expect(reset.skipped).toEqual([]); + expect(reset.fields).toEqual(['refiner']); + expect(reset.values).toMatchObject({ + refinerModel: null, + refinerSteps: 20, + refinerCfgScale: 7.5, + refinerStart: 0.8, + refinerPositiveAestheticScore: 6, + refinerNegativeAestheticScore: 2.5, + refinerScheduler: 'ddim', + }); + + const dormantReset = build(currentFor(flux, { refinerSteps: 35 }), { refiner_steps: null }); + expect(dormantReset.skipped).toEqual([]); + expect(dormantReset.values.refinerSteps).toBe(20); + }); + + it.each([sdxl, refinerLora])( + 'rejects a non-refiner slot model $key without retuning the current refiner', + (model) => { + const result = build(currentFor(sdxl, { refinerModel: refiner, refinerSteps: 35 }), { + refiner_model: model.key, + refiner_steps: 12, + }); + + expect(result.values.refinerModel).toEqual(refiner); + expect(result.values.refinerSteps).toBe(35); + expect(result.fields).toEqual([]); + expect(result.skipped[0]).toEqual({ detail: model.name, key: 'refiner_model', reason: 'incompatible' }); + expect(result.skipped[1]).toEqual({ detail: model.name, key: 'refiner_steps', reason: 'incompatible' }); + } + ); + + it('preserves the current refiner and tuning when a delivered identifier is absent from the frontend catalog', () => { + const current = currentFor(sdxl, { refinerModel: refiner, refinerSteps: 35 }); + const missingRefiner = build(current, { refiner_model: 'missing', refiner_steps: 12 }); + expect(missingRefiner.values).toMatchObject({ refinerModel: refiner, refinerSteps: 35 }); + expect(missingRefiner.fields).toEqual([]); + expect(missingRefiner.skipped).toEqual([ + { key: 'refiner_model', reason: 'unresolved', detail: 'missing' }, + { key: 'refiner_steps', reason: 'unresolved', detail: 'missing' }, + ]); + + const staleMainCatalog = build(current, { + model: 'server-resolved-main-key', + refiner_model: otherRefiner.key, + refiner_steps: 12, + }); + expect(staleMainCatalog.values).toMatchObject({ refinerModel: refiner, refinerSteps: 35 }); + expect(staleMainCatalog.fields).toEqual([]); + expect(staleMainCatalog.skipped.map(({ key }) => key)).toEqual(['model', 'refiner_model', 'refiner_steps']); + }); + + it.each([ + ['refiner_model', 42], + ['refiner_steps', 0], + ['refiner_steps', 1.5], + ['refiner_steps', '12'], + ['refiner_steps', Infinity], + ['refiner_cfg_scale', 0.9], + ['refiner_cfg_scale', NaN], + ['refiner_denoise_start', -0.1], + ['refiner_denoise_start', 1.1], + ['refiner_positive_aesthetic_score', 0], + ['refiner_positive_aesthetic_score', '6'], + ['refiner_negative_aesthetic_score', 11], + ])('rejects invalid %s value %s without changing the refiner', (key, value) => { + const current = currentFor(sdxl, { refinerModel: refiner }); + const result = build(current, { [key]: value }); + + expect(result.values).toEqual(current); + expect(result.fields).toEqual([]); + expect(result.skipped).toEqual([{ key, reason: 'invalid' }]); + }); + + it('accepts refiner range boundaries and rejects the metadata-only denoise key', () => { + const result = build(currentFor(sdxl), { + refiner_steps: 1, + refiner_cfg_scale: 1, + refiner_denoise_start: 0, + refiner_positive_aesthetic_score: 1, + refiner_negative_aesthetic_score: 10, + }); + expect(result.skipped).toEqual([]); + expect(result.values).toMatchObject({ + refinerSteps: 1, + refinerCfgScale: 1, + refinerStart: 0, + refinerPositiveAestheticScore: 1, + refinerNegativeAestheticScore: 10, + }); + expect(build(result.values, { refiner_denoise_start: 1 }).values.refinerStart).toBe(1); + expect(build(result.values, { refiner_start: 0.5 }).skipped).toEqual([ + { key: 'refiner_start', reason: 'unsupported' }, + ]); + }); + it('builds the LoRA list from installed models, dropping unknown, incompatible and duplicate entries', () => { const result = build(currentFor(sdxl), { loras: [ @@ -302,15 +507,10 @@ describe('buildRecallParametersSettings', () => { control_layers: [{ model_key: 'controlnet' }], denoise_strength: 0.5, positive_prompt: 'still applied', - refiner_model: 'refiner', }); expect(result.fields).toEqual(['prompts']); - expect(result.skipped.map((skip) => skip.key).sort()).toEqual([ - 'control_layers', - 'denoise_strength', - 'refiner_model', - ]); + expect(result.skipped.map((skip) => skip.key).sort()).toEqual(['control_layers', 'denoise_strength']); expect(result.skipped.every((skip) => skip.reason === 'unsupported')).toBe(true); expect(build(currentFor(sdxl), { control_layers: [] }).skipped).toEqual([]); diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.ts b/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.ts index afaf6fe57c2..251ed16e97c 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/recallParameters.ts @@ -28,8 +28,17 @@ import { hasModelDefaultVae, isLoraCompatibleWithModel, isLoraModelConfig, + isMainModelConfig, + isRefinerModel, + isRefinerSupported, isReferenceImageSupported, isVaeCompatibleWithGenerateModel, + MAX_REFINER_AESTHETIC_SCORE, + MAX_REFINER_START, + MIN_REFINER_AESTHETIC_SCORE, + MIN_REFINER_CFG_SCALE, + MIN_REFINER_START, + MIN_REFINER_STEPS, } from '@features/generation/settings'; import { @@ -69,7 +78,8 @@ export type RecallParameterField = | 'seamless' | 'clipSkip' | 'loras' - | 'referenceImages'; + | 'referenceImages' + | 'refiner'; export type RecallParameterSkipReason = 'unsupported' | 'unresolved' | 'incompatible' | 'invalid' | 'limit'; @@ -128,6 +138,12 @@ const HANDLED_KEYS = new Set([ 'negative_prompt', 'positive_prompt', 'reference_images', + 'refiner_model', + 'refiner_steps', + 'refiner_cfg_scale', + 'refiner_denoise_start', + 'refiner_positive_aesthetic_score', + 'refiner_negative_aesthetic_score', 'scheduler', 'seamless_x', 'seamless_y', @@ -187,6 +203,7 @@ export const buildRecallParametersSettings = ({ const isReset = (key: string) => has(key) && parameters[key] === null; const append = parameters.append === true; let values: GenerateWidgetValues = cloneGenerateWidgetValues(currentValues); + let mainModelResolved = true; for (const key of Object.keys(parameters)) { if (HANDLED_KEYS.has(key) || parameters[key] === null) { @@ -203,6 +220,7 @@ export const buildRecallParametersSettings = ({ if (!model) { skip('model', 'unresolved', parameters.model); + mainModelResolved = false; } else if (model.key !== values.model.key) { // Apply model defaults before the selector's compatibility transition for references, VAE, and components. const { settings } = getGenerateModelSelectionResult({ @@ -220,6 +238,11 @@ export const buildRecallParametersSettings = ({ } } + if (has('model') && parameters.model !== null && typeof parameters.model !== 'string') { + skip('model', 'invalid'); + mainModelResolved = false; + } + const defaults = getDefaultGenerateSettings(values.model); if (typeof parameters.vae_model === 'string') { @@ -258,6 +281,89 @@ export const buildRecallParametersSettings = ({ } }; + // Refiner tuning belongs to the requested model pair; a failed lookup must not retune the current pair. + let refinerFailure: Omit | null = mainModelResolved + ? null + : { reason: 'unresolved', detail: 'main model was not resolved' }; + + if (has('refiner_model')) { + if (refinerFailure) { + skip('refiner_model', refinerFailure.reason, refinerFailure.detail); + } else if (isReset('refiner_model')) { + values = { ...values, refinerModel: null }; + fields.add('refiner'); + } else if (typeof parameters.refiner_model !== 'string') { + skip('refiner_model', 'invalid'); + refinerFailure = { reason: 'invalid', detail: 'invalid refiner_model' }; + } else { + const identifier = parameters.refiner_model; + const refiner = + models.find((model) => model.key === identifier) ?? + models.find((model) => model.name === identifier && isRefinerModel(model)) ?? + models.find((model) => model.name === identifier); + + if (!refiner) { + skip('refiner_model', 'unresolved', parameters.refiner_model); + refinerFailure = { reason: 'unresolved', detail: parameters.refiner_model }; + } else if (!isMainModelConfig(refiner) || !isRefinerModel(refiner)) { + skip('refiner_model', 'incompatible', refiner.name); + refinerFailure = { reason: 'incompatible', detail: refiner.name }; + } else if (!isRefinerSupported(values.model)) { + skip('refiner_model', 'incompatible', values.model.name); + refinerFailure = { reason: 'incompatible', detail: values.model.name }; + } else { + values = { ...values, refinerModel: refiner }; + fields.add('refiner'); + } + } + } + + const refinerParameters = [ + { key: 'refiner_steps', valueKey: 'refinerSteps', min: MIN_REFINER_STEPS, max: Infinity, integer: true }, + { key: 'refiner_cfg_scale', valueKey: 'refinerCfgScale', min: MIN_REFINER_CFG_SCALE, max: Infinity }, + { key: 'refiner_denoise_start', valueKey: 'refinerStart', min: MIN_REFINER_START, max: MAX_REFINER_START }, + { + key: 'refiner_positive_aesthetic_score', + valueKey: 'refinerPositiveAestheticScore', + min: MIN_REFINER_AESTHETIC_SCORE, + max: MAX_REFINER_AESTHETIC_SCORE, + }, + { + key: 'refiner_negative_aesthetic_score', + valueKey: 'refinerNegativeAestheticScore', + min: MIN_REFINER_AESTHETIC_SCORE, + max: MAX_REFINER_AESTHETIC_SCORE, + }, + ] as const; + + for (const parameter of refinerParameters) { + const { key, max, min, valueKey } = parameter; + + if (!has(key)) { + continue; + } + if (refinerFailure) { + skip(key, refinerFailure.reason, refinerFailure.detail); + } else if (parameters[key] !== null && !isRefinerSupported(values.model)) { + skip(key, 'incompatible', values.model.name); + } else { + applyField( + key, + 'refiner', + () => { + const value = parameters[key]; + return isFiniteNumber(value) && + value >= min && + value <= max && + (!('integer' in parameter) || Number.isInteger(value)) + ? { [valueKey]: value } + : null; + }, + () => ({ [valueKey]: defaults[valueKey] }) + ); + } + } + applyField( 'positive_prompt', 'prompts', diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index da47761218a..80c9f9b140a 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -203,6 +203,13 @@ const createGenerateValues = (overrides: Partial = {}): Ge pidDecoderModel: null, gemma2EncoderModel: null, pidSteps: 4, + refinerModel: null, + refinerSteps: 20, + refinerCfgScale: 7.5, + refinerScheduler: 'euler', + refinerPositiveAestheticScore: 6, + refinerNegativeAestheticScore: 2.5, + refinerStart: 0.8, cfgRescaleMultiplier: 0, cfgScale: 7, clipEmbedModel: null,