diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index f74ced7dfd9..1a35799f95d 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -4,6 +4,7 @@ "active": "Active", "add": "Add", "all": "All", + "apply": "Apply", "archived": "Archived", "asc": "Asc", "assets": "Assets", @@ -15,6 +16,7 @@ "close": "Close", "configure": "Configure", "copy": "Copy", + "crop": "Crop", "clipboardBlocked": "Clipboard access was blocked by the browser.", "couldNotCopy": "Could not copy", "countOfTotal": "{{count}} of {{total}}", @@ -53,6 +55,7 @@ "required": "Required", "refresh": "Refresh", "reload": "Reload", + "reset": "Reset", "retry": "Retry", "saved": "Saved", "save": "Save", @@ -673,6 +676,34 @@ "promptTemplate": "Prompt Template", "promptTriggerOptions": "Prompt trigger options", "random": "Random", + "activeSteps": "Active steps", + "clearReferenceImages": "Remove all", + "collapseReferenceImage": "Collapse reference image", + "cropReferenceImage": "Crop reference image", + "cropReferenceImageHelp": "Drag the crop box to move it, or drag its handles to resize. Apply to create a cropped reference image.", + "disableReferenceImage": "Disable reference image", + "enableReferenceImage": "Enable reference image", + "expandReferenceImage": "Expand reference image", + "imageInfluence": "Image influence", + "mode": "Mode", + "modeBoth": "Both", + "modeComposition": "Composition", + "modeStyle": "Style", + "pullBboxIntoReferenceImage": "Pull Bbox into Reference Image", + "referenceImage": "Reference Image", + "referenceImageModel": "Reference image model", + "referenceImages": "Reference images", + "referenceImagesHelp": "Upload an image or drag images from Gallery here.", + "referenceImagesUnsupported": "Reference images are not supported by the selected model.", + "removeReferenceImage": "Remove reference image", + "styleVariant": "Style variant", + "styleVariantPrecise": "Precise", + "styleVariantPreciseDesc": "Applies a precise visual style, eliminating subject influence.", + "styleVariantStandard": "Standard", + "styleVariantStandardDesc": "Applies visual style (colors, textures) without considering its layout. Previously called Style Only.", + "styleVariantStrong": "Strong", + "styleVariantStrongDesc": "Applies a strong visual style, with a slightly reduced composition influence.", + "useSize": "Use size", "removeConcept": "Remove concept", "removeConceptNamed": "Remove {{name}}", "resizeNegativePrompt": "Resize negative prompt", @@ -709,8 +740,10 @@ "useModelDefaultWidth": "Use model default width", "usePrompt": "Use prompt", "usingBundledVae": "Using the VAE bundled with the model.", + "upload": "Upload", "vae": "VAE", "vaePrecision": "VAE precision", + "weight": "Weight", "width": "Width", "xAxis": "X axis", "yAxis": "Y axis" diff --git a/invokeai/frontend/webv2/src/theme/recipes.ts b/invokeai/frontend/webv2/src/theme/recipes.ts index 812a5b4e035..5ac5325db08 100644 --- a/invokeai/frontend/webv2/src/theme/recipes.ts +++ b/invokeai/frontend/webv2/src/theme/recipes.ts @@ -53,7 +53,11 @@ const dropdownContent = { const dropdownItem = { _highlighted: { bg: 'bg.subtle' }, - _focusVisible: { outline: '2px solid', outlineColor: 'accent.solid', outlineOffset: '-2px' }, + _focusVisible: { + outline: '2px solid', + outlineColor: 'accent.solid', + outlineOffset: '-2px', + }, }; const dropdownGroupLabel = { @@ -223,15 +227,27 @@ export const rowRecipe = defineRecipe({ transition: 'background var(--wb-motion-duration-fast) ease, color var(--wb-motion-duration-fast) ease', w: 'full', _hover: { bg: 'bg.muted' }, - _focusVisible: { outline: '2px solid', outlineColor: 'accent.solid', outlineOffset: '-2px' }, + _focusVisible: { + outline: '2px solid', + outlineColor: 'accent.solid', + outlineOffset: '-2px', + }, _disabled: { cursor: 'not-allowed', opacity: 0.5 }, }, variants: { active: { none: {}, muted: { bg: 'bg.muted' }, - brand: { bg: 'brand.subtle', color: 'brand.fg', _hover: { bg: 'brand.subtle' } }, - accent: { bg: 'accent.solid', color: 'accent.contrast', _hover: { bg: 'accent.solid' } }, + brand: { + bg: 'brand.subtle', + color: 'brand.fg', + _hover: { bg: 'brand.subtle' }, + }, + accent: { + bg: 'accent.solid', + color: 'accent.contrast', + _hover: { bg: 'accent.solid' }, + }, }, }, defaultVariants: { active: 'none' }, @@ -270,8 +286,7 @@ export const fieldLabelRecipe = defineRecipe({ color: 'fg.muted', fontSize: '2xs', fontWeight: '600', - letterSpacing: '0.02em', - textTransform: 'uppercase', + letterSpacing: '0.03em', }, }); @@ -295,7 +310,11 @@ export const themeCardRecipe = defineSlotRecipe({ transition: 'border-color var(--wb-motion-duration-fast) ease, background var(--wb-motion-duration-fast) ease, transform var(--wb-motion-duration-fast) ease', _hover: { borderColor: 'border.emphasized' }, - _focusVisible: { outline: '2px solid', outlineColor: 'accent.solid', outlineOffset: '2px' }, + _focusVisible: { + outline: '2px solid', + outlineColor: 'accent.solid', + outlineOffset: '2px', + }, }, preview: { borderColor: 'border.subtle', @@ -306,7 +325,12 @@ export const themeCardRecipe = defineSlotRecipe({ overflow: 'hidden', }, swatch: { flex: '1' }, - body: { alignItems: 'flex-start', display: 'flex', flexDirection: 'column', gap: '0.5' }, + body: { + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + gap: '0.5', + }, name: { color: 'fg', fontSize: 'sm', fontWeight: '600' }, description: { color: 'fg.subtle', fontSize: '2xs', lineHeight: '1.3' }, indicator: { diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx index 153073fa9c4..32fa33e702d 100644 --- a/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Field.tsx @@ -1,7 +1,8 @@ import type { ReactNode } from 'react'; -import { HStack, Stack, Text, useRecipe, type StackProps } from '@chakra-ui/react'; +import { Field as ChakraField, HStack, Stack, Text, useRecipe, type StackProps } from '@chakra-ui/react'; import { fieldLabelRecipe } from '@theme/recipes'; +import { useMemo } from 'react'; /** * The shared, theme-aware uppercase field label. Backed by `fieldLabelRecipe` so @@ -18,31 +19,106 @@ export const FieldLabel = ({ children }: { children: ReactNode }) => { ); }; -export interface FieldProps extends Omit { +export interface FieldProps extends Omit { + id?: string; label: string; labelEnd?: ReactNode; - /** Validation error, shown in place of `helpText`. Mark the control itself invalid via `aria-invalid`. */ + disabled?: boolean; + orientation?: 'horizontal' | 'vertical'; + invalid?: boolean; + readOnly?: boolean; + required?: boolean; + /** Validation error, shown in place of `helpText`; also marks the field invalid by default. */ error?: string | null; helpText?: string; children: ReactNode; } /** A labelled form field: an uppercase label stacked above its control, with an optional help/error line below. */ -export const Field = ({ children, error, helpText, label, labelEnd, ...rest }: FieldProps) => ( - - - {label} - {labelEnd} - - {children} - {error ? ( - - {error} - - ) : helpText ? ( - - {helpText} - - ) : null} - -); +export const Field = ({ + children, + disabled, + error, + helpText, + id, + invalid, + label, + labelEnd, + orientation = 'vertical', + readOnly, + required, + ...rest +}: FieldProps) => { + const recipe = useRecipe({ recipe: fieldLabelRecipe }); + const isHorizontal = orientation === 'horizontal'; + const isInvalid = invalid ?? Boolean(error); + const ids = useMemo( + () => + id + ? { + errorText: `${id}-error`, + helperText: `${id}-help`, + label: `${id}-label`, + } + : undefined, + [id] + ); + const labelContent = {label}; + const message = error ? ( + + {error} + + ) : helpText ? ( + + {helpText} + + ) : null; + + if (!isHorizontal) { + return ( + + + + {labelContent} + {labelEnd} + + {children} + {message} + + + ); + } + + return ( + + + + {labelContent} + {labelEnd} + + + {children} + {message} + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Select.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Select.tsx new file mode 100644 index 00000000000..1c917eca753 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Select.tsx @@ -0,0 +1,77 @@ +import type { + CollectionItem, + SelectContentProps, + SelectIndicatorGroupProps, + SelectPositionerProps, + SelectRootProps, + SelectTriggerProps, + SelectValueTextProps, +} from '@chakra-ui/react'; +import type { Key, ReactNode } from 'react'; + +import { Portal, Select as ChakraSelect } from '@chakra-ui/react'; + +const getDefaultItemKey = (item: T, index: number): Key => { + const keyedItem = item as { id?: Key; value?: Key }; + + return keyedItem.value ?? keyedItem.id ?? index; +}; + +const renderDefaultItem = (item: T): ReactNode => { + const labelledItem = item as { label?: ReactNode; value?: ReactNode }; + + return labelledItem.label ?? labelledItem.value; +}; + +export interface SelectProps extends Omit, 'children'> { + contentProps?: SelectContentProps; + getItemKey?: (item: T, index: number) => Key; + indicatorGroupProps?: SelectIndicatorGroupProps; + itemIndicator?: boolean; + portalled?: boolean; + positionerProps?: SelectPositionerProps; + renderItem?: (item: T) => ReactNode; + triggerProps?: SelectTriggerProps; + valueText?: ReactNode; + valueTextProps?: SelectValueTextProps; +} + +/** Workbench Select: Chakra's custom Select with the standard trigger, portal, and item markup pre-wired. */ +export const Select = ({ + collection, + contentProps, + getItemKey = getDefaultItemKey, + indicatorGroupProps, + itemIndicator = true, + portalled = true, + positionerProps, + renderItem = renderDefaultItem, + triggerProps, + valueText, + valueTextProps, + ...rootProps +}: SelectProps) => ( + + + + + {valueText} + + + + + + + + + {collection.items.map((item, index) => ( + + {renderItem(item)} + {itemIndicator ? : null} + + ))} + + + + +); diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Slider.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Slider.tsx new file mode 100644 index 00000000000..97de382b885 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Slider.tsx @@ -0,0 +1,172 @@ +import type { ReactNode } from 'react'; + +import { Portal, Slider as ChakraSlider, Tooltip as ChakraTooltip } from '@chakra-ui/react'; +import { useCallback, useMemo, useState } from 'react'; + +export type SliderMark = number | { label: ReactNode; value: number }; + +export interface SliderProps extends Omit { + /** Formats a thumb's value for its tooltip. */ + formatValue?: (value: number, index: number) => string; + /** Marks under the track, revealed while the slider is hovered or dragged. */ + marks?: SliderMark[]; + /** Show the formatted value above each thumb while the slider is hovered or a thumb is dragged. */ + withThumbTooltip?: boolean; +} + +// Marks stay mounted so the slider keeps a stable height, but only fade in +// while the slider is hovered or dragged. This is driven by the primitives' own +// DOM state: the root carries `data-dragging` for the whole gesture. +const INTERACTION_REVEAL_CSS = { + '& [data-part="marker-group"]': { + opacity: 0, + transition: 'opacity var(--wb-motion-duration-fast)', + }, + '&:hover [data-part="marker-group"], &[data-dragging] [data-part="marker-group"]': { + opacity: 1, + }, +}; + +const RANGE_MARKER_CSS = { + '& [data-part="marker"][data-state="under-value"], & [data-part="marker"][data-state="over-value"]': { + '--marker-bg': 'white', + }, + '& [data-part="marker"][data-state="at-value"]': { + '--marker-bg': 'colors.bg', + }, +}; + +const TOOLTIP_POSITIONING = { + gutter: 4, + placement: 'top', + strategy: 'fixed', +} as const; + +const formatValueDefault = (value: number): string => String(value); + +const SliderThumb = ({ index, isTooltipOpen, label }: { index: number; isTooltipOpen: boolean; label: string }) => ( + + + + + + + + + {label} + + + +); + +/** + * Workbench slider. Wraps the Chakra slider primitives with the conveniences + * the legacy (Chakra v2) slider had: a formatted value tooltip on each thumb + * that shows while the slider is hovered and stays up through drags, and track + * marks that reveal on hover so dense forms stay quiet until the slider is + * being used. Renders one thumb per entry in `value`/`defaultValue`, so it + * covers single and range sliders. + */ +export const Slider = ({ + formatValue = formatValueDefault, + marks, + withThumbTooltip = true, + ...rootProps +}: SliderProps) => { + const { + css, + onBlurCapture, + onFocusCapture, + onPointerDown, + onPointerEnter, + onPointerLeave, + onPointerUp, + onValueChangeEnd, + ...rest + } = rootProps; + const values = rest.value ?? rest.defaultValue ?? []; + const [isHovering, setIsHovering] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [isDragging, setIsDragging] = useState(false); + + const rootCss = useMemo(() => { + const baseCss = values.length > 1 ? [INTERACTION_REVEAL_CSS, RANGE_MARKER_CSS] : [INTERACTION_REVEAL_CSS]; + + return css ? [...baseCss, css] : baseCss; + }, [css, values.length]); + const isTooltipOpen = withThumbTooltip && (isHovering || isFocused || isDragging); + const handleBlurCapture: NonNullable = useCallback( + (event) => { + setIsFocused(false); + onBlurCapture?.(event); + }, + [onBlurCapture] + ); + const handleFocusCapture: NonNullable = useCallback( + (event) => { + setIsFocused(true); + onFocusCapture?.(event); + }, + [onFocusCapture] + ); + const handlePointerDown: NonNullable = useCallback( + (event) => { + setIsDragging(true); + onPointerDown?.(event); + }, + [onPointerDown] + ); + const handlePointerEnter: NonNullable = useCallback( + (event) => { + setIsHovering(true); + onPointerEnter?.(event); + }, + [onPointerEnter] + ); + const handlePointerLeave: NonNullable = useCallback( + (event) => { + setIsHovering(false); + onPointerLeave?.(event); + }, + [onPointerLeave] + ); + const handlePointerUp: NonNullable = useCallback( + (event) => { + setIsDragging(false); + onPointerUp?.(event); + }, + [onPointerUp] + ); + const handleValueChangeEnd: NonNullable = useCallback( + (details) => { + setIsDragging(false); + onValueChangeEnd?.(details); + }, + [onValueChangeEnd] + ); + + return ( + + + + + + {values.map((value, index) => ( + + ))} + + {marks?.length ? : null} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ToggleDot.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ToggleDot.tsx new file mode 100644 index 00000000000..6a342bce800 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ToggleDot.tsx @@ -0,0 +1,43 @@ +import type { BoxProps } from '@chakra-ui/react'; +import type { ReactNode } from 'react'; + +import { Box } from '@chakra-ui/react'; +import { useCallback } from 'react'; + +import { Tooltip } from './Tooltip'; + +const TOGGLE_DOT_HOVER_STYLES = { borderColor: 'fg.muted' }; +const TOGGLE_DOT_CHECKED_HOVER_STYLES = { bg: 'accent.emphasized', borderColor: 'accent.emphasized' }; + +export interface ToggleDotProps extends Omit { + checked: boolean; + label: string; + tooltip?: ReactNode; + onCheckedChange: (checked: boolean) => void; +} + +/** Small pressable dot for compact enabled/disabled state toggles. */ +export const ToggleDot = ({ checked, label, onCheckedChange, tooltip = label, ...rest }: ToggleDotProps) => { + const handleClick = useCallback(() => onCheckedChange(!checked), [checked, onCheckedChange]); + const dot = ( + + ); + + return tooltip ? {dot} : dot; +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/index.ts b/invokeai/frontend/webv2/src/workbench/components/ui/index.ts index 48af2a50f78..0596ba3d29a 100644 --- a/invokeai/frontend/webv2/src/workbench/components/ui/index.ts +++ b/invokeai/frontend/webv2/src/workbench/components/ui/index.ts @@ -8,7 +8,10 @@ export * from './RenameDialog'; export * from './ResizableTextarea'; export * from './Row'; export * from './Scrollable'; +export * from './Select'; +export * from './Slider'; export * from './Tabs'; export * from './toaster'; export * from './Toolbar'; +export * from './ToggleDot'; export * from './Tooltip'; diff --git a/invokeai/frontend/webv2/src/workbench/gallery/api.ts b/invokeai/frontend/webv2/src/workbench/gallery/api.ts index e816fe0ba53..7575fedaec6 100644 --- a/invokeai/frontend/webv2/src/workbench/gallery/api.ts +++ b/invokeai/frontend/webv2/src/workbench/gallery/api.ts @@ -209,7 +209,7 @@ export interface GalleryImagesPage { total: number; } -const getGalleryImagesByNames = async (imageNames: string[]): Promise => { +export const getGalleryImagesByNames = async (imageNames: string[]): Promise => { if (imageNames.length === 0) { return []; } @@ -431,11 +431,15 @@ export const downloadGalleryArchive = async ({ throw new Error('Timed out preparing the download archive.'); }; -export const uploadGalleryImage = async (file: File, boardId: string): Promise => { +export const uploadGalleryImage = async ( + file: File, + boardId: string, + options: { isIntermediate?: boolean } = {} +): Promise => { const query = toSearchParams({ board_id: boardId === 'none' || isDateBoardId(boardId) ? undefined : boardId, image_category: 'user', - is_intermediate: false, + is_intermediate: options.isIntermediate ?? false, }); const body = new FormData(); body.append('file', file); diff --git a/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.test.ts b/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.test.ts index 2bdf5859257..d53f49d8e84 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.test.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.test.ts @@ -19,9 +19,11 @@ import { getGenerationModelAvailabilityReasons, getGenerationModelPolicy, getGenerationValidationReasons, + getMaxReferenceImages, getPromptPolicy, getSettingsWithCompatibleModelSelections, getSettingsWithModelDefaults, + isReferenceImageSupported, isSupportedGenerateModel, SUPPORTED_GENERATE_BASES, } from './baseGenerationPolicies'; @@ -69,6 +71,27 @@ const qwenImageVae: VaeModelConfig = { base: 'qwen-image', key: 'qwen-vae', name const sdxlVae: VaeModelConfig = { base: 'sdxl', key: 'sdxl-vae', name: 'SDXL VAE', type: 'vae' }; const sd1Lora: LoraModelConfig = { base: 'sd-1', key: 'sd1-lora', name: 'SD 1 LoRA', type: 'lora' }; const sdxlLora: LoraModelConfig = { base: 'sdxl', key: 'sdxl-lora', name: 'SDXL LoRA', type: 'lora' }; +const sd1IpAdapter: ComponentModelConfig = { + base: 'sd-1', + key: 'sd1-ip-adapter', + name: 'SD 1 IP Adapter', + type: 'ip_adapter', +}; +const sdxlIpAdapter: ComponentModelConfig = { + base: 'sdxl', + key: 'sdxl-ip-adapter', + name: 'SDXL IP Adapter', + type: 'ip_adapter', +}; +const referenceImage = { + height: 768, + imageName: 'reference.png', + imageUrl: '/api/v1/images/i/reference.png/full', + queuedAt: '2026-01-01T00:00:00.000Z', + sourceQueueItemId: 'backend-gallery', + thumbnailUrl: '/api/v1/images/i/reference.png/thumbnail', + width: 512, +}; const externalModel: GenerateModelConfig = { base: 'external', capabilities: { modes: ['txt2img'], supports_negative_prompt: false, supports_seed: false }, @@ -401,7 +424,7 @@ describe('component policies', () => { vae: sdxlVae, }); - const result = getSettingsWithCompatibleModelSelections(settings, model); + const result = getSettingsWithCompatibleModelSelections(settings, model, []); expect(result.settings.modelKey).toBe(model.key); expect(result.settings.loras.map((lora) => lora.model.key)).toEqual(['sdxl-lora']); @@ -416,7 +439,7 @@ describe('component policies', () => { it('reconciles dimensions to the new model grid when the selected model changes', () => { const model = createModel('cogview4'); const settings = createSettings(createModel('sdxl'), { height: 520, width: 520 }); - const result = getSettingsWithCompatibleModelSelections(settings, model); + const result = getSettingsWithCompatibleModelSelections(settings, model, []); expect(result.settings.width).toBe(512); expect(result.settings.height).toBe(512); @@ -462,10 +485,280 @@ describe('component policies', () => { vae: flux2Vae, }); - const result = getSettingsWithCompatibleModelSelections(settings, model); + const result = getSettingsWithCompatibleModelSelections(settings, model, []); expect(result.settings.qwen3EncoderModel).toBeNull(); expect(result.settings.vae).toEqual(flux2Vae); expect(result.clearedLabels).toEqual(['Qwen3 Encoder']); }); + + it('requires enabled IP Adapter reference images to have an image and compatible adapter model', () => { + const model = createModel('sd-1'); + + expect( + getGenerationValidationReasons( + model, + createSettings(model, { + referenceImages: [ + { + id: 'ref-1', + isEnabled: true, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: null, + method: 'full', + model: sd1IpAdapter, + type: 'ip_adapter', + weight: 1, + }, + }, + ], + }) + ) + ).toContain('Reference Image #1 needs an image.'); + + expect( + getGenerationValidationReasons( + model, + createSettings(model, { + referenceImages: [ + { + id: 'ref-1', + isEnabled: true, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: referenceImage, + method: 'full', + model: null, + type: 'ip_adapter', + weight: 1, + }, + }, + ], + }) + ) + ).toContain('Reference Image #1 needs a compatible IP Adapter model.'); + }); + + it('clears reference images when switching to a model without reference image support', () => { + const settings = createSettings(createModel('sdxl'), { + referenceImages: [ + { + id: 'ref-1', + isEnabled: true, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: referenceImage, + method: 'full', + model: sdxlIpAdapter, + type: 'ip_adapter', + weight: 1, + }, + }, + ], + }); + + const result = getSettingsWithCompatibleModelSelections(settings, createModel('cogview4'), [sdxlIpAdapter]); + + expect(result.settings.referenceImages).toEqual([]); + expect(result.clearedLabels).toContain('Reference Images'); + }); + + it('re-targets reference image configs to the new model base, keeping the image and enabled state', () => { + const settings = createSettings(createModel('sdxl'), { + referenceImages: [ + { + id: 'ref-1', + isEnabled: false, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: referenceImage, + method: 'style', + model: sdxlIpAdapter, + type: 'ip_adapter', + weight: 0.5, + }, + }, + ], + }); + + const result = getSettingsWithCompatibleModelSelections(settings, createModel('sd-1'), [ + sd1IpAdapter, + sdxlIpAdapter, + ]); + + expect(result.settings.referenceImages).toEqual([ + { + id: 'ref-1', + isEnabled: false, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: referenceImage, + method: 'full', + model: sd1IpAdapter, + type: 'ip_adapter', + weight: 1, + }, + }, + ]); + expect(result.clearedLabels).toContain('Reference Images'); + }); + + it('re-targets reference images across a flux2 -> sdxl -> flux2 round trip', () => { + const flux2Model = createModel('flux2'); + const sdxlModel = createModel('sdxl'); + const settings = createSettings(flux2Model, { + referenceImages: [ + { id: 'ref-1', isEnabled: true, config: { image: referenceImage, type: 'flux2_reference_image' } }, + ], + }); + + const toSdxl = getSettingsWithCompatibleModelSelections(settings, sdxlModel, [sdxlIpAdapter]); + + expect(toSdxl.settings.referenceImages[0]?.config).toMatchObject({ + image: referenceImage, + model: sdxlIpAdapter, + type: 'ip_adapter', + }); + + const backToFlux2 = getSettingsWithCompatibleModelSelections(toSdxl.settings, flux2Model, [sdxlIpAdapter]); + + expect(backToFlux2.settings.referenceImages).toEqual([ + { id: 'ref-1', isEnabled: true, config: { image: referenceImage, type: 'flux2_reference_image' } }, + ]); + }); + + it('keeps compatible reference images untouched when the selected model changes', () => { + const referenceImages: GenerateSettings['referenceImages'] = [ + { + id: 'ref-1', + isEnabled: true, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: referenceImage, + method: 'full', + model: sdxlIpAdapter, + type: 'ip_adapter', + weight: 1, + }, + }, + ]; + const settings = createSettings(createModel('sdxl'), { referenceImages }); + + const result = getSettingsWithCompatibleModelSelections(settings, createModel('sdxl', { key: 'sdxl-other' }), [ + sdxlIpAdapter, + ]); + + expect(result.settings.referenceImages).toBe(referenceImages); + expect(result.clearedLabels).not.toContain('Reference Images'); + }); + + it('reports reference image support per model', () => { + expect(isReferenceImageSupported(createModel('sdxl'))).toBe(true); + expect(isReferenceImageSupported(createModel('cogview4'))).toBe(false); + expect(isReferenceImageSupported(createModel('qwen-image'))).toBe(false); + expect(isReferenceImageSupported(createModel('qwen-image', { variant: 'edit' }))).toBe(true); + expect(isReferenceImageSupported(externalModel)).toBe(false); + expect( + isReferenceImageSupported({ + ...externalModel, + capabilities: { ...externalModel.capabilities, supports_reference_images: true }, + }) + ).toBe(true); + expect(isReferenceImageSupported(undefined)).toBe(false); + }); + + it('derives the reference image limit from external provider capabilities', () => { + expect(getMaxReferenceImages(createModel('sdxl'))).toBe(5); + expect(getMaxReferenceImages(createModel('cogview4'))).toBe(0); + expect( + getMaxReferenceImages({ + ...externalModel, + capabilities: { ...externalModel.capabilities, max_reference_images: 14, supports_reference_images: true }, + }) + ).toBe(14); + expect( + getMaxReferenceImages({ + ...externalModel, + capabilities: { ...externalModel.capabilities, supports_reference_images: true }, + }) + ).toBe(5); + }); + + it('rejects unsupported reference image configs for the selected model', () => { + const model = createModel('sd-3'); + + expect( + getGenerationValidationReasons( + model, + createSettings(model, { + referenceImages: [ + { id: 'ref-1', isEnabled: true, config: { image: referenceImage, type: 'flux2_reference_image' } }, + ], + }) + ) + ).toContain('Reference Image #1 is not supported by sd-3 model.'); + }); + + it('reports missing reference image model dependencies', () => { + const model = createModel('sd-1'); + + expect( + getGenerationModelAvailabilityReasons( + model, + createSettings(model, { + referenceImages: [ + { + id: 'ref-1', + isEnabled: true, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: referenceImage, + method: 'full', + model: sd1IpAdapter, + type: 'ip_adapter', + weight: 1, + }, + }, + ], + }), + [model as never] + ) + ).toContain('Reference Image model "SD 1 IP Adapter" is no longer installed.'); + }); + + it('ignores missing reference image model dependencies when the reference image is disabled', () => { + const model = createModel('sd-1'); + + expect( + getGenerationModelAvailabilityReasons( + model, + createSettings(model, { + referenceImages: [ + { + id: 'ref-1', + isEnabled: false, + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image: referenceImage, + method: 'full', + model: sd1IpAdapter, + type: 'ip_adapter', + weight: 1, + }, + }, + ], + }), + [model as never] + ) + ).toEqual([]); + }); }); diff --git a/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.ts b/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.ts index f37542f170a..dd35d8c705f 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/baseGenerationPolicies.ts @@ -1,7 +1,15 @@ import type { KnownModelBase } from '@workbench/models/baseIdentity'; import type { ModelConfig, ModelTaxonomyType } from '@workbench/models/types'; -import type { GenerateModelConfig, GenerateSettings, MainModelConfig, VaePrecision } from './types'; +import type { + GenerateModelConfig, + GenerateReferenceImage, + GenerateReferenceImageAsset, + GenerateReferenceImageConfig, + GenerateSettings, + MainModelConfig, + VaePrecision, +} from './types'; import { getCompatibleDiffusersComponentSource, @@ -17,6 +25,7 @@ import { } from './componentCompatibility'; import { clampDimension, + DEFAULT_REFERENCE_IMAGE_LIMIT, deriveAspectRatioId, isLoraCompatibleWithModel, MAX_DIMENSION, @@ -518,6 +527,7 @@ export const getDefaultGenerateSettings = (model?: GenerateModelConfig): Generat positivePromptHeightPx: 96, qwen3EncoderModel: null, qwenVLEncoderModel: null, + referenceImages: [], scheduler: defaults.scheduler, seamlessXAxis: false, seamlessYAxis: false, @@ -944,9 +954,166 @@ const isSelectedComponentCompatible = ( return !slotPolicy.filter || slotPolicy.filter(value as ModelConfig, getComponentPolicyContext(model, settings)); }; +type ReferenceModelCandidate = { base: string; key: string; name: string; type: string }; + +const isFluxKontextModel = (model: GenerateModelConfig | undefined): model is MainModelConfig => + Boolean( + model && + model.type !== 'external_image_generator' && + model.base === 'flux' && + model.name.toLowerCase().includes('kontext') + ); + +/** + * Whether the model can consume reference images at all. Kept strict so the UI + * only offers reference images where `getGenerationValidationReasons` would + * accept them (e.g. Qwen Image requires the `edit` variant). + */ +export const isReferenceImageSupported = (model: GenerateModelConfig | undefined): boolean => { + if (!model) { + return false; + } + + if (model.type === 'external_image_generator') { + return model.capabilities?.supports_reference_images === true; + } + + if (model.base === 'qwen-image') { + return model.variant === 'edit'; + } + + return ['flux', 'flux2', 'sd-1', 'sdxl'].includes(model.base); +}; + +export const getMaxReferenceImages = (model: GenerateModelConfig | undefined): number => { + if (!model || !isReferenceImageSupported(model)) { + return 0; + } + + if (model.type === 'external_image_generator' && typeof model.capabilities?.max_reference_images === 'number') { + return Math.max(0, model.capabilities.max_reference_images); + } + + return DEFAULT_REFERENCE_IMAGE_LIMIT; +}; + +export const createReferenceImageId = (): string => + `reference_image_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +export const getDefaultReferenceImageConfig = ( + model: GenerateModelConfig | undefined, + models: readonly ReferenceModelCandidate[], + image: GenerateReferenceImageAsset | null = null +): GenerateReferenceImageConfig => { + const modelBase: string | undefined = model?.base; + + if (model?.type === 'external_image_generator') { + return { image, type: 'external_reference_image' }; + } + + if (modelBase === 'flux2') { + return { image, type: 'flux2_reference_image' }; + } + + if (modelBase === 'qwen-image') { + return { image, type: 'qwen_image_reference_image' }; + } + + if (isFluxKontextModel(model)) { + return { image, model, type: 'flux_kontext_reference_image' }; + } + + const adapterModel = + models.find((candidate) => candidate.type === 'ip_adapter' && candidate.base === modelBase) ?? null; + + return { + beginEndStepPct: [0, 1], + clipVisionModel: modelBase === 'flux' ? 'ViT-L' : 'ViT-H', + image, + method: 'full', + model: adapterModel, + type: 'ip_adapter', + weight: 1, + }; +}; + +const getReferenceImageConfigSupported = ( + model: GenerateModelConfig, + referenceImage: GenerateReferenceImage +): boolean => { + switch (referenceImage.config.type) { + case 'external_reference_image': + return model.type === 'external_image_generator' && model.capabilities?.supports_reference_images === true; + case 'flux2_reference_image': + return model.type !== 'external_image_generator' && model.base === 'flux2'; + case 'qwen_image_reference_image': + return model.type !== 'external_image_generator' && model.base === 'qwen-image' && model.variant === 'edit'; + case 'flux_kontext_reference_image': + return isFluxKontextModel(model); + case 'flux_redux': + return model.type !== 'external_image_generator' && model.base === 'flux'; + case 'ip_adapter': + return model.type !== 'external_image_generator' && ['sd-1', 'sdxl', 'flux'].includes(model.base); + } +}; + +export const isReferenceImageCompatibleWithModel = ( + model: GenerateModelConfig, + referenceImage: GenerateReferenceImage +): boolean => { + if (!getReferenceImageConfigSupported(model, referenceImage)) { + return false; + } + + const config = referenceImage.config; + + if ((config.type === 'ip_adapter' || config.type === 'flux_redux') && config.model) { + return config.model.base === model.base; + } + + if (config.type === 'flux_kontext_reference_image' && config.model) { + return config.model.key === model.key; + } + + return true; +}; + +/** + * Reference images carried over to `model`: dropped entirely when the model + * does not support reference images, and re-targeted to a default config for + * the new model (keeping the image and enabled state) when incompatible. + * Returns the input array untouched when nothing changes. + */ +export const getCompatibleReferenceImages = ( + referenceImages: GenerateReferenceImage[], + model: GenerateModelConfig, + models: readonly ReferenceModelCandidate[] +): GenerateReferenceImage[] => { + if (referenceImages.length === 0) { + return referenceImages; + } + + if (!isReferenceImageSupported(model)) { + return []; + } + + let didChange = false; + const next = referenceImages.map((referenceImage) => { + if (isReferenceImageCompatibleWithModel(model, referenceImage)) { + return referenceImage; + } + + didChange = true; + return { ...referenceImage, config: getDefaultReferenceImageConfig(model, models, referenceImage.config.image) }; + }); + + return didChange ? next : referenceImages; +}; + export const getSettingsWithCompatibleModelSelections = ( settings: GenerateSettings, - model: GenerateModelConfig + model: GenerateModelConfig, + models: readonly ReferenceModelCandidate[] ): GenerateSettingsCompatibilityResult => { const nextSettings: GenerateSettings = { ...settings, modelKey: model.key }; const clearedLabels: string[] = []; @@ -968,6 +1135,13 @@ export const getSettingsWithCompatibleModelSelections = ( addClearedLabel(clearedLabels, 'LoRAs'); } + const compatibleReferenceImages = getCompatibleReferenceImages(settings.referenceImages, model, models); + + if (compatibleReferenceImages !== settings.referenceImages) { + nextSettings.referenceImages = compatibleReferenceImages; + addClearedLabel(clearedLabels, 'Reference Images'); + } + const policy = getComponentSectionPolicy(model, nextSettings); const slotsByKey = new Map(policy.slots.map((slotPolicy) => [slotPolicy.key, slotPolicy])); const uiPolicy = getGenerationUiPolicy(model, nextSettings); @@ -1051,6 +1225,18 @@ export const getGenerationModelAvailabilityReasons = ( } } + for (const referenceImage of settings.referenceImages) { + if (!referenceImage.isEnabled) { + continue; + } + + const config = referenceImage.config; + + if ('model' in config && config.model && !hasModelKey(models, config.model.key, config.model.type)) { + reasons.push(`Reference Image model "${config.model.name}" is no longer installed.`); + } + } + return reasons; }; @@ -1073,12 +1259,61 @@ const getDimensionValidationReasons = (model: GenerateModelConfig, settings: Gen return reasons; }; +const getReferenceImageValidationReasons = (model: GenerateModelConfig, settings: GenerateSettings): string[] => { + const reasons: string[] = []; + const enabled = settings.referenceImages.filter((referenceImage) => referenceImage.isEnabled); + const maxReferenceImages = + model.type === 'external_image_generator' && typeof model.capabilities?.max_reference_images === 'number' + ? model.capabilities.max_reference_images + : DEFAULT_REFERENCE_IMAGE_LIMIT; + + if (enabled.length > maxReferenceImages) { + reasons.push(`Generate supports at most ${maxReferenceImages} reference images for ${model.name}.`); + } + + enabled.forEach((referenceImage, index) => { + const prefix = `Reference Image #${index + 1}`; + + if (!getReferenceImageConfigSupported(model, referenceImage)) { + reasons.push(`${prefix} is not supported by ${model.base} model.`); + return; + } + + if (!referenceImage.config.image) { + reasons.push(`${prefix} needs an image.`); + } + + if (referenceImage.config.type === 'ip_adapter') { + if (!referenceImage.config.model || referenceImage.config.model.base !== model.base) { + reasons.push(`${prefix} needs a compatible IP Adapter model.`); + } + } + + if (referenceImage.config.type === 'flux_redux') { + if (!referenceImage.config.model || referenceImage.config.model.base !== model.base) { + reasons.push(`${prefix} needs a compatible FLUX Redux model.`); + } + } + + if (referenceImage.config.type === 'flux_kontext_reference_image') { + if (!referenceImage.config.model || referenceImage.config.model.key !== model.key) { + reasons.push(`${prefix} needs the selected FLUX Kontext model.`); + } + } + }); + + return reasons; +}; + export const getGenerationValidationReasons = (model: GenerateModelConfig, settings: GenerateSettings): string[] => { if (!isSupportedGenerateModel(model)) { return ['Generate needs a supported model before it can be invoked.']; } - const reasons = getDimensionValidationReasons(model, settings); + const reasons = [ + ...getDimensionValidationReasons(model, settings), + ...getReferenceImageValidationReasons(model, settings), + ]; if (model.type === 'external_image_generator') { if (model.capabilities?.modes && !model.capabilities.modes.includes('txt2img')) { diff --git a/invokeai/frontend/webv2/src/workbench/generation/graph.test.ts b/invokeai/frontend/webv2/src/workbench/generation/graph.test.ts index 2518b5a365f..70747801e80 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/graph.test.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/graph.test.ts @@ -31,6 +31,13 @@ const flux2Klein9bModel: MainModelConfig = { type: 'main', variant: 'klein_9b', }; +const fluxKontextModel: MainModelConfig = { + base: 'flux', + format: 'diffusers', + key: 'flux-kontext', + name: 'FLUX Kontext dev', + type: 'main', +}; const incompatibleFlux2Source: MainModelConfig = { base: 'flux2', format: 'diffusers', @@ -68,6 +75,18 @@ const sd1Lora: LoraModelConfig = { base: 'sd-1', key: 'sd1-lora', name: 'SD 1.5 const sd2Lora: LoraModelConfig = { base: 'sd-2', key: 'sd2-lora', name: 'SD 2 LoRA', type: 'lora' }; const sdxlLora: LoraModelConfig = { base: 'sdxl', key: 'sdxl-lora', name: 'SDXL LoRA', type: 'lora' }; const fluxLora: LoraModelConfig = { base: 'flux', key: 'flux-lora', name: 'FLUX LoRA', type: 'lora' }; +const sd1IpAdapter: ComponentModelConfig = { + base: 'sd-1', + key: 'sd1-ip-adapter', + name: 'SD 1 IP Adapter', + type: 'ip_adapter', +}; +const fluxRedux: ComponentModelConfig = { + base: 'flux', + key: 'flux-redux', + name: 'FLUX Redux', + type: 'flux_redux', +}; const vae: VaeModelConfig = { base: 'sd-1', key: 'vae-model', name: 'Custom VAE', type: 'vae' }; const fluxVae: VaeModelConfig = { base: 'flux', key: 'flux-vae', name: 'FLUX VAE', type: 'vae' }; const flux2Vae: VaeModelConfig = { base: 'flux2', key: 'flux2-vae', name: 'FLUX.2 VAE', type: 'vae' }; @@ -89,6 +108,16 @@ const createSettings = (model: GenerateModelConfig, overrides: Partial = {}) => compileGenerateGraph(createSettings(model, overrides), model, 'gallery', { useCpuNoise: true }).backendGraph; @@ -302,6 +331,115 @@ describe('compileGenerateGraph', () => { expect(getEdge(graph, 'canvas_output', 'negative_prompt')?.source.node_id).toBe('negative_prompt'); expect(getEdge(graph, 'canvas_output', 'seed')).toBeUndefined(); }); + + it('wires external provider reference images when supported', () => { + const graph = compile( + { ...externalModel, capabilities: { modes: ['txt2img'], supports_reference_images: true } }, + { + referenceImages: [ + { id: 'ref-1', isEnabled: true, config: { type: 'external_reference_image', image: refImage } }, + ], + } + ); + + expect(graph.nodes.canvas_output?.reference_images).toEqual([{ image_name: 'reference.png' }]); + }); + + it('wires SD IP Adapter reference images into denoise', () => { + const graph = compile(sd1Model, { + referenceImages: [ + { + id: 'ref-1', + isEnabled: true, + config: { + beginEndStepPct: [0.1, 0.8], + clipVisionModel: 'ViT-H', + image: refImage, + method: 'style', + model: sd1IpAdapter, + type: 'ip_adapter', + weight: 0.6, + }, + }, + ], + }); + const adapter = getNodeByType(graph, 'ip_adapter'); + + expect(adapter).toMatchObject({ + begin_step_percent: 0.1, + clip_vision_model: 'ViT-H', + end_step_percent: 0.8, + image: { image_name: 'reference.png' }, + ip_adapter_model: sd1IpAdapter, + method: 'style', + weight: 0.6, + }); + expect(getEdge(graph, 'denoise_latents', 'ip_adapter')?.source.node_id).toContain('ip_adapter_collector'); + }); + + it('wires FLUX Redux reference images into denoise', () => { + const graph = compile(fluxModel, { + clipEmbedModel: clipEmbed, + referenceImages: [ + { + id: 'ref-redux', + isEnabled: true, + config: { image: refImage, imageInfluence: 'medium', model: fluxRedux, type: 'flux_redux' }, + }, + ], + t5EncoderModel: t5Encoder, + vae: fluxVae, + }); + const redux = getNodeByType(graph, 'flux_redux'); + + expect(redux).toMatchObject({ image: { image_name: 'reference.png' }, redux_model: fluxRedux }); + expect(getEdge(graph, 'denoise_latents', 'redux_conditioning')?.source.node_id).toContain('flux_redux_collector'); + }); + + it('wires FLUX Kontext reference images into denoise', () => { + const graph = compile(fluxKontextModel, { + clipEmbedModel: clipEmbed, + referenceImages: [ + { + id: 'ref-kontext', + isEnabled: true, + config: { image: refImage, model: fluxKontextModel, type: 'flux_kontext_reference_image' }, + }, + ], + t5EncoderModel: t5Encoder, + vae: fluxVae, + }); + + expect(getNodeByType(graph, 'flux_kontext')).toBeDefined(); + expect(getEdge(graph, 'denoise_latents', 'kontext_conditioning')?.source.node_id).toContain('flux_kontext_collect'); + }); + + it('wires FLUX.2 built-in reference images into denoise', () => { + const graph = compile(flux2Model, { + referenceImages: [ + { id: 'ref-flux2', isEnabled: true, config: { image: refImage, type: 'flux2_reference_image' } }, + ], + }); + + expect(getNodeByType(graph, 'flux_kontext')).toBeDefined(); + expect(getEdge(graph, 'denoise_latents', 'kontext_conditioning')?.source.node_id).toContain( + 'flux2_kontext_collect' + ); + }); + + it('wires Qwen Image Edit reference images into text encoder and denoise latents', () => { + const graph = compile( + { ...qwenImageModel, variant: 'edit' }, + { + referenceImages: [ + { id: 'ref-qwen', isEnabled: true, config: { image: refImage, type: 'qwen_image_reference_image' } }, + ], + } + ); + + expect(getEdge(graph, 'pos_cond', 'reference_images')).toBeDefined(); + expect(getEdge(graph, 'denoise_latents', 'reference_latents')).toBeDefined(); + }); }); describe('generate seeds', () => { diff --git a/invokeai/frontend/webv2/src/workbench/generation/graph.ts b/invokeai/frontend/webv2/src/workbench/generation/graph.ts index 3aeae8cbbd2..b336c5d733e 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/graph.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/graph.ts @@ -11,6 +11,9 @@ import type { ComponentModelConfig, GenerateLora, GenerateModelConfig, + GenerateReferenceImage, + GenerateReferenceImageAsset, + GenerateReferenceImageConfig, GenerateSettings, MainModelConfig, } from './types'; @@ -86,6 +89,167 @@ const getCompatibleComponentSource = ( const getCompatibleVae = (settings: GenerateSettings, bases: readonly string[]) => settings.vae && isVaeForBases(bases)(settings.vae) ? settings.vae : null; +const toImageField = (image: GenerateReferenceImageAsset) => ({ image_name: image.imageName }); + +const getEnabledReferenceImages = ( + settings: GenerateSettings, + type: T +): Array }> => + settings.referenceImages.filter( + ( + referenceImage + ): referenceImage is GenerateReferenceImage & { config: Extract } => + referenceImage.isEnabled && referenceImage.config.type === type && referenceImage.config.image !== null + ); + +const addReferenceImageMetadata = ( + graph: BackendGraphContract, + outputNode: BackendInvocationContract, + settings: GenerateSettings +) => { + const enabled = settings.referenceImages.filter( + (referenceImage) => referenceImage.isEnabled && referenceImage.config.image + ); + + if (enabled.length === 0) { + return; + } + + const metadata = Object.values(graph.nodes).find((node) => node.type === 'core_metadata'); + + if (metadata) { + metadata.ref_images = enabled; + return; + } + + outputNode.ref_images = enabled; +}; + +const addIPAdapterReferenceImages = ( + graph: BackendGraphContract, + settings: GenerateSettings, + model: MainModelConfig, + denoise: BackendInvocationContract +) => { + const refs = getEnabledReferenceImages(settings, 'ip_adapter').filter((ref) => ref.config.model?.base === model.base); + + if (refs.length === 0) { + return; + } + + const collector = addNode(graph, { id: createId('ip_adapter_collector'), type: 'collect' }); + + for (const ref of refs) { + const { beginEndStepPct, clipVisionModel, image, method, model: adapterModel, weight } = ref.config; + + if (!image || !adapterModel) { + continue; + } + + const node = addNode(graph, { + begin_step_percent: beginEndStepPct[0], + clip_vision_model: clipVisionModel, + end_step_percent: beginEndStepPct[1], + id: createId('ip_adapter'), + image: toImageField(image), + ip_adapter_model: adapterModel, + method, + type: model.base === 'flux' ? 'flux_ip_adapter' : 'ip_adapter', + weight, + }); + + addEdge(graph, node, 'ip_adapter', collector, 'item'); + } + + addEdge(graph, collector, 'collection', denoise, 'ip_adapter'); +}; + +const FLUX_REDUX_INFLUENCE = { + lowest: { downsampling_factor: 5, weight: 1 }, + low: { downsampling_factor: 4, weight: 1 }, + medium: { downsampling_factor: 3, weight: 1 }, + high: { downsampling_factor: 2, weight: 1 }, + highest: { downsampling_factor: 1, weight: 1 }, +} as const; + +const addFluxReduxReferenceImages = ( + graph: BackendGraphContract, + settings: GenerateSettings, + model: MainModelConfig, + denoise: BackendInvocationContract +) => { + const refs = getEnabledReferenceImages(settings, 'flux_redux').filter((ref) => ref.config.model?.base === model.base); + + if (refs.length === 0) { + return; + } + + const collector = addNode(graph, { id: createId('flux_redux_collector'), type: 'collect' }); + + for (const ref of refs) { + const { image, imageInfluence, model: reduxModel } = ref.config; + + if (!image || !reduxModel) { + continue; + } + + const node = addNode(graph, { + id: createId('flux_redux'), + image: toImageField(image), + redux_model: reduxModel, + type: 'flux_redux', + ...FLUX_REDUX_INFLUENCE[imageInfluence], + }); + + addEdge(graph, node, 'redux_cond', collector, 'item'); + } + + addEdge(graph, collector, 'collection', denoise, 'redux_conditioning'); +}; + +const addFluxKontextReferenceImages = ( + graph: BackendGraphContract, + settings: GenerateSettings, + denoise: BackendInvocationContract, + type: 'flux2_reference_image' | 'flux_kontext_reference_image' +) => { + const refs = getEnabledReferenceImages(settings, type); + + if (refs.length === 0) { + return; + } + + let prevCollect: BackendInvocationContract | null = null; + + for (const ref of refs) { + const { image } = ref.config; + + if (!image) { + continue; + } + + const kontext = addNode(graph, { + id: createId('flux_kontext'), + image: toImageField(image), + type: 'flux_kontext', + }); + const collect = addNode(graph, { + id: createId(type === 'flux2_reference_image' ? 'flux2_kontext_collect' : 'flux_kontext_collect'), + type: 'collect', + }); + + addEdge(graph, kontext, 'kontext_cond', collect, 'item'); + if (prevCollect) { + addEdge(graph, prevCollect, 'collection', collect, 'collection'); + } + prevCollect = collect; + } + + if (prevCollect) { + addEdge(graph, prevCollect, 'collection', denoise, 'kontext_conditioning'); + } +}; + const getDiffusersSource = (settings: GenerateSettings, model: MainModelConfig): MainModelConfig | undefined => getCompatibleComponentSource(settings, model) ?? (model.format === 'diffusers' ? model : undefined); @@ -322,11 +486,13 @@ const buildSDGraph = ( addEdge(graph, unetSource, 'unet', denoise, 'unet'); addEdge(graph, noise, 'noise', denoise, 'noise'); + addIPAdapterReferenceImages(graph, settings, model, denoise); addEdge(graph, denoise, 'latents', output, 'latents'); addEdge(graph, seamless ?? vaeLoader ?? modelLoader, 'vae', output, 'vae'); addMetadata(graph, output, settings, model, model.base === 'sdxl' ? 'sdxl_txt2img' : 'txt2img', projectSettings, { scheduler, }); + addReferenceImageMetadata(graph, output, settings); return graph; }; @@ -450,6 +616,11 @@ const buildFluxGraph = ( addEdge(graph, posCond, 'conditioning', posCondCollect, 'item'); addEdge(graph, posCondCollect, 'collection', denoise, 'positive_text_conditioning'); addEdge(graph, graph.nodes.seed, 'value', denoise, 'seed'); + if (model.name.toLowerCase().includes('kontext')) { + addFluxKontextReferenceImages(graph, settings, denoise, 'flux_kontext_reference_image'); + } + addIPAdapterReferenceImages(graph, settings, model, denoise); + addFluxReduxReferenceImages(graph, settings, model, denoise); addEdge(graph, denoise, 'latents', output, 'latents'); addMetadata(graph, output, settings, model, 'flux_txt2img', projectSettings, { clip_embed_model: clipEmbedModel, @@ -458,6 +629,7 @@ const buildFluxGraph = ( t5_encoder: t5EncoderModel, vae: vaeModel, }); + addReferenceImageMetadata(graph, output, settings); return graph; }; @@ -521,6 +693,7 @@ const buildFlux2Graph = ( addEdge(graph, positivePrompt, 'value', posCond, 'prompt'); addEdge(graph, posCond, 'conditioning', denoise, 'positive_text_conditioning'); addEdge(graph, graph.nodes.seed, 'value', denoise, 'seed'); + addFluxKontextReferenceImages(graph, settings, denoise, 'flux2_reference_image'); addEdge(graph, denoise, 'latents', output, 'latents'); addMetadata(graph, output, settings, model, 'flux2_txt2img', projectSettings, { qwen3_encoder: qwen3EncoderModel ?? undefined, @@ -528,6 +701,7 @@ const buildFlux2Graph = ( scheduler, vae: vaeModel ?? undefined, }); + addReferenceImageMetadata(graph, output, settings); return graph; }; @@ -626,6 +800,49 @@ const buildQwenImageGraph = ( } addEdge(graph, seed, 'value', denoise, 'seed'); + const qwenReferenceImages = + model.variant === 'edit' ? getEnabledReferenceImages(settings, 'qwen_image_reference_image') : []; + + if (qwenReferenceImages.length > 0) { + let prevCollect: BackendInvocationContract | null = null; + + for (const { config } of qwenReferenceImages) { + if (!config.image) { + continue; + } + + const imageNode = addNode(graph, { + id: createId('qwen_ref_img'), + image: toImageField(config.image), + type: 'image', + }); + const collectNode = addNode(graph, { id: createId('qwen_ref_img_collect'), type: 'collect' }); + + addEdge(graph, imageNode, 'image', collectNode, 'item'); + if (prevCollect) { + addEdge(graph, prevCollect, 'collection', collectNode, 'collection'); + } + prevCollect = collectNode; + } + + if (prevCollect) { + addEdge(graph, prevCollect, 'collection', posCond, 'reference_images'); + } + + const firstImage = qwenReferenceImages[0]?.config.image; + if (firstImage) { + const refI2l = addNode(graph, { id: createId('qwen_ref_i2l'), type: 'qwen_image_i2l' }); + const refImageNode = addNode(graph, { + id: createId('qwen_ref_img_for_vae'), + image: toImageField(firstImage), + type: 'image', + }); + + addEdge(graph, refImageNode, 'image', refI2l, 'image'); + addEdge(graph, modelLoader, 'vae', refI2l, 'vae'); + addEdge(graph, refI2l, 'latents', denoise, 'reference_latents'); + } + } addEdge(graph, denoise, 'latents', output, 'latents'); addMetadata(graph, output, settings, model, 'qwen_image_txt2img', projectSettings, { qwen_image_component_source: sourceModel, @@ -633,6 +850,7 @@ const buildQwenImageGraph = ( qwen_image_vae: vaeModel ?? undefined, scheduler: undefined, }); + addReferenceImageMetadata(graph, output, settings); return graph; }; @@ -832,7 +1050,18 @@ const buildExternalGraph = ( if (model.capabilities?.supports_seed === true) { addEdge(graph, seed, 'value', output, 'seed'); } + if (model.capabilities?.supports_reference_images === true) { + const referenceImages = getEnabledReferenceImages(settings, 'external_reference_image') + .map((referenceImage) => referenceImage.config.image) + .filter((image): image is GenerateReferenceImageAsset => image !== null) + .map(toImageField); + + if (referenceImages.length > 0) { + (output as Record).reference_images = referenceImages; + } + } addMetadata(graph, output, settings, model, null, projectSettings, { scheduler: undefined }); + addReferenceImageMetadata(graph, output, settings); return graph; }; diff --git a/invokeai/frontend/webv2/src/workbench/generation/settings.test.ts b/invokeai/frontend/webv2/src/workbench/generation/settings.test.ts index a344700a6a8..846d72943fb 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/settings.test.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/settings.test.ts @@ -42,6 +42,61 @@ const legacyStoredValues = { }; describe('normalizeGenerateSettings', () => { + it('normalizes persisted reference images and drops invalid entries', () => { + const normalized = normalizeGenerateSettings({ + ...legacyStoredValues, + referenceImages: [ + { + id: 'ref-1', + isEnabled: true, + config: { + type: 'qwen_image_reference_image', + image: { + height: 768, + imageName: 'reference.png', + imageUrl: '/api/v1/images/i/reference.png/full', + queuedAt: '2026-01-01T00:00:00.000Z', + sourceQueueItemId: 'backend-gallery', + thumbnailUrl: '/api/v1/images/i/reference.png/thumbnail', + width: 512, + }, + }, + }, + { id: 'broken', isEnabled: true, config: { type: 'qwen_image_reference_image', image: { imageName: 3 } } }, + ], + }); + + expect(normalized?.referenceImages).toHaveLength(1); + expect(normalized?.referenceImages[0]).toMatchObject({ + id: 'ref-1', + isEnabled: true, + config: { type: 'qwen_image_reference_image', image: { imageName: 'reference.png' } }, + }); + }); + + it('keeps more than five valid persisted reference images for provider-specific caps', () => { + const referenceImages = Array.from({ length: 6 }, (_, index) => ({ + id: `ref-${index}`, + isEnabled: true, + config: { + type: 'external_reference_image', + image: { + height: 768, + imageName: `reference-${index}.png`, + imageUrl: `/api/v1/images/i/reference-${index}.png/full`, + queuedAt: '2026-01-01T00:00:00.000Z', + sourceQueueItemId: 'backend-gallery', + thumbnailUrl: `/api/v1/images/i/reference-${index}.png/thumbnail`, + width: 512, + }, + }, + })); + + const normalized = normalizeGenerateSettings({ ...legacyStoredValues, referenceImages }); + + expect(normalized?.referenceImages).toHaveLength(6); + }); + it('upgrades legacy persisted values without losing the core fields', () => { const normalized = normalizeGenerateSettings(legacyStoredValues); diff --git a/invokeai/frontend/webv2/src/workbench/generation/settings.ts b/invokeai/frontend/webv2/src/workbench/generation/settings.ts index afce86f3c91..227b343b211 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/settings.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/settings.ts @@ -3,6 +3,9 @@ import type { ComponentModelConfig, GenerateLora, GenerateModelConfig, + GenerateReferenceImage, + GenerateReferenceImageAsset, + GenerateReferenceImageConfig, GenerateSettings, GenerateWidgetValues, LoraModelConfig, @@ -160,6 +163,146 @@ const isGenerateLora = (value: unknown): value is GenerateLora => hasFiniteNumber(value, 'weight') && typeof value.isEnabled === 'boolean'; +export const DEFAULT_REFERENCE_IMAGE_LIMIT = 5; + +const REFERENCE_IMAGE_CONFIG_TYPES = new Set([ + 'external_reference_image', + 'flux2_reference_image', + 'flux_kontext_reference_image', + 'flux_redux', + 'ip_adapter', + 'qwen_image_reference_image', +]); + +const isClipVisionModel = (value: unknown): value is 'ViT-H' | 'ViT-G' | 'ViT-L' => + value === 'ViT-H' || value === 'ViT-G' || value === 'ViT-L'; + +const isIPAdapterMethod = ( + value: unknown +): value is 'full' | 'style' | 'composition' | 'style_strong' | 'style_precise' => + value === 'full' || + value === 'style' || + value === 'composition' || + value === 'style_strong' || + value === 'style_precise'; + +const isFluxReduxImageInfluence = (value: unknown): value is 'lowest' | 'low' | 'medium' | 'high' | 'highest' => + value === 'lowest' || value === 'low' || value === 'medium' || value === 'high' || value === 'highest'; + +export const isReferenceImageAsset = (value: unknown): value is GenerateReferenceImageAsset => + isRecord(value) && + typeof value.imageName === 'string' && + typeof value.imageUrl === 'string' && + typeof value.thumbnailUrl === 'string' && + typeof value.queuedAt === 'string' && + typeof value.sourceQueueItemId === 'string' && + hasFiniteNumber(value, 'width') && + hasFiniteNumber(value, 'height'); + +const cloneReferenceImageAsset = (image: GenerateReferenceImageAsset): GenerateReferenceImageAsset => ({ ...image }); + +const normalizeReferenceImageAsset = (value: unknown): GenerateReferenceImageAsset | null => + value === null ? null : isReferenceImageAsset(value) ? cloneReferenceImageAsset(value) : null; + +const normalizeReferenceImageConfig = (value: unknown): GenerateReferenceImageConfig | null => { + if (!isRecord(value) || typeof value.type !== 'string' || !REFERENCE_IMAGE_CONFIG_TYPES.has(value.type)) { + return null; + } + + const image = normalizeReferenceImageAsset(value.image); + + if (value.image !== null && image === null) { + return null; + } + + switch (value.type) { + case 'external_reference_image': + case 'flux2_reference_image': + case 'qwen_image_reference_image': + return { image, type: value.type }; + case 'flux_kontext_reference_image': + return { image, model: getMainModelOrNull(value.model), type: value.type }; + case 'flux_redux': + return { + image, + imageInfluence: isFluxReduxImageInfluence(value.imageInfluence) ? value.imageInfluence : 'highest', + model: getModelIdentifierOrNull(value.model), + type: value.type, + }; + case 'ip_adapter': + return { + beginEndStepPct: + Array.isArray(value.beginEndStepPct) && + value.beginEndStepPct.length === 2 && + value.beginEndStepPct.every((item) => typeof item === 'number' && Number.isFinite(item)) + ? [value.beginEndStepPct[0], value.beginEndStepPct[1]] + : [0, 1], + clipVisionModel: isClipVisionModel(value.clipVisionModel) ? value.clipVisionModel : 'ViT-H', + image, + method: isIPAdapterMethod(value.method) ? value.method : 'full', + model: getModelIdentifierOrNull(value.model), + type: value.type, + weight: hasFiniteNumber(value, 'weight') ? (value.weight as number) : 1, + }; + } + + return null; +}; + +export const normalizeReferenceImages = (value: unknown): GenerateReferenceImage[] => { + if (!Array.isArray(value)) { + return []; + } + + const images: GenerateReferenceImage[] = []; + + for (const item of value) { + if (!isRecord(item) || typeof item.id !== 'string') { + continue; + } + + const config = normalizeReferenceImageConfig(item.config); + + if (!config) { + continue; + } + + images.push({ config, id: item.id, isEnabled: item.isEnabled !== false }); + } + + return images; +}; + +const cloneReferenceImageConfig = (config: GenerateReferenceImageConfig): GenerateReferenceImageConfig => { + switch (config.type) { + case 'external_reference_image': + case 'flux2_reference_image': + case 'qwen_image_reference_image': + return { ...config, image: config.image ? cloneReferenceImageAsset(config.image) : null }; + case 'flux_kontext_reference_image': + return { + ...config, + image: config.image ? cloneReferenceImageAsset(config.image) : null, + model: config.model ? { ...config.model } : null, + }; + case 'flux_redux': + case 'ip_adapter': + return { + ...config, + image: config.image ? cloneReferenceImageAsset(config.image) : null, + model: config.model ? { ...config.model } : null, + }; + } +}; + +export const cloneReferenceImages = ( + referenceImages: readonly GenerateReferenceImage[] = [] +): GenerateReferenceImage[] => + referenceImages.map((referenceImage) => ({ + ...referenceImage, + config: cloneReferenceImageConfig(referenceImage.config), + })); + export const getDefaultLoraWeight = (model: LoraModelConfig): number => { const weight = model.default_settings?.weight; @@ -245,10 +388,61 @@ export const cloneGenerateWidgetValues = ( model: { ...values.model }, qwen3EncoderModel: values.qwen3EncoderModel ? { ...values.qwen3EncoderModel } : null, qwenVLEncoderModel: values.qwenVLEncoderModel ? { ...values.qwenVLEncoderModel } : null, + referenceImages: cloneReferenceImages(values.referenceImages), t5EncoderModel: values.t5EncoderModel ? { ...values.t5EncoderModel } : null, vae: values.vae ? { ...values.vae } : null, }); +const syncReferenceImageConfigWithModels = ( + config: GenerateReferenceImageConfig, + modelsByKey: Map +): GenerateReferenceImageConfig => { + switch (config.type) { + case 'flux_kontext_reference_image': { + const model = syncModelIdentifierWithModels(config.model, modelsByKey); + + return model === config.model ? config : { ...config, model: isMainModelConfig(model) ? model : config.model }; + } + case 'flux_redux': + case 'ip_adapter': { + const model = syncModelIdentifierWithModels(config.model, modelsByKey); + + return model === config.model ? config : { ...config, model }; + } + case 'external_reference_image': + case 'flux2_reference_image': + case 'qwen_image_reference_image': + return config; + } +}; + +export const syncReferenceImagesWithModels = ( + referenceImages: GenerateReferenceImage[], + models: readonly unknown[] +): GenerateReferenceImage[] => { + const modelsByKey = new Map(); + + for (const model of models) { + if (isModelIdentifierConfig(model)) { + modelsByKey.set(model.key, model); + } + } + + let didSync = false; + const synced = referenceImages.map((referenceImage) => { + const config = syncReferenceImageConfigWithModels(referenceImage.config, modelsByKey); + + if (config === referenceImage.config) { + return referenceImage; + } + + didSync = true; + return { ...referenceImage, config }; + }); + + return didSync ? synced : referenceImages; +}; + export const syncGenerateWidgetValuesWithModels = ( values: GenerateWidgetValues, models: readonly unknown[] @@ -279,6 +473,7 @@ export const syncGenerateWidgetValuesWithModels = ( modelKey: model.key, qwen3EncoderModel: syncModelIdentifierWithModels(values.qwen3EncoderModel, modelsByKey), qwenVLEncoderModel: syncModelIdentifierWithModels(values.qwenVLEncoderModel, modelsByKey), + referenceImages: syncReferenceImagesWithModels(values.referenceImages, models), t5EncoderModel: syncModelIdentifierWithModels(values.t5EncoderModel, modelsByKey), vae: isVaeModelConfig(vae) ? vae : values.vae, }; @@ -292,6 +487,7 @@ export const syncGenerateWidgetValuesWithModels = ( nextValues.clipGEmbedModel === values.clipGEmbedModel && nextValues.qwen3EncoderModel === values.qwen3EncoderModel && nextValues.qwenVLEncoderModel === values.qwenVLEncoderModel && + nextValues.referenceImages === values.referenceImages && nextValues.componentSourceModel === values.componentSourceModel && nextValues.modelKey === values.modelKey ? values @@ -381,6 +577,7 @@ export const normalizeGenerateSettings = (values: unknown): GenerateSettings | n MAX_POSITIVE_PROMPT_HEIGHT_PX, DEFAULT_POSITIVE_PROMPT_HEIGHT_PX ), + referenceImages: normalizeReferenceImages(values.referenceImages), scheduler: values.scheduler as string, seamlessXAxis: typeof values.seamlessXAxis === 'boolean' ? values.seamlessXAxis : false, seamlessYAxis: typeof values.seamlessYAxis === 'boolean' ? values.seamlessYAxis : false, @@ -429,6 +626,8 @@ export const isGenerateSettings = (values: unknown): values is GenerateSettings hasFiniteNumber(values, 'positivePromptHeightPx') && Array.isArray(values.loras) && values.loras.every(isGenerateLora) && + Array.isArray(values.referenceImages) && + normalizeReferenceImages(values.referenceImages).length === values.referenceImages.length && typeof values.seamlessXAxis === 'boolean' && typeof values.seamlessYAxis === 'boolean' && isVaePrecision(values.vaePrecision) && diff --git a/invokeai/frontend/webv2/src/workbench/generation/types.ts b/invokeai/frontend/webv2/src/workbench/generation/types.ts index 2384156fbb8..86018f78f63 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/types.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/types.ts @@ -36,6 +36,8 @@ export type ExternalImageGeneratorModelConfig = ModelIdentifierConfig & { modes?: string[]; supports_seed?: boolean; supports_negative_prompt?: boolean; + supports_reference_images?: boolean; + max_reference_images?: number | null; } | null; default_settings?: { width?: number | null; @@ -67,6 +69,45 @@ export interface GenerateLora { weight: number; } +export type GenerateReferenceImageAsset = GeneratedImageContract; + +export type ClipVisionModel = 'ViT-H' | 'ViT-G' | 'ViT-L'; + +export type IPAdapterMethod = 'full' | 'style' | 'composition' | 'style_strong' | 'style_precise'; + +export type FluxReduxImageInfluence = 'lowest' | 'low' | 'medium' | 'high' | 'highest'; + +export type GenerateReferenceImageConfig = + | { + type: 'ip_adapter'; + image: GenerateReferenceImageAsset | null; + model: ComponentModelConfig | null; + weight: number; + beginEndStepPct: [number, number]; + method: IPAdapterMethod; + clipVisionModel: ClipVisionModel; + } + | { + type: 'flux_redux'; + image: GenerateReferenceImageAsset | null; + model: ComponentModelConfig | null; + imageInfluence: FluxReduxImageInfluence; + } + | { + type: 'flux_kontext_reference_image'; + image: GenerateReferenceImageAsset | null; + model: MainModelConfig | null; + } + | { type: 'flux2_reference_image'; image: GenerateReferenceImageAsset | null } + | { type: 'qwen_image_reference_image'; image: GenerateReferenceImageAsset | null } + | { type: 'external_reference_image'; image: GenerateReferenceImageAsset | null }; + +export interface GenerateReferenceImage { + id: string; + isEnabled: boolean; + config: GenerateReferenceImageConfig; +} + export type VaePrecision = 'fp16' | 'fp32'; export type AspectRatioId = @@ -115,6 +156,7 @@ export interface GenerateSettings { vae: VaeModelConfig | null; vaePrecision: VaePrecision; loras: GenerateLora[]; + referenceImages: GenerateReferenceImage[]; t5EncoderModel: ComponentModelConfig | null; clipEmbedModel: ComponentModelConfig | null; clipLEmbedModel: ComponentModelConfig | null; diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx index 91c5d8160a3..01e19234247 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/image-actions/ImageContextMenu.tsx @@ -311,6 +311,7 @@ const SingleImageMenuItems = ({ const handleRecallDimensions = useRecallImageDataHandler(actions, image, 'dimensions'); const handleRecallClipSkip = useRecallImageDataHandler(actions, image, 'clipSkip'); const handleSelectForCompare = useSelectForCompareHandler(actions, image); + const handleUseAsReferenceImage = useUseAsReferenceImageHandler(actions, image); return ( <> @@ -369,7 +370,13 @@ const SingleImageMenuItems = ({ - + useCallback(() => actions.selectForCompare(image), [actions, image]); +const useUseAsReferenceImageHandler = (actions: ImageActions, image: GalleryImage) => + useCallback(() => actions.useAsReferenceImage(image), [actions, image]); + const OpenInNewTabQuickMenuItem = ({ image }: { image: GalleryImage }) => { const handleClick = useCallback(() => window.open(image.imageUrl, '_blank', 'noopener'), [image.imageUrl]); 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 dd64eb78574..471eb5508a5 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.test.ts @@ -53,6 +53,7 @@ const createValues = (overrides: Partial = {}): GenerateWi positivePromptHeightPx: 96, qwen3EncoderModel: null, qwenVLEncoderModel: null, + referenceImages: [], scheduler: 'euler_a', seamlessXAxis: false, seamlessYAxis: false, @@ -128,6 +129,82 @@ describe('image recall', () => { expect(result?.fields).toContain('seed'); }); + it('recalls reference images from metadata, re-targeting configs to the effective model', () => { + // The metadata model is not installed, so the current SDXL model stays + // selected and the Qwen reference config is rebuilt for SDXL. + const result = buildImageRecallSettings({ + currentValues: createValues(), + image, + kind: 'all', + metadata: { + ref_images: [ + { + id: 'ref-1', + isEnabled: true, + config: { + image: { + height: 770, + imageName: 'reference.png', + imageUrl: '/api/v1/images/i/reference.png/full', + queuedAt: '2026-01-01T00:00:00.000Z', + sourceQueueItemId: 'backend-gallery', + thumbnailUrl: '/api/v1/images/i/reference.png/thumbnail', + width: 513, + }, + type: 'qwen_image_reference_image', + }, + }, + ], + }, + models: [], + supportedModels: [sdxlModel], + vaeModels: [], + }); + + expect(result?.values.referenceImages).toHaveLength(1); + expect(result?.values.referenceImages[0]?.config).toMatchObject({ + image: { imageName: 'reference.png' }, + type: 'ip_adapter', + }); + expect(result?.fields).toContain('referenceImages'); + }); + + it('drops recalled reference images when the effective model does not support them', () => { + const result = buildImageRecallSettings({ + currentValues: createValues({ model: animaModel, modelKey: animaModel.key }), + image, + kind: 'all', + metadata: { + positive_prompt: 'a recalled prompt', + ref_images: [ + { + id: 'ref-1', + isEnabled: true, + config: { + image: { + height: 770, + imageName: 'reference.png', + imageUrl: '/api/v1/images/i/reference.png/full', + queuedAt: '2026-01-01T00:00:00.000Z', + sourceQueueItemId: 'backend-gallery', + thumbnailUrl: '/api/v1/images/i/reference.png/thumbnail', + width: 513, + }, + type: 'qwen_image_reference_image', + }, + }, + ], + }, + models: [], + supportedModels: [animaModel], + vaeModels: [], + }); + + expect(result?.fields).toContain('prompts'); + expect(result?.values.referenceImages).toEqual([]); + expect(result?.fields).not.toContain('referenceImages'); + }); + it('remixes without changing the current seed state', () => { const result = buildImageRecallSettings({ currentValues: createValues({ seed: 999, shouldRandomizeSeed: false }), diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts index 2653c29b05a..122e7e70724 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/imageRecall.ts @@ -8,6 +8,7 @@ import type { } from '@workbench/generation/types'; import { + getCompatibleReferenceImages, getGenerationDimensions, getGenerationUiPolicy, getSettingsWithModelDefaults, @@ -22,6 +23,7 @@ import { isModelIdentifierConfig, clampDimension, deriveAspectRatioId, + normalizeReferenceImages, SEED_MAX, } from '@workbench/generation/settings'; @@ -56,7 +58,8 @@ type RecalledField = | 'scheduler' | 'seamless' | 'clipSkip' - | 'components'; + | 'components' + | 'referenceImages'; export interface ImageRecallResult { fields: RecalledField[]; @@ -311,6 +314,9 @@ const hasComponentModels = (metadata: unknown, models: readonly ComponentModelCo getMetadataComponent(metadata, 'qwen3_encoder', models) !== undefined || getMetadataComponent(metadata, 'qwen_image_qwen_vl_encoder', models) !== undefined; +const getMetadataReferenceImages = (metadata: unknown) => + isRecord(metadata) ? normalizeReferenceImages(metadata.ref_images) : []; + const withDimensions = ( values: GenerateWidgetValues, size: Partial> @@ -357,10 +363,20 @@ export const getImageRecallCapabilities = ({ const hasSize = hasMetadataSize(metadata, currentValues.model); const hasSettings = hasGenerationSettings(metadata); const hasComponents = hasComponentModels(metadata, models); + const hasReferenceImages = getMetadataReferenceImages(metadata).length > 0; const hasModel = supportedMetadataModel !== null; const hasAnyMetadata = - hasModel || hasVae || hasPrompts || hasSeed || hasSize || hasSettings || hasClipSkip || hasComponents; - const hasNonSeedMetadata = hasModel || hasVae || hasPrompts || hasSize || hasSettings || hasClipSkip || hasComponents; + hasModel || + hasVae || + hasPrompts || + hasSeed || + hasSize || + hasSettings || + hasClipSkip || + hasComponents || + hasReferenceImages; + const hasNonSeedMetadata = + hasModel || hasVae || hasPrompts || hasSize || hasSettings || hasClipSkip || hasComponents || hasReferenceImages; return { all: hasAnyMetadata, @@ -431,6 +447,16 @@ export const buildImageRecallSettings = ({ values = { ...values, vae }; fields.push('vae'); } + + // Recalled reference images must fit the effective model — when the + // metadata model was not recalled (e.g. uninstalled), `values.model` is + // still the current one and incompatible configs are re-targeted or dropped. + const referenceImages = getCompatibleReferenceImages(getMetadataReferenceImages(metadata), values.model, models); + + if (referenceImages.length > 0) { + values = { ...values, referenceImages }; + fields.push('referenceImages'); + } } if (kind === 'all' || kind === 'remix' || kind === 'prompts') { diff --git a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts index 7786d43b17a..72b5568b67d 100644 --- a/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts +++ b/invokeai/frontend/webv2/src/workbench/image-actions/useImageActions.ts @@ -12,7 +12,13 @@ import { type GalleryBoard, type GalleryImage, } from '@workbench/gallery/api'; -import { isSupportedGenerateModel } from '@workbench/generation/baseGenerationPolicies'; +import { + createReferenceImageId, + getDefaultReferenceImageConfig, + getMaxReferenceImages, + isReferenceImageSupported, + isSupportedGenerateModel, +} from '@workbench/generation/baseGenerationPolicies'; import { isVaeModelConfig } from '@workbench/generation/settings'; import { ensureModelsLoaded, useModelsSelector } from '@workbench/models/modelsStore'; import { useOpenWorkbenchWidget } from '@workbench/useOpenWorkbenchWidget'; @@ -34,6 +40,8 @@ import { * a refresh action so any mounted gallery widget refetches affected backend data. */ export interface ImageActions { + /** Whether the generate widget's current model can accept another reference image. */ + canUseAsReferenceImage: boolean; copyImage: (image: GalleryImage) => Promise; deleteImages: (imageNames: string[]) => Promise; downloadImage: (image: GalleryImage) => Promise; @@ -44,6 +52,7 @@ export interface ImageActions { recallImageData: (image: GalleryImage, kind: ImageRecallKind) => Promise; selectForCompare: (image: GalleryImage) => void; setImagesStarred: (imageNames: string[], starred: boolean) => Promise; + useAsReferenceImage: (image: GalleryImage) => void; } export const saveBlobToDisk = (blob: Blob, fileName: string): void => { @@ -256,6 +265,35 @@ export const useImageActions = ({ recordError(error); } }, + canUseAsReferenceImage: Boolean( + currentGenerateValues && + currentGenerateValues.referenceImages.length < getMaxReferenceImages(currentGenerateValues.model) + ), + useAsReferenceImage: (image) => { + const latestGenerateValues = getLatestGenerateValues(); + const currentValues = getCurrentGenerateValues({ generateValues: latestGenerateValues, supportedModels }); + + if ( + !currentValues || + !isReferenceImageSupported(currentValues.model) || + currentValues.referenceImages.length >= getMaxReferenceImages(currentValues.model) + ) { + return; + } + + const referenceImage = { + config: getDefaultReferenceImageConfig(currentValues.model, models, image), + id: createReferenceImageId(), + isEnabled: true, + }; + + dispatch({ + projectId, + type: 'patchGenerateSettings', + values: { referenceImages: [...currentValues.referenceImages, referenceImage] }, + }); + openWorkbenchWidget('generate', { preferredRegions: ['left'] }); + }, }; }, [ boards, diff --git a/invokeai/frontend/webv2/src/workbench/models/components/ModelSelect.tsx b/invokeai/frontend/webv2/src/workbench/models/components/ModelSelect.tsx index 42a695f69ff..2fe22b3742e 100644 --- a/invokeai/frontend/webv2/src/workbench/models/components/ModelSelect.tsx +++ b/invokeai/frontend/webv2/src/workbench/models/components/ModelSelect.tsx @@ -3,6 +3,7 @@ import type { ModelConfig, ModelTaxonomyType } from '@workbench/models/types'; import { Badge, + Box, HStack, Icon, Input, @@ -34,11 +35,12 @@ const EMPTY_BASES: ReadonlySet = new Set(); */ export const ModelSelect = ({ className, + disabled, excludeKeys, filter, id, invalid, - isClearable = true, + isClearable = false, modelTypes, onChange, placeholder, @@ -47,6 +49,7 @@ export const ModelSelect = ({ value, }: { className?: string; + disabled?: boolean; /** Hide specific models (e.g. the current model and already-linked ones). */ excludeKeys?: ReadonlySet; /** Extra predicate, e.g. base-architecture compatibility. */ @@ -100,6 +103,13 @@ export const ModelSelect = ({ setSearchTerm(''); setSelectedBases(EMPTY_BASES); }; + + useEffect(() => { + if (disabled) { + closeAndReset(); + } + }, [disabled]); + const toggleBase = (base: string) => { setSelectedBases((prev) => { const next = new Set(prev); @@ -146,9 +156,10 @@ export const ModelSelect = ({ optionRefs.current = []; let optionIndex = 0; + const canClear = isClearable && Boolean(value); return ( - + { + if (disabled) { + setIsOpen(false); + return; + } + if (event.open) { setSearchTerm(''); setSelectedBases(EMPTY_BASES); @@ -173,29 +189,54 @@ export const ModelSelect = ({ closeAndReset(); }} > - - - + + + + + {canClear ? ( + { + event.stopPropagation(); + clearSelection(); + }} + onMouseDown={(event) => { + event.stopPropagation(); + }} + > + + + ) : null} + - {isClearable && value ? ( - - - - ) : null} - + ); }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryWidgetView.tsx index efa8a191522..e60ae0af49c 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/gallery/GalleryWidgetView.tsx @@ -165,9 +165,11 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: WidgetViewP const galleryImagesRef = useRef(gallery.images); const selectedImageNameRef = useRef(gallery.selectedImageName); const lastPublishedTotalRef = useRef(null); + const knownTotalImagesRef = useRef(knownTotalImages); galleryImagesRef.current = gallery.images; selectedImageNameRef.current = gallery.selectedImageName; + knownTotalImagesRef.current = knownTotalImages; // After a deletion that takes out the previewed image, move the selection to // the image that now occupies the old index, else the one before it. @@ -228,27 +230,32 @@ export const GalleryWidgetView = ({ presentation, region, runtime }: WidgetViewP // Publish the backend total into widget values so the manifest footer can // render page navigation without its own fetch, and clamp the page when the // query shrinks (e.g. after deletions). + // + // The effect reacts only to this instance's fetched `total` — the published + // state (`knownTotalImages`) is read through a ref on purpose. Several + // gallery views can be mounted at once (e.g. the bottom status chip plus an + // expanded gallery), and if each instance re-published whenever the shared + // state disagreed with its own in-flight total, two instances mid-refetch + // would dispatch in a loop until React aborts with "maximum update depth". useEffect(() => { - if (total === knownTotalImages) { - lastPublishedTotalRef.current = null; - return; - } - if (typeof total !== 'number' || !Number.isFinite(total)) { return; } + const lastPublishedTotal = lastPublishedTotalRef.current; + + lastPublishedTotalRef.current = total; + if ( shouldPublishGalleryTotal({ - knownTotalImages, - lastPublishedTotal: lastPublishedTotalRef.current, + knownTotalImages: knownTotalImagesRef.current, + lastPublishedTotal, total, }) ) { - lastPublishedTotalRef.current = total; dispatch({ totalImages: total, type: 'setGalleryPageInfo' }); } - }, [dispatch, knownTotalImages, total]); + }, [dispatch, total]); useEffect(() => { if (settings.paginationMode !== 'paginated' || total === null) { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateComponentsSection.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateComponentsSection.tsx index a491cedd9d4..78ee742fdd7 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateComponentsSection.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateComponentsSection.tsx @@ -57,6 +57,7 @@ const ComponentPicker = ({ ) => { + const setAspectRatioId = ({ value }: SelectValueChangeDetails) => { const id = value[0]; if (!isAspectRatioId(id)) { @@ -284,49 +273,30 @@ export const GenerateDimensionFields = ({ - ( + + + + {option.id} + + + )} size="xs" value={[settings.aspectRatioId]} + valueText={ + + + + {settings.aspectRatioId} + + + } onValueChange={setAspectRatioId} - > - - - - - - - - {settings.aspectRatioId} - - - - - - - - - - - - {aspectRatioOptions.map((option) => ( - - - - - - {option.id} - - - - - - ))} - - - - + /> interface GenerateModelFieldsProps { settings: GenerateSettings; + models: readonly ModelConfig[]; selectedModel: GenerateModelConfig | undefined; vaeModels: VaeModelConfig[]; onCommit: (patch: Partial) => void; @@ -57,6 +59,7 @@ const settingsMatchModelDefaults = (settings: GenerateSettings, modelDefaultSett settings.loras.every((lora, index) => lora.isEnabled === modelDefaultSettings.loras[index]?.isEnabled); export const GenerateModelFields = ({ + models, onCommit, onCommitSettings, selectedModel, @@ -94,7 +97,7 @@ export const GenerateModelFields = ({ }; const selectModel = (model: GenerateModelConfig) => { - const result = getSettingsWithCompatibleModelSelections(settings, model); + const result = getSettingsWithCompatibleModelSelections(settings, model, models); onCommitSettings(result.settings); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateSettingsForm.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateSettingsForm.tsx index 7f0ba80f70b..438eedfd515 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateSettingsForm.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateSettingsForm.tsx @@ -5,6 +5,7 @@ import type { LoraModelConfig, VaeModelConfig, } from '@workbench/generation/types'; +import type { ModelConfig } from '@workbench/models/types'; import { Stack, Text } from '@chakra-ui/react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; @@ -25,6 +26,7 @@ import { useRegisterGenerateDraftFlusher } from './generateDraftRegistry'; import { getSettingsWithLatestPromptFields } from './generateFormViewModel'; import { GenerateModelFields } from './GenerateModelFields'; import { GeneratePromptFields } from './promptFields'; +import { GenerateReferenceImagesSection } from './reference-images/GenerateReferenceImagesSection'; const GENERATE_INPUT_DEBOUNCE_MS = 250; @@ -33,6 +35,7 @@ interface GenerateSettingsFormProps { loadError: string | null; settings: GenerateSettings; loraModels: LoraModelConfig[]; + models: readonly ModelConfig[]; projectId: string; selectedModel: GenerateModelConfig | undefined; supportedModels: GenerateModelConfig[]; @@ -45,6 +48,7 @@ export const GenerateSettingsForm = ({ isLoadingModels, loadError, loraModels, + models, onCommitSettings, onPatchSettings, projectId, @@ -235,6 +239,14 @@ export const GenerateSettingsForm = ({ onCommitImmediate={commitPatchImmediately} /> + + { isLoadingModels={status === 'idle' || status === 'loading'} loadError={error} loraModels={loraModels} + models={models} projectId={projectId} selectedModel={selectedModel} settings={settings} diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/GenerateReferenceImagesSection.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/GenerateReferenceImagesSection.tsx new file mode 100644 index 00000000000..aee0fbfaba5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/GenerateReferenceImagesSection.tsx @@ -0,0 +1,312 @@ +import type { + GenerateModelConfig, + GenerateReferenceImage, + GenerateReferenceImageAsset, + GenerateSettings, +} from '@workbench/generation/types'; +import type { ModelConfig } from '@workbench/models/types'; +import type { GenerateSettingsUpdate } from '@workbench/widgets/generate/generateDebounce'; +import type { ChangeEvent } from 'react'; + +import { Badge, Box, HStack, Input, Stack, Text } from '@chakra-ui/react'; +import { useDndMonitor, useDroppable } from '@dnd-kit/core'; +import { Button, IconButton, Tooltip } from '@workbench/components/ui'; +import { getGalleryImagesByNames, uploadGalleryImage } from '@workbench/gallery/api'; +import { + createReferenceImageId, + getDefaultReferenceImageConfig, + getGenerationDimensions, + getMaxReferenceImages, + isReferenceImageSupported, +} from '@workbench/generation/baseGenerationPolicies'; +import { clampDimension, deriveAspectRatioId } from '@workbench/generation/settings'; +import { isGalleryImageDragData } from '@workbench/widgets/gallery/galleryDnd'; +import { GenerateCollapsibleSection } from '@workbench/widgets/generate/shared/GenerateCollapsibleSection'; +import { useWorkbenchDispatch } from '@workbench/WorkbenchContext'; +import { ScanIcon, UploadIcon } from 'lucide-react'; +import { useCallback, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ReferenceImageCard } from './ReferenceImageCard'; + +const UPLOAD_ZONE_HOVER_STYLES = { bg: 'bg.muted', color: 'fg' }; + +interface GenerateReferenceImagesSectionProps { + models: readonly ModelConfig[]; + selectedModel: GenerateModelConfig | undefined; + settings: GenerateSettings; + onCommit: (update: GenerateSettingsUpdate) => void; + onCommitImmediate: (patch: Partial) => void; +} + +export const GenerateReferenceImagesSection = ({ + models, + onCommit, + onCommitImmediate, + selectedModel, + settings, +}: GenerateReferenceImagesSectionProps) => { + const { t } = useTranslation(); + const dispatch = useWorkbenchDispatch(); + const fileInputRef = useRef(null); + const referenceImages = settings.referenceImages; + const isSupported = isReferenceImageSupported(selectedModel); + const maxReferenceImages = getMaxReferenceImages(selectedModel); + const canAdd = referenceImages.length < maxReferenceImages; + const { isOver, setNodeRef } = useDroppable({ + data: { kind: 'generate-reference-images' }, + disabled: !canAdd, + id: 'generate-reference-images', + }); + const activeCount = referenceImages.filter((image) => image.isEnabled).length; + const referenceImageCount = referenceImages.length; + + const appendReferenceImages = useCallback( + (images: GenerateReferenceImageAsset[]) => { + onCommit((currentSettings) => { + const remaining = getMaxReferenceImages(selectedModel) - currentSettings.referenceImages.length; + + if (remaining <= 0 || images.length === 0) { + return currentSettings; + } + + return { + ...currentSettings, + referenceImages: [ + ...currentSettings.referenceImages, + ...images.slice(0, remaining).map((image) => ({ + config: getDefaultReferenceImageConfig(selectedModel, models, image), + id: createReferenceImageId(), + isEnabled: true, + })), + ], + }; + }); + }, + [models, onCommit, selectedModel] + ); + + const patchReferenceImage = useCallback( + (id: string, patch: Partial) => { + onCommit((currentSettings) => ({ + ...currentSettings, + referenceImages: currentSettings.referenceImages.map((referenceImage) => + referenceImage.id === id ? { ...referenceImage, ...patch } : referenceImage + ), + })); + }, + [onCommit] + ); + + const removeReferenceImage = useCallback( + (id: string) => { + onCommit((currentSettings) => ({ + ...currentSettings, + referenceImages: currentSettings.referenceImages.filter((referenceImage) => referenceImage.id !== id), + })); + }, + [onCommit] + ); + + const clearReferenceImages = useCallback(() => onCommitImmediate({ referenceImages: [] }), [onCommitImmediate]); + + const applyReferenceImageSize = useCallback( + (image: GenerateReferenceImageAsset) => { + if (!selectedModel) { + return; + } + + const grid = getGenerationDimensions(selectedModel).grid; + const width = clampDimension(image.width, grid); + const height = clampDimension(image.height, grid); + + onCommitImmediate({ + aspectRatioId: deriveAspectRatioId(width, height), + aspectRatioValue: height > 0 ? width / height : 1, + height, + width, + }); + }, + [onCommitImmediate, selectedModel] + ); + + const uploadFiles = useCallback( + async (files: File[]) => { + if (!canAdd || files.length === 0) { + return; + } + + try { + const uploaded = await Promise.all( + files.slice(0, maxReferenceImages - referenceImageCount).map((file) => uploadGalleryImage(file, 'none')) + ); + + appendReferenceImages(uploaded); + dispatch({ type: 'touchGalleryImagesRefresh' }); + } catch (error) { + dispatch({ + area: 'reference-images', + message: error instanceof Error ? error.message : String(error), + namespace: 'generation', + type: 'recordError', + }); + } + }, + [appendReferenceImages, canAdd, dispatch, maxReferenceImages, referenceImageCount] + ); + + const handleUploadZoneClick = useCallback(() => { + if (canAdd) { + fileInputRef.current?.click(); + } + }, [canAdd]); + + const handleFileInputChange = useCallback( + (event: ChangeEvent) => { + void uploadFiles(Array.from(event.currentTarget.files ?? [])); + event.currentTarget.value = ''; + }, + [uploadFiles] + ); + + const addGalleryImages = async (imageNames: string[]) => { + if (!canAdd || imageNames.length === 0) { + return; + } + + const images = await getGalleryImagesByNames(imageNames.slice(0, maxReferenceImages - referenceImageCount)); + + if (images.length > 0) { + appendReferenceImages(images); + } + }; + + useDndMonitor({ + onDragEnd: (event) => { + const data = event.active.data.current; + + if (event.over?.id === 'generate-reference-images' && isGalleryImageDragData(data) && canAdd) { + void addGalleryImages(data.images.map((image) => image.imageName)); + } + }, + }); + + const badges = useMemo(() => { + if (!isSupported) { + return referenceImageCount > 0 ? ( + + {t('widgets.generate.incompatible')} + + ) : null; + } + + if (activeCount > 0) { + return ( + + {t('widgets.generate.activeCount', { count: activeCount })} + + ); + } + + return referenceImageCount > 0 ? ( + + {t('widgets.generate.offCount', { count: referenceImageCount })} + + ) : null; + }, [activeCount, isSupported, referenceImageCount, t]); + + if (!isSupported && referenceImageCount === 0) { + return null; + } + + // Leftover reference images on a model that cannot use them (e.g. a persisted + // project). No editable cards, no add paths — just the way out. + if (!isSupported) { + return ( + + + + {t('widgets.generate.referenceImagesUnsupported')} + + + + + ); + } + + return ( + + + + + + {t('widgets.generate.referenceImagesHelp')} + + {/* The button is disabled (canvas bbox pulls are not wired up yet), so + the tooltip hangs off a wrapper — disabled buttons eat pointer events. */} + + + + + + + + + + {referenceImageCount > 0 ? ( + + {referenceImages.map((referenceImage, index) => ( + + ))} + + ) : null} + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/ReferenceImageCard.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/ReferenceImageCard.tsx new file mode 100644 index 00000000000..74c0cba9502 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/ReferenceImageCard.tsx @@ -0,0 +1,384 @@ +import type { + GenerateModelConfig, + GenerateReferenceImage, + GenerateReferenceImageAsset, + GenerateReferenceImageConfig, +} from '@workbench/generation/types'; +import type { ModelConfig } from '@workbench/models/types'; +import type { CSSProperties } from 'react'; + +import { Box, HStack, Icon, Stack, Text } from '@chakra-ui/react'; +import { IconButton, ToggleDot, Tooltip } from '@workbench/components/ui'; +import { ModelSelect } from '@workbench/models/components'; +import { ChevronDownIcon, CropIcon, ImageIcon, RulerIcon, Trash2Icon } from 'lucide-react'; +import { memo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { formatWeight, getModeLabelKey } from './referenceImageConfig'; +import { ClipVisionSelect, FieldHeader, FluxReduxControls, IPAdapterControls } from './ReferenceImageControls'; +import { ReferenceImageCropDialog } from './ReferenceImageCropDialog'; + +/** Reference image config variants that carry a selectable model. */ +type ModelBackedConfig = Extract< + GenerateReferenceImageConfig, + { type: 'ip_adapter' | 'flux_redux' | 'flux_kontext_reference_image' } +>; + +const THUMBNAIL_ACTIONS_CSS = { + '&:focus-within .reference-image-actions, &:hover .reference-image-actions': { + opacity: 1, + }, +}; + +const FLUX_MODEL_TYPES = ['ip_adapter', 'flux_redux']; +const DEFAULT_MODEL_TYPES = ['ip_adapter']; +const COVER_IMG_STYLE: CSSProperties = { + height: '100%', + objectFit: 'cover', + width: '100%', +}; +const OVERLAY_GRADIENT_STYLE: CSSProperties = { + background: 'linear-gradient(to top, rgba(0, 0, 0, 0.72), transparent)', +}; + +interface ReferenceImageCardProps { + index: number; + referenceImage: GenerateReferenceImage; + selectedModel: GenerateModelConfig | undefined; + onPatch: (id: string, patch: Partial) => void; + onRemove: (id: string) => void; + onUseSize: (image: GenerateReferenceImageAsset) => void; +} + +const ReferenceImageCardBase = ({ + index, + onPatch, + onRemove, + onUseSize, + referenceImage, + selectedModel, +}: ReferenceImageCardProps) => { + const { t } = useTranslation(); + const [isCollapsed, setIsCollapsed] = useState(false); + const config = referenceImage.config; + const isEnabled = referenceImage.isEnabled; + const selectedBase = selectedModel?.base; + const title = `${t('widgets.generate.referenceImage')} #${index + 1}`; + const toggleLabel = isEnabled + ? t('widgets.generate.disableReferenceImage') + : t('widgets.generate.enableReferenceImage'); + + const changeConfig = useCallback( + (nextConfig: GenerateReferenceImageConfig) => onPatch(referenceImage.id, { config: nextConfig }), + [onPatch, referenceImage.id] + ); + + const handleToggle = useCallback( + (nextEnabled: boolean) => onPatch(referenceImage.id, { isEnabled: nextEnabled }), + [onPatch, referenceImage.id] + ); + + const handleRemove = useCallback(() => onRemove(referenceImage.id), [onRemove, referenceImage.id]); + + const handleCrop = useCallback( + (image: GenerateReferenceImageAsset) => changeConfig({ ...config, image }), + [changeConfig, config] + ); + + const expand = useCallback(() => setIsCollapsed(false), []); + + const toggleCollapsed = useCallback(() => setIsCollapsed((previous) => !previous), []); + + return ( + + + + + {isCollapsed ? ( + + + + + {title} + + {config.type === 'ip_adapter' ? ( + <> + {' · '} + {t(getModeLabelKey(config.method))}{' '} + + {formatWeight(config.weight)} + + + ) : null} + + + ) : ( + + {title} + + )} + + + + + + + + + + + + + + + + {isCollapsed ? null : ( + + + + {config.type === 'ip_adapter' ? ( + + + + ) : config.type === 'flux_redux' ? ( + <> + + + + ) : config.type === 'flux_kontext_reference_image' ? ( + + ) : null} + + + )} + + ); +}; + +export const ReferenceImageCard = memo(ReferenceImageCardBase); + +const ReferenceModelSelector = ({ + config, + disabled, + onConfigChange, + selectedBase, +}: { + config: ModelBackedConfig; + disabled: boolean; + selectedBase: string | undefined; + onConfigChange: (config: GenerateReferenceImageConfig) => void; +}) => { + const { t } = useTranslation(); + const modelTypes = selectedBase === 'flux' ? FLUX_MODEL_TYPES : DEFAULT_MODEL_TYPES; + + const filterModel = useCallback((model: ModelConfig) => model.base === selectedBase, [selectedBase]); + + const selectReferenceModel = useCallback( + (model: ModelConfig | null) => { + if (!model || model.base !== selectedBase) { + return; + } + + if (model.type === 'flux_redux') { + onConfigChange({ + image: config.image, + imageInfluence: 'highest', + model, + type: 'flux_redux', + }); + return; + } + + if (model.type === 'ip_adapter') { + onConfigChange({ + beginEndStepPct: [0, 1], + clipVisionModel: selectedBase === 'flux' ? 'ViT-L' : 'ViT-H', + image: config.image, + method: 'full', + model, + type: 'ip_adapter', + weight: 1, + }); + } + }, + [config.image, onConfigChange, selectedBase] + ); + + return ( + + + + + {config.type === 'ip_adapter' ? ( + + ) : null} + + + ); +}; + +const MiniThumbnail = ({ image }: { image: GenerateReferenceImageAsset | null }) => ( + + {image ? ( + {image.imageName} + ) : ( + + + + )} + +); + +const ReferenceImageThumbnail = ({ + disabled, + image, + onCrop, + onUseSize, +}: { + disabled: boolean; + image: GenerateReferenceImageAsset | null; + onCrop: (image: GenerateReferenceImageAsset) => void; + onUseSize: (image: GenerateReferenceImageAsset) => void; +}) => { + const { t } = useTranslation(); + const [isCropOpen, setIsCropOpen] = useState(false); + + const openCrop = useCallback(() => setIsCropOpen(true), []); + const closeCrop = useCallback(() => setIsCropOpen(false), []); + + const handleUseSize = useCallback(() => { + if (image) { + onUseSize(image); + } + }, [image, onUseSize]); + + return ( + <> + + {image ? ( + {image.imageName} + ) : ( + + + + )} + {image ? ( + + + + + + + + + + + + + ) : null} + + {image ? ( + + ) : null} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/ReferenceImageControls.tsx b/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/ReferenceImageControls.tsx new file mode 100644 index 00000000000..f2979eec033 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/generate/reference-images/ReferenceImageControls.tsx @@ -0,0 +1,457 @@ +import type { SelectValueChangeDetails, SliderValueChangeDetails } from '@chakra-ui/react'; +import type { + ClipVisionModel, + FluxReduxImageInfluence, + GenerateReferenceImageConfig, + IPAdapterMethod, +} from '@workbench/generation/types'; +import type { KeyboardEvent, ReactNode } from 'react'; + +import { + Collapsible, + createListCollection, + Flex, + HStack, + NumberInput, + SegmentGroup, + Stack, + Text, +} from '@chakra-ui/react'; +import { Field, Select, Slider } from '@workbench/components/ui'; +import { ChevronRightIcon } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { StyleMethod } from './referenceImageConfig'; + +import { + CLIP_VISION_MODELS, + formatPct, + formatWeight, + getReferenceMode, + isClipVisionModel, + isReferenceMode, + isStyleMethod, + MODE_SEGMENTS, + STYLE_VARIANT_OPTIONS, +} from './referenceImageConfig'; + +type IPAdapterConfig = Extract; +type ClipVisionOption = { label: ClipVisionModel; value: ClipVisionModel }; +type StyleVariantOption = { description: string; label: string; value: StyleMethod }; +type FluxReduxImageInfluenceOption = { label: string; value: FluxReduxImageInfluence }; + +const CLIP_VISION_COLLECTION = createListCollection({ + items: CLIP_VISION_MODELS.map((model) => ({ label: model, value: model })), +}); + +const FLUX_REDUX_IMAGE_INFLUENCE_OPTIONS: FluxReduxImageInfluenceOption[] = [ + { label: 'Lowest', value: 'lowest' }, + { label: 'Low', value: 'low' }, + { label: 'Medium', value: 'medium' }, + { label: 'High', value: 'high' }, + { label: 'Highest', value: 'highest' }, +]; + +const FLUX_REDUX_IMAGE_INFLUENCE_COLLECTION = createListCollection({ + items: FLUX_REDUX_IMAGE_INFLUENCE_OPTIONS, +}); + +const isFluxReduxImageInfluence = (value: unknown): value is FluxReduxImageInfluence => + FLUX_REDUX_IMAGE_INFLUENCE_OPTIONS.some((option) => option.value === value); + +const renderStyleVariantItem = (option: StyleVariantOption) => ( + + + {option.label} + + + {option.description} + + +); + +const WEIGHT_MARKS = [ + { label: '0', value: 0 }, + { label: '1', value: 1 }, + { label: '2', value: 2 }, +]; + +const BEGIN_END_MARKS = [ + { label: '0%', value: 0 }, + { label: '25%', value: 0.25 }, + { label: '50%', value: 0.5 }, + { label: '75%', value: 0.75 }, + { label: '100%', value: 1 }, +]; + +const SEGMENT_CHECKED_STYLES = { color: 'accent.contrast' }; +const ADVANCED_TRIGGER_HOVER_STYLES = { color: 'fg' }; +const COLLAPSIBLE_INDICATOR_OPEN_STYLES = { transform: 'rotate(90deg)' }; + +/** Tiny muted label row above a control, with optional right-aligned content. */ +export const FieldHeader = ({ children, label }: { children?: ReactNode; label: string }) => ( + + + {label} + + {children} + +); + +/** + * Controls for `ip_adapter` reference image configs. `children` renders inside + * the Advanced section (the card passes the model selector through here). + */ +export const IPAdapterControls = ({ + children, + config, + disabled, + onChange, +}: { + children?: ReactNode; + config: IPAdapterConfig; + disabled: boolean; + onChange: (config: GenerateReferenceImageConfig) => void; +}) => { + const { t } = useTranslation(); + const [draftWeight, setDraftWeight] = useState(null); + const [draftBeginEndStepPct, setDraftBeginEndStepPct] = useState<[number, number] | null>(null); + const mode = getReferenceMode(config.method); + const weight = draftWeight ?? config.weight; + const sliderWeight = Math.max(0, weight); + const beginEndStepPct = draftBeginEndStepPct ?? config.beginEndStepPct; + const styleVariantCollection = useMemo( + () => + createListCollection({ + items: STYLE_VARIANT_OPTIONS.map((option) => ({ + description: t(option.descriptionKey), + label: t(option.labelKey), + value: option.value, + })), + }), + [t] + ); + const styleVariantValue = useMemo( + () => [isStyleMethod(config.method) ? config.method : 'style'], + [config.method] + ); + + const commitWeight = useCallback( + (nextWeight: number) => { + setDraftWeight(null); + + if (nextWeight !== config.weight) { + onChange({ ...config, weight: nextWeight }); + } + }, + [config, onChange] + ); + + const commitBeginEndStepPct = useCallback( + (nextBeginEndStepPct: [number, number]) => { + setDraftBeginEndStepPct(null); + + if ( + nextBeginEndStepPct[0] !== config.beginEndStepPct[0] || + nextBeginEndStepPct[1] !== config.beginEndStepPct[1] + ) { + onChange({ ...config, beginEndStepPct: nextBeginEndStepPct }); + } + }, + [config, onChange] + ); + + const handleModeChange = useCallback( + ({ value }: SegmentGroup.ValueChangeDetails) => { + if (!isReferenceMode(value)) { + return; + } + + const nextMethod: IPAdapterMethod = + value === 'style' ? (isStyleMethod(config.method) ? config.method : 'style') : value; + + if (nextMethod !== config.method) { + onChange({ ...config, method: nextMethod }); + } + }, + [config, onChange] + ); + + const handleStyleVariantChange = useCallback( + ({ value }: SelectValueChangeDetails) => { + const nextMethod = value[0] as IPAdapterMethod | undefined; + + if (nextMethod && isStyleMethod(nextMethod)) { + onChange({ ...config, method: nextMethod }); + } + }, + [config, onChange] + ); + + const handleWeightInputChange = useCallback(({ valueAsNumber }: NumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + setDraftWeight(valueAsNumber); + } + }, []); + + const handleWeightInputBlur = useCallback(() => commitWeight(weight), [commitWeight, weight]); + + const handleWeightInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Enter') { + commitWeight(weight); + } + }, + [commitWeight, weight] + ); + + const handleWeightSliderChange = useCallback(({ value }: SliderValueChangeDetails) => { + if (Number.isFinite(value[0])) { + setDraftWeight(value[0] as number); + } + }, []); + + const handleWeightSliderChangeEnd = useCallback( + ({ value }: SliderValueChangeDetails) => { + if (Number.isFinite(value[0])) { + commitWeight(value[0] as number); + } + }, + [commitWeight] + ); + + const handleStepsChange = useCallback(({ value }: SliderValueChangeDetails) => { + const begin = value[0]; + const end = value[1]; + + if (Number.isFinite(begin) && Number.isFinite(end)) { + setDraftBeginEndStepPct([begin as number, end as number]); + } + }, []); + + const handleStepsChangeEnd = useCallback( + ({ value }: SliderValueChangeDetails) => { + const begin = value[0]; + const end = value[1]; + + if (Number.isFinite(begin) && Number.isFinite(end)) { + commitBeginEndStepPct([begin as number, end as number]); + } + }, + [commitBeginEndStepPct] + ); + + const weightSliderValue = useMemo(() => [sliderWeight], [sliderWeight]); + const weightAriaLabel = useMemo(() => [t('widgets.generate.weight')], [t]); + const stepsAriaLabel = useMemo(() => [t('widgets.generate.activeSteps'), t('widgets.generate.activeSteps')], [t]); + + return ( + + + + + + {MODE_SEGMENTS.map((segment) => ( + + {t(segment.labelKey)} + + + ))} + + + + + + + + + + + + + + + + + + + + {t('widgets.generate.advanced')} + + + + {children} + {mode === 'style' ? ( + + + + ); +}; + +export const FluxReduxControls = ({ + config, + disabled, + onChange, +}: { + config: Extract; + disabled: boolean; + onChange: (config: GenerateReferenceImageConfig) => void; +}) => { + const { t } = useTranslation(); + const imageInfluenceValue = useMemo(() => [config.imageInfluence], [config.imageInfluence]); + + const handleValueChange = useCallback( + ({ value }: SelectValueChangeDetails) => { + const imageInfluence = value[0]; + + if (!isFluxReduxImageInfluence(imageInfluence)) { + return; + } + + onChange({ + ...config, + imageInfluence, + }); + }, + [config, onChange] + ); + + return ( + +