diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index b1ab43456e0..c63f0ed04bf 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2205,6 +2205,35 @@ "pullBboxIntoReferenceImageError": "Problem Pulling BBox Into ReferenceImage", "addAdjustments": "Add Adjustments", "removeAdjustments": "Remove Adjustments", + "compositeOperation": { + "label": "Blend Mode", + "add": "Add Blend Mode", + "remove": "Remove Blend Mode", + "blendModes": { + "source-over": "Normal", + "color": "Color", + "hue": "Hue", + "overlay": "Overlay", + "soft-light": "Soft Light", + "hard-light": "Hard Light", + "screen": "Screen", + "color-burn": "Color Burn", + "color-dodge": "Color Dodge", + "multiply": "Multiply", + "darken": "Darken", + "lighten": "Lighten", + "difference": "Difference", + "luminosity": "Luminosity", + "saturation": "Saturation" + } + }, + "booleanOps": { + "label": "Boolean Operations", + "intersect": "Intersect", + "cutout": "Cut Out", + "cutaway": "Cut Away", + "exclude": "Exclude" + }, "adjustments": { "simple": "Simple", "curves": "Curves", @@ -2433,10 +2462,16 @@ "horizontal": "Horizontal", "diagonal": "Diagonal" }, + "gradient": { + "linear": "Linear", + "radial": "Radial", + "clip": "Clip Gradient" + }, "tool": { "brush": "Brush", "eraser": "Eraser", "rectangle": "Rectangle", + "gradient": "Gradient", "bbox": "Bbox", "move": "Move", "view": "View", diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index c80117bf101..e5bccc1f584 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -202,7 +202,8 @@ "unableToLoad": "Impossibile caricare la Galleria", "selectAnImageToCompare": "Seleziona un'immagine da confrontare", "openViewer": "Apri Visualizzatore", - "closeViewer": "Chiudi Visualizzatore" + "closeViewer": "Chiudi Visualizzatore", + "usePagedGalleryView": "Utilizza la visualizzazione Galleria a pagine" }, "hotkeys": { "searchHotkeys": "Cerca tasti di scelta rapida", @@ -804,7 +805,9 @@ "updatePathTooltip": "Aggiorna il percorso del file per questo modello se hai spostato i file del modello in una nuova posizione.", "updatePath": "Aggiorna percorso", "actions": "Azioni in blocco", - "zImageVae": "VAE (opzionale)" + "zImageVae": "VAE (opzionale)", + "missingFiles": "File mancanti", + "missingFilesTooltip": "I file del modello sono mancanti dal disco" }, "parameters": { "images": "Immagini", @@ -1949,6 +1952,20 @@ "Off: generazione standard. Auto: abilita automaticamente per immagini > 1536px. 4K: impostazioni ottimizzate per output con risoluzione 4K." ], "heading": "DyPE (alta risoluzione)" + }, + "fluxDypeScale": { + "paragraphs": [ + "Controlla l'entità della modulazione DyPE. Valori più alti = estrapolazione più forte.", + "Predefinito: 2.0. Intervallo: 0.0-8.0." + ] + }, + "fluxDypeExponent": { + "paragraphs": [ + "Controlla l'intensità dell'effetto dinamico nel tempo.", + "2.0: Consigliato per risoluzioni 4K+. Programmazione aggressiva con transizioni rapide per la pulizia degli artefatti.", + "1.0: Buon punto di partenza per risoluzioni ~2K-3K.", + "0.5: Programma più delicato per risoluzioni appena superiori a quelle native (1024px)." + ] } }, "sdxl": { @@ -2001,7 +2018,6 @@ "vae": "VAE", "parsingFailed": "Analisi non riuscita", "recallParameter": "Richiama {{label}}", - "dypePreset": "Preimpostazione DyPE", "seedVarianceRandomizePercent": "Casualità della varianza del seme %", "seedVarianceEnabled": "Varianza seme abilitata", "seedVarianceStrength": "Intensità della varianza del seme" diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx index 2eb664e0287..afc663122fd 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx @@ -1,32 +1,17 @@ -import { Flex, Spacer } from '@invoke-ai/ui-library'; -import { EntityListGlobalActionBarAddLayerMenu } from 'features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu'; -import { EntityListSelectedEntityActionBarDuplicateButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton'; +import { Flex } from '@invoke-ai/ui-library'; import { EntityListSelectedEntityActionBarFill } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill'; -import { EntityListSelectedEntityActionBarFilterButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton'; -import { EntityListSelectedEntityActionBarInvertMaskButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarInvertMaskButton'; import { EntityListSelectedEntityActionBarOpacity } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity'; -import { EntityListSelectedEntityActionBarSelectObjectButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton'; -import { EntityListSelectedEntityActionBarTransformButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton'; -import { EntityListNonRasterLayerToggle } from 'features/controlLayers/components/common/CanvasNonRasterLayersIsHiddenToggle'; import { memo } from 'react'; -import { EntityListSelectedEntityActionBarSaveToAssetsButton } from './EntityListSelectedEntityActionBarSaveToAssetsButton'; +import { EntityListSelectedEntityActionBarCompositeOperation } from './EntityListSelectedEntityActionBarCompositeOperation'; export const EntityListSelectedEntityActionBar = memo(() => { return ( - - - - - - - - - - - - - + + + + + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx new file mode 100644 index 00000000000..f82dd530c62 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarCompositeOperation.tsx @@ -0,0 +1,74 @@ +import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; +import { + selectCanvasSlice, + selectEntity, + selectSelectedEntityIdentifier, +} from 'features/controlLayers/store/selectors'; +import type { + CanvasEntityIdentifier, + CanvasRasterLayerState, + CompositeOperation, +} from 'features/controlLayers/store/types'; +import { COLOR_BLEND_MODES } from 'features/controlLayers/store/types'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +const selectCompositeOperation = createSelector(selectCanvasSlice, (canvas) => { + const { selectedEntityIdentifier } = canvas; + + if (selectedEntityIdentifier?.type !== 'raster_layer') { + return 'source-over'; + } + + const entity = selectEntity(canvas, selectedEntityIdentifier); + + return (entity as CanvasRasterLayerState)?.globalCompositeOperation ?? 'source-over'; +}); + +export const EntityListSelectedEntityActionBarCompositeOperation = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const currentOperation = useAppSelector(selectCompositeOperation); + + const onChange = useCallback( + (e: ChangeEvent) => { + if (selectedEntityIdentifier?.type === 'raster_layer') { + const value = e.target.value as CompositeOperation; + + dispatch( + rasterLayerGlobalCompositeOperationChanged({ + entityIdentifier: selectedEntityIdentifier as CanvasEntityIdentifier<'raster_layer'>, + globalCompositeOperation: value, + }) + ); + } + }, + [dispatch, selectedEntityIdentifier] + ); + + if (selectedEntityIdentifier?.type !== 'raster_layer') { + return null; + } + + return ( + + + {t('controlLayers.compositeOperation.label')} + + + + ); +}); + +EntityListSelectedEntityActionBarCompositeOperation.displayName = 'EntityListSelectedEntityActionBarCompositeOperation'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx index 7e38cdd9fda..1302e6a8834 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx @@ -132,7 +132,9 @@ export const EntityListSelectedEntityActionBarOpacity = memo(() => { return ( - {t('controlLayers.opacity')} + + {t('controlLayers.opacity')} + { + return ( + + + + + + + + + + + ); +}); + +EntityListSelectedEntityOperationsBar.displayName = 'EntityListSelectedEntityOperationsBar'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx index 3b28be2799a..4dd08edcd58 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx @@ -7,6 +7,7 @@ import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/Canva import { selectHasEntities } from 'features/controlLayers/store/selectors'; import { memo } from 'react'; +import { EntityListSelectedEntityOperationsBar } from './CanvasEntityList/EntityListSelectedEntityOperationsBar'; import { ParamDenoisingStrength } from './ParamDenoisingStrength'; export const CanvasLayersPanel = memo(() => { @@ -15,12 +16,14 @@ export const CanvasLayersPanel = memo(() => { return ( - - + + {!hasEntities && } {hasEntities && } + + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx index 708f7f29cd6..fd3f56a03ad 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -10,6 +10,7 @@ import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/com import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSelectObject'; import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; import { RasterLayerMenuItemsAdjustments } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments'; +import { RasterLayerMenuItemsBooleanSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu'; import { RasterLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu'; import { RasterLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu'; import { memo } from 'react'; @@ -28,6 +29,7 @@ export const RasterLayerMenuItems = memo(() => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx new file mode 100644 index 00000000000..c321317a34e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -0,0 +1,75 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityIdentifierBelowThisOne } from 'features/controlLayers/hooks/useNextRenderableEntityIdentifier'; +import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasEntityIdentifier, CompositeOperation } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CgPathBack, CgPathCrop, CgPathExclude, CgPathFront, CgPathIntersect } from 'react-icons/cg'; + +export const RasterLayerMenuItemsBooleanSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + const canvasManager = useCanvasManager(); + const isBusy = useCanvasIsBusy(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const entityIdentifierBelowThisOne = useEntityIdentifierBelowThisOne(entityIdentifier as CanvasEntityIdentifier); + + const perform = useCallback( + async (op: CompositeOperation) => { + if (!entityIdentifierBelowThisOne) { + return; + } + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: op })); + try { + // Use boolean-specific merge which disables the source raster layers instead of deleting them + await canvasManager.compositor.mergeBooleanRasterLayers( + entityIdentifierBelowThisOne as CanvasEntityIdentifier<'raster_layer'>, + entityIdentifier as CanvasEntityIdentifier<'raster_layer'>, + true + ); + } finally { + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); + } + }, + [canvasManager.compositor, dispatch, entityIdentifier, entityIdentifierBelowThisOne] + ); + + const onIntersect = useCallback(() => perform('source-in'), [perform]); + const onCutOut = useCallback(() => perform('destination-in'), [perform]); + const onCutAway = useCallback(() => perform('source-out'), [perform]); + const onExclude = useCallback(() => perform('xor'), [perform]); + + const disabled = isBusy || !entityIdentifierBelowThisOne; + + return ( + }> + + + + + + }> + {t('controlLayers.booleanOps.intersect')} + + }> + {t('controlLayers.booleanOps.cutout')} + + }> + {t('controlLayers.booleanOps.cutaway')} + + }> + {t('controlLayers.booleanOps.exclude')} + + + + + ); +}); + +RasterLayerMenuItemsBooleanSubMenu.displayName = 'RasterLayerMenuItemsBooleanSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx new file mode 100644 index 00000000000..b09e46d7320 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/GradientIcons.tsx @@ -0,0 +1,81 @@ +import { Box } from '@invoke-ai/ui-library'; +import { memo, useId } from 'react'; + +export const GradientToolIcon = memo(() => { + const id = useId(); + const gradientId = `${id}-gradient-tool-horizontal`; + return ( + + + + + + + + + + ); +}); +GradientToolIcon.displayName = 'GradientToolIcon'; + +export const GradientLinearIcon = memo(() => { + const id = useId(); + const gradientId = `${id}-gradient-linear-diagonal`; + return ( + + + + + + + + + + ); +}); +GradientLinearIcon.displayName = 'GradientLinearIcon'; + +export const GradientRadialIcon = memo(() => { + const id = useId(); + const gradientId = `${id}-gradient-radial`; + return ( + + + + + + + + + + ); +}); +GradientRadialIcon.displayName = 'GradientRadialIcon'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx index d2b35ced8ac..b0652210007 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolChooser.tsx @@ -2,6 +2,7 @@ import { ButtonGroup } from '@invoke-ai/ui-library'; import { ToolBboxButton } from 'features/controlLayers/components/Tool/ToolBboxButton'; import { ToolBrushButton } from 'features/controlLayers/components/Tool/ToolBrushButton'; import { ToolColorPickerButton } from 'features/controlLayers/components/Tool/ToolColorPickerButton'; +import { ToolGradientButton } from 'features/controlLayers/components/Tool/ToolGradientButton'; import { ToolMoveButton } from 'features/controlLayers/components/Tool/ToolMoveButton'; import { ToolRectButton } from 'features/controlLayers/components/Tool/ToolRectButton'; import React from 'react'; @@ -16,6 +17,7 @@ export const ToolChooser: React.FC = () => { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx new file mode 100644 index 00000000000..32275399180 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientButton.tsx @@ -0,0 +1,30 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { GradientToolIcon } from 'features/controlLayers/components/Tool/GradientIcons'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const ToolGradientButton = memo(() => { + const { t } = useTranslation(); + const isSelected = useToolIsSelected('gradient'); + const selectGradient = useSelectTool('gradient'); + // clicking selects the gradient tool; mode switching is handled in the top toolbar + const handleClick = useCallback(() => selectGradient(), [selectGradient]); + + const gradientLabel = t('controlLayers.tool.gradient', { defaultValue: 'Gradient' }); + + return ( + + } + isActive={isSelected} + colorScheme={isSelected ? 'invokeBlue' : 'base'} + variant="solid" + onClick={handleClick} + /> + + ); +}); + +ToolGradientButton.displayName = 'ToolGradientButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientClipToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientClipToggle.tsx new file mode 100644 index 00000000000..a89ef6a470b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientClipToggle.tsx @@ -0,0 +1,36 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + selectGradientClipEnabled, + settingsGradientClipToggled, +} from 'features/controlLayers/store/canvasSettingsSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCropBold } from 'react-icons/pi'; + +export const ToolGradientClipToggle = memo(() => { + const { t } = useTranslation(); + const isEnabled = useAppSelector(selectGradientClipEnabled); + const dispatch = useAppDispatch(); + + const onClick = useCallback(() => { + dispatch(settingsGradientClipToggled()); + }, [dispatch]); + + const label = t('controlLayers.gradient.clip', { defaultValue: 'Clip Gradient' }); + + return ( + + } + size="sm" + variant="solid" + colorScheme={isEnabled ? 'invokeBlue' : 'base'} + onClick={onClick} + /> + + ); +}); + +ToolGradientClipToggle.displayName = 'ToolGradientClipToggle'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientModeToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientModeToggle.tsx new file mode 100644 index 00000000000..03b18c466ee --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolGradientModeToggle.tsx @@ -0,0 +1,40 @@ +import { ButtonGroup, IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { GradientLinearIcon, GradientRadialIcon } from 'features/controlLayers/components/Tool/GradientIcons'; +import { selectGradientType, settingsGradientTypeChanged } from 'features/controlLayers/store/canvasSettingsSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const ToolGradientModeToggle = memo(() => { + const { t } = useTranslation(); + const gradientType = useAppSelector(selectGradientType); + const dispatch = useAppDispatch(); + + const onLinearClick = useCallback(() => dispatch(settingsGradientTypeChanged('linear')), [dispatch]); + const onRadialClick = useCallback(() => dispatch(settingsGradientTypeChanged('radial')), [dispatch]); + + return ( + + + } + colorScheme={gradientType === 'linear' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onLinearClick} + /> + + + } + colorScheme={gradientType === 'radial' ? 'invokeBlue' : 'base'} + variant="solid" + onClick={onRadialClick} + /> + + + ); +}); + +ToolGradientModeToggle.displayName = 'ToolGradientModeToggle'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx index 979eea95d76..91976cb55d6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Toolbar/CanvasToolbar.tsx @@ -1,7 +1,9 @@ -import { Divider, Flex } from '@invoke-ai/ui-library'; +import { Box, Divider, Flex } from '@invoke-ai/ui-library'; import { CanvasSettingsPopover } from 'features/controlLayers/components/Settings/CanvasSettingsPopover'; import { useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; import { ToolFillColorPicker } from 'features/controlLayers/components/Tool/ToolFillColorPicker'; +import { ToolGradientClipToggle } from 'features/controlLayers/components/Tool/ToolGradientClipToggle'; +import { ToolGradientModeToggle } from 'features/controlLayers/components/Tool/ToolGradientModeToggle'; import { ToolWidthPicker } from 'features/controlLayers/components/Tool/ToolWidthPicker'; import { CanvasToolbarFitBboxToLayersButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToLayersButton'; import { CanvasToolbarFitBboxToMasksButton } from 'features/controlLayers/components/Toolbar/CanvasToolbarFitBboxToMasksButton'; @@ -26,6 +28,7 @@ import { memo, useMemo } from 'react'; export const CanvasToolbar = memo(() => { const isBrushSelected = useToolIsSelected('brush'); const isEraserSelected = useToolIsSelected('eraser'); + const isGradientSelected = useToolIsSelected('gradient'); const showToolWithPicker = useMemo(() => { return isBrushSelected || isEraserSelected; }, [isBrushSelected, isEraserSelected]); @@ -45,6 +48,12 @@ export const CanvasToolbar = memo(() => { + {isGradientSelected && ( + + + + + )} {showToolWithPicker && } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 3f419387439..9638c771a1c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -24,6 +24,7 @@ import type { CanvasEntityIdentifier, CanvasEntityState, CanvasEntityType, + CompositeOperation, GenerationMode, Rect, } from 'features/controlLayers/store/types'; @@ -45,8 +46,9 @@ type CompositingOptions = { /** * The global composite operation to use when compositing each entity. * See: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation + * Invoke supports a subset of these modes for raster and control layer combinations. */ - globalCompositeOperation?: GlobalCompositeOperation; + globalCompositeOperation?: CompositeOperation; }; /** @@ -226,12 +228,16 @@ export class CanvasCompositorModule extends CanvasModuleBase { ctx.imageSmoothingEnabled = false; - if (compositingOptions?.globalCompositeOperation) { - ctx.globalCompositeOperation = compositingOptions.globalCompositeOperation; - } - for (const adapter of adapters) { this.log.debug({ entityIdentifier: adapter.entityIdentifier }, 'Drawing entity to composite canvas'); + // Set composite operation for this specific layer + // Priority: 1) Per-layer setting, 2) Global compositing option, 3) Default 'source-over' + const layerCompositeOp = + adapter.state.type === 'raster_layer' || adapter.state.type === 'control_layer' + ? adapter.state.globalCompositeOperation + : undefined; + ctx.globalCompositeOperation = layerCompositeOp || compositingOptions?.globalCompositeOperation || 'source-over'; + const adapterCanvas = adapter.getCanvas(rect); ctx.drawImage(adapterCanvas, 0, 0); } @@ -397,6 +403,59 @@ export class CanvasCompositorModule extends CanvasModuleBase { return result.value; }; + /** + * Performs a boolean merge specifically for raster layers. + * Creates a new raster layer with the composite result and disables the source layers instead of deleting them. + */ + mergeBooleanRasterLayers = async ( + below: CanvasEntityIdentifier<'raster_layer'>, + top: CanvasEntityIdentifier<'raster_layer'>, + disableSources = true + ): Promise => { + toast({ id: 'MERGE_LAYERS_TOAST', title: t('controlLayers.mergingLayers'), withCount: false }); + + const adapters = this.manager.getAdapters([below, top]); + assert(adapters.length === 2, 'Failed to get adapters for boolean merge'); + + const rect = this.getRectOfAdapters(adapters); + + const compositingOptions: CompositingOptions = { + globalCompositeOperation: 'source-over', + }; + + const result = await withResultAsync(() => + this.getCompositeImageDTO(adapters, rect, { is_intermediate: true }, compositingOptions) + ); + + if (result.isErr()) { + this.log.error({ error: serializeError(result.error) }, 'Failed to boolean merge raster layers'); + toast({ + id: 'MERGE_LAYERS_TOAST', + title: t('controlLayers.mergeVisibleError'), + status: 'error', + withCount: false, + }); + return null; + } + + // Add new raster layer while disabling the merged sources + const addEntityArg = { + isSelected: true, + overrides: { + objects: [imageDTOToImageObject(result.value)], + position: { x: Math.floor(rect.x), y: Math.floor(rect.y) }, + }, + mergedEntitiesToDisable: disableSources ? [below.id, top.id] : [], + addAfter: top.id, + }; + + this.manager.stateApi.addRasterLayer(addEntityArg); + + toast({ id: 'MERGE_LAYERS_TOAST', title: t('controlLayers.mergeVisibleOk'), status: 'success', withCount: false }); + + return result.value; + }; + /** * Merges all visible entities of the given type. This is used for "merge visible" functionality. * diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts index 8723664d258..f1b50cabca7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts @@ -11,6 +11,26 @@ import type { CanvasEntityIdentifier, CanvasRasterLayerState, Rect } from 'featu import type { GroupConfig } from 'konva/lib/Group'; import type { JsonObject } from 'type-fest'; +// Map globalCompositeOperation to CSS mix-blend-mode for live preview +const mixBlendModeMap: Record = { + 'source-over': 'normal', // this one is why we need the map + multiply: 'multiply', + screen: 'screen', + overlay: 'overlay', + darken: 'darken', + lighten: 'lighten', + 'color-dodge': 'color-dodge', + 'color-burn': 'color-burn', + 'hard-light': 'hard-light', + 'soft-light': 'soft-light', + difference: 'difference', + exclusion: 'exclusion', + hue: 'hue', + saturation: 'saturation', + color: 'color', + luminosity: 'luminosity', +}; + export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< CanvasRasterLayerState, 'raster_layer_adapter' @@ -60,6 +80,9 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< if (!prevState || this.state.opacity !== prevState.opacity) { this.syncOpacity(); } + if (!prevState || this.state.globalCompositeOperation !== prevState.globalCompositeOperation) { + this.syncGlobalCompositeOperation(); + } // Apply per-layer adjustments as a Konva filter if (!prevState || this.haveAdjustmentsChanged(prevState, this.state)) { @@ -147,7 +170,7 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< ) { return true; } - // curves reference (UI not implemented yet) - if arrays differ by ref, consider changed + // curves params const pc = pa.curves; const cc = ca.curves; if (pc !== cc) { @@ -155,4 +178,14 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< } return false; }; + + private syncGlobalCompositeOperation = () => { + this.log.trace('Syncing globalCompositeOperation'); + const operation = this.state.globalCompositeOperation ?? 'source-over'; + const mixBlendMode = mixBlendModeMap[operation] || 'normal'; + const canvasElement = this.konva.layer.getCanvas()._canvas as HTMLCanvasElement | undefined; + if (canvasElement) { + canvasElement.style.mixBlendMode = mixBlendMode; + } + }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts index 2dc5bdab26a..ef5cee8d897 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer.ts @@ -6,6 +6,7 @@ import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject import { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure'; import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine'; import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure'; +import { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect'; import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types'; @@ -151,6 +152,15 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase { this.konva.group.add(this.renderer.konva.group); } + didRender = this.renderer.update(this.state, true); + } else if (this.state.type === 'gradient') { + assert(this.renderer instanceof CanvasObjectGradient || !this.renderer); + + if (!this.renderer) { + this.renderer = new CanvasObjectGradient(this.state, this); + this.konva.group.add(this.renderer.konva.group); + } + didRender = this.renderer.update(this.state, true); } else if (this.state.type === 'image') { assert(this.renderer instanceof CanvasObjectImage || !this.renderer); @@ -237,6 +247,9 @@ export class CanvasEntityBufferObjectRenderer extends CanvasModuleBase { case 'rect': this.manager.stateApi.addRect({ entityIdentifier, rect: this.state }); break; + case 'gradient': + this.manager.stateApi.addGradient({ entityIdentifier, gradient: this.state }); + break; } } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts index 114542cdb9b..b33a4aaa87a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts @@ -8,6 +8,7 @@ import { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasObject import { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure'; import { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine'; import { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure'; +import { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient'; import { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; import { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect'; import type { AnyObjectRenderer, AnyObjectState } from 'features/controlLayers/konva/CanvasObject/types'; @@ -400,6 +401,16 @@ export class CanvasEntityObjectRenderer extends CanvasModuleBase { this.konva.objectGroup.add(renderer.konva.group); } + didRender = renderer.update(objectState, force || isFirstRender); + } else if (objectState.type === 'gradient') { + assert(renderer instanceof CanvasObjectGradient || !renderer); + + if (!renderer) { + renderer = new CanvasObjectGradient(objectState, this); + this.renderers.set(renderer.id, renderer); + this.konva.objectGroup.add(renderer.konva.group); + } + didRender = renderer.update(objectState, force || isFirstRender); } else if (objectState.type === 'image') { assert(renderer instanceof CanvasObjectImage || !renderer); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts new file mode 100644 index 00000000000..3dfb281c664 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectGradient.ts @@ -0,0 +1,149 @@ +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import { deepClone } from 'common/util/deepClone'; +import type { CanvasEntityBufferObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityBufferObjectRenderer'; +import type { CanvasEntityObjectRenderer } from 'features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer'; +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import type { CanvasGradientState } from 'features/controlLayers/store/types'; +import Konva from 'konva'; +import type { Logger } from 'roarr'; + +export class CanvasObjectGradient extends CanvasModuleBase { + readonly type = 'object_gradient'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer; + readonly manager: CanvasManager; + readonly log: Logger; + + state: CanvasGradientState; + konva: { + group: Konva.Group; + rect: Konva.Rect; + }; + isFirstRender: boolean = false; + + constructor(state: CanvasGradientState, parent: CanvasEntityObjectRenderer | CanvasEntityBufferObjectRenderer) { + super(); + this.id = state.id; + this.parent = parent; + this.manager = parent.manager; + this.path = this.manager.buildPath(this); + this.log = this.manager.buildLogger(this); + + this.log.debug({ state }, 'Creating module'); + + this.konva = { + group: new Konva.Group({ name: `${this.type}:group`, listening: false }), + rect: new Konva.Rect({ name: `${this.type}:rect`, listening: false, perfectDrawEnabled: false }), + }; + this.konva.group.add(this.konva.rect); + this.state = state; + } + + update(state: CanvasGradientState, force = false): boolean { + if (force || this.state !== state) { + this.isFirstRender = false; + + const { rect, fgColor, bgColor } = state; + const fg = rgbaColorToString(fgColor); + const bg = rgbaColorToString(bgColor); + + this.log.trace({ state }, 'Updating gradient'); + this.konva.rect.setAttrs({ + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }); + + this.konva.group.clipFunc((ctx) => { + ctx.beginPath(); + ctx.rect(state.bboxRect.x, state.bboxRect.y, state.bboxRect.width, state.bboxRect.height); + ctx.clip(); + if (state.clipEnabled) { + ctx.beginPath(); + if (state.gradientType === 'linear') { + const startX = state.rect.x + state.start.x; + const startY = state.rect.y + state.start.y; + const endX = state.rect.x + state.end.x; + const endY = state.rect.y + state.end.y; + const dirX = Math.cos(state.clipAngle); + const dirY = Math.sin(state.clipAngle); + const perpX = -dirY; + const perpY = dirX; + const clipExtent = Math.max(Math.hypot(state.bboxRect.width, state.bboxRect.height) * 2, 1); + const drawHalfPlane = (originX: number, originY: number, dirSign: number) => { + const dX = dirX * dirSign; + const dY = dirY * dirSign; + const p1x = originX + perpX * clipExtent; + const p1y = originY + perpY * clipExtent; + const p2x = originX - perpX * clipExtent; + const p2y = originY - perpY * clipExtent; + const p3x = p2x + dX * clipExtent; + const p3y = p2y + dY * clipExtent; + const p4x = p1x + dX * clipExtent; + const p4y = p1y + dY * clipExtent; + ctx.moveTo(p1x, p1y); + ctx.lineTo(p2x, p2y); + ctx.lineTo(p3x, p3y); + ctx.lineTo(p4x, p4y); + ctx.closePath(); + }; + drawHalfPlane(startX, startY, 1); + ctx.clip(); + ctx.beginPath(); + drawHalfPlane(endX, endY, -1); + } else { + ctx.arc(state.clipCenter.x, state.clipCenter.y, state.clipRadius, 0, Math.PI * 2); + } + ctx.clip(); + } + }); + + if (state.gradientType === 'linear') { + this.konva.rect.setAttrs({ + fillPriority: 'linear-gradient', + fillLinearGradientStartPoint: { x: state.start.x, y: state.start.y }, + fillLinearGradientEndPoint: { x: state.end.x, y: state.end.y }, + fillLinearGradientColorStops: [0, fg, 1, bg], + }); + } else { + this.konva.rect.setAttrs({ + fillPriority: 'radial-gradient', + fillRadialGradientStartPoint: { x: state.center.x, y: state.center.y }, + fillRadialGradientEndPoint: { x: state.center.x, y: state.center.y }, + fillRadialGradientStartRadius: 0, + fillRadialGradientEndRadius: state.radius, + fillRadialGradientColorStops: [0, fg, 1, bg], + }); + } + + this.state = state; + return true; + } + + return false; + } + + setVisibility(isVisible: boolean): void { + this.log.trace({ isVisible }, 'Setting gradient visibility'); + this.konva.group.visible(isVisible); + } + + destroy = () => { + this.log.debug('Destroying module'); + this.konva.group.destroy(); + }; + + repr = () => { + return { + id: this.id, + type: this.type, + path: this.path, + parent: this.parent.id, + isFirstRender: this.isFirstRender, + state: deepClone(this.state), + }; + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts index eb17bf7e136..31a2bfee078 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/types.ts @@ -2,6 +2,7 @@ import type { CanvasObjectBrushLine } from 'features/controlLayers/konva/CanvasO import type { CanvasObjectBrushLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure'; import type { CanvasObjectEraserLine } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLine'; import type { CanvasObjectEraserLineWithPressure } from 'features/controlLayers/konva/CanvasObject/CanvasObjectEraserLineWithPressure'; +import type { CanvasObjectGradient } from 'features/controlLayers/konva/CanvasObject/CanvasObjectGradient'; import type { CanvasObjectImage } from 'features/controlLayers/konva/CanvasObject/CanvasObjectImage'; import type { CanvasObjectRect } from 'features/controlLayers/konva/CanvasObject/CanvasObjectRect'; import type { @@ -9,6 +10,7 @@ import type { CanvasBrushLineWithPressureState, CanvasEraserLineState, CanvasEraserLineWithPressureState, + CanvasGradientState, CanvasImageState, CanvasRectState, } from 'features/controlLayers/store/types'; @@ -23,7 +25,9 @@ export type AnyObjectRenderer = | CanvasObjectEraserLine | CanvasObjectEraserLineWithPressure | CanvasObjectRect - | CanvasObjectImage; /** + | CanvasObjectImage + | CanvasObjectGradient; +/** * Union of all object states. */ export type AnyObjectState = @@ -32,4 +36,5 @@ export type AnyObjectState = | CanvasEraserLineState | CanvasEraserLineWithPressureState | CanvasImageState - | CanvasRectState; + | CanvasRectState + | CanvasGradientState; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts index dc2c0b15043..e00ade1f8bb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasStateApiModule.ts @@ -20,6 +20,7 @@ import { controlLayerAdded, entityBrushLineAdded, entityEraserLineAdded, + entityGradientAdded, entityMovedBy, entityMovedTo, entityRasterized, @@ -40,6 +41,7 @@ import type { CanvasState, EntityBrushLineAddedPayload, EntityEraserLineAddedPayload, + EntityGradientAddedPayload, EntityIdentifierPayload, EntityMovedByPayload, EntityMovedToPayload, @@ -173,6 +175,13 @@ export class CanvasStateApiModule extends CanvasModuleBase { this.store.dispatch(entityRectAdded(arg)); }; + /** + * Adds a gradient to an entity, pushing state to redux. + */ + addGradient = (arg: EntityGradientAddedPayload) => { + this.store.dispatch(entityGradientAdded(arg)); + }; + /** * Adds a raster layer to the canvas, pushing state to redux. */ diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts new file mode 100644 index 00000000000..745a5417355 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasGradientToolModule.ts @@ -0,0 +1,219 @@ +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule'; +import { getPrefixedId, offsetCoord } from 'features/controlLayers/konva/util'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import type { Logger } from 'roarr'; + +export class CanvasGradientToolModule extends CanvasModuleBase { + readonly type = 'gradient_tool'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasToolModule; + readonly manager: CanvasManager; + readonly log: Logger; + + private startPoint: { x: number; y: number } | null = null; + private lastPoint: { x: number; y: number } | null = null; + private gradientId: string | null = null; + + constructor(parent: CanvasToolModule) { + super(); + this.id = getPrefixedId(this.type); + this.parent = parent; + this.manager = this.parent.manager; + this.path = this.manager.buildPath(this); + this.log = this.manager.buildLogger(this); + + this.log.debug('Creating module'); + } + + syncCursorStyle = () => { + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + if (!selectedEntity || selectedEntity.state.type !== 'raster_layer') { + this.manager.stage.setCursor('not-allowed'); + return; + } + this.manager.stage.setCursor('crosshair'); + }; + + onStagePointerDown = async (_e: KonvaEventObject) => { + const cursorPos = this.parent.$cursorPos.get(); + const isPrimaryPointerDown = this.parent.$isPrimaryPointerDown.get(); + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + + if (!cursorPos || !isPrimaryPointerDown || !selectedEntity || selectedEntity.state.type !== 'raster_layer') { + return; + } + + if (selectedEntity.bufferRenderer.hasBuffer()) { + selectedEntity.bufferRenderer.commitBuffer(); + } + + const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); + this.startPoint = normalizedPoint; + this.lastPoint = normalizedPoint; + this.gradientId = getPrefixedId('gradient'); + + await this.updateGradientBuffer(normalizedPoint, normalizedPoint); + }; + + onStagePointerMove = async (_e: KonvaEventObject) => { + const cursorPos = this.parent.$cursorPos.get(); + + if (!cursorPos || !this.parent.$isPrimaryPointerDown.get()) { + return; + } + + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + + if (!selectedEntity || selectedEntity.state.type !== 'raster_layer') { + return; + } + + const bufferState = selectedEntity.bufferRenderer.state; + if (!bufferState || bufferState.type !== 'gradient' || !this.startPoint) { + return; + } + + const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position); + this.lastPoint = normalizedPoint; + await this.updateGradientBuffer(this.startPoint, normalizedPoint); + }; + + onStagePointerUp = (_e: KonvaEventObject) => { + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + if (!selectedEntity) { + this.resetState(); + return; + } + + const shouldCommit = this.startPoint && this.lastPoint && this.getRadius(this.startPoint, this.lastPoint) > 1; + if (selectedEntity.bufferRenderer.state?.type === 'gradient' && selectedEntity.bufferRenderer.hasBuffer()) { + if (shouldCommit) { + selectedEntity.bufferRenderer.commitBuffer(); + } else { + selectedEntity.bufferRenderer.clearBuffer(); + } + } + + this.resetState(); + }; + + private resetState = () => { + this.startPoint = null; + this.lastPoint = null; + this.gradientId = null; + }; + + private getRadius = (start: { x: number; y: number }, end: { x: number; y: number }) => { + return Math.hypot(end.x - start.x, end.y - start.y); + }; + + private updateGradientBuffer = async (start: { x: number; y: number }, end: { x: number; y: number }) => { + const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); + if (!selectedEntity || selectedEntity.state.type !== 'raster_layer') { + return; + } + + const settings = this.manager.stateApi.getSettings(); + const { rect: bboxRect } = this.manager.stateApi.getBbox(); + const bboxInLayer = { + x: bboxRect.x - selectedEntity.state.position.x, + y: bboxRect.y - selectedEntity.state.position.y, + width: bboxRect.width, + height: bboxRect.height, + }; + const radius = this.getRadius(start, end); + const angle = Math.atan2(end.y - start.y, end.x - start.x); + let rect = { + x: start.x - radius, + y: start.y - radius, + width: radius * 2, + height: radius * 2, + }; + + if (settings.gradientType === 'linear') { + const bboxCenter = { + x: bboxInLayer.x + bboxInLayer.width / 2, + y: bboxInLayer.y + bboxInLayer.height / 2, + }; + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const halfWidth = (Math.abs(bboxInLayer.width * cos) + Math.abs(bboxInLayer.height * sin)) / 2; + const halfHeight = (Math.abs(bboxInLayer.width * sin) + Math.abs(bboxInLayer.height * cos)) / 2; + rect = { + x: bboxCenter.x - halfWidth, + y: bboxCenter.y - halfHeight, + width: Math.max(halfWidth * 2, 1), + height: Math.max(halfHeight * 2, 1), + }; + } + + const startInRect = offsetCoord(start, { x: rect.x, y: rect.y }); + const endInRect = offsetCoord(end, { x: rect.x, y: rect.y }); + + const id = this.gradientId ?? getPrefixedId('gradient'); + if (!this.gradientId) { + this.gradientId = id; + } + + const activeColor = settings.activeColor === 'bgColor' ? settings.bgColor : settings.fgColor; + const inactiveColor = settings.activeColor === 'bgColor' ? settings.fgColor : settings.bgColor; + const clipEnabled = settings.gradientClipEnabled; + + if (settings.gradientType === 'radial') { + let radialRect = rect; + let radialCenter = startInRect; + let radialRadius = Math.max(1, radius); + if (!clipEnabled) { + radialRect = bboxInLayer; + radialCenter = offsetCoord(start, { x: radialRect.x, y: radialRect.y }); + } + await selectedEntity.bufferRenderer.setBuffer({ + id, + type: 'gradient', + gradientType: 'radial', + rect: radialRect, + center: radialCenter, + radius: radialRadius, + clipCenter: start, + clipRadius: Math.max(1, radius), + clipEnabled, + bboxRect: bboxInLayer, + fgColor: activeColor, + bgColor: inactiveColor, + }); + } else { + const endPoint = { + x: endInRect.x === startInRect.x && endInRect.y === startInRect.y ? endInRect.x + 1 : endInRect.x, + y: endInRect.x === startInRect.x && endInRect.y === startInRect.y ? endInRect.y : endInRect.y, + }; + await selectedEntity.bufferRenderer.setBuffer({ + id, + type: 'gradient', + gradientType: 'linear', + rect, + start: startInRect, + end: endPoint, + clipCenter: start, + clipRadius: Math.max(1, radius), + clipAngle: angle, + clipEnabled, + bboxRect: bboxInLayer, + fgColor: activeColor, + bgColor: inactiveColor, + }); + } + }; + + repr = () => { + return { + id: this.id, + type: this.type, + path: this.path, + startPoint: this.startPoint, + gradientId: this.gradientId, + }; + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts index b9b8adae9b8..1477f1b097c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts @@ -4,6 +4,7 @@ import { CanvasBboxToolModule } from 'features/controlLayers/konva/CanvasTool/Ca import { CanvasBrushToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasBrushToolModule'; import { CanvasColorPickerToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasColorPickerToolModule'; import { CanvasEraserToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasEraserToolModule'; +import { CanvasGradientToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasGradientToolModule'; import { CanvasMoveToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasMoveToolModule'; import { CanvasRectToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasRectToolModule'; import { CanvasViewToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasViewToolModule'; @@ -60,6 +61,7 @@ export class CanvasToolModule extends CanvasModuleBase { brush: CanvasBrushToolModule; eraser: CanvasEraserToolModule; rect: CanvasRectToolModule; + gradient: CanvasGradientToolModule; colorPicker: CanvasColorPickerToolModule; bbox: CanvasBboxToolModule; view: CanvasViewToolModule; @@ -117,6 +119,7 @@ export class CanvasToolModule extends CanvasModuleBase { brush: new CanvasBrushToolModule(this), eraser: new CanvasEraserToolModule(this), rect: new CanvasRectToolModule(this), + gradient: new CanvasGradientToolModule(this), colorPicker: new CanvasColorPickerToolModule(this), bbox: new CanvasBboxToolModule(this), view: new CanvasViewToolModule(this), @@ -194,6 +197,8 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.move.syncCursorStyle(); } else if (tool === 'rect') { this.tools.rect.syncCursorStyle(); + } else if (tool === 'gradient') { + this.tools.gradient.syncCursorStyle(); } } else if (this.manager.stateApi.getRenderedEntityCount() === 0) { stage.setCursor('not-allowed'); @@ -336,7 +341,11 @@ export class CanvasToolModule extends CanvasModuleBase { const tool = this.$tool.get(); const selectedEntity = this.manager.stateApi.getSelectedEntityAdapter(); - if (selectedEntity?.bufferRenderer.state?.type !== 'rect' && selectedEntity?.bufferRenderer.hasBuffer()) { + if ( + selectedEntity?.bufferRenderer.state?.type !== 'rect' && + selectedEntity?.bufferRenderer.state?.type !== 'gradient' && + selectedEntity?.bufferRenderer.hasBuffer() + ) { selectedEntity.bufferRenderer.commitBuffer(); return; } @@ -375,6 +384,8 @@ export class CanvasToolModule extends CanvasModuleBase { await this.tools.eraser.onStagePointerDown(e); } else if (tool === 'rect') { await this.tools.rect.onStagePointerDown(e); + } else if (tool === 'gradient') { + await this.tools.gradient.onStagePointerDown(e); } } finally { this.render(); @@ -405,6 +416,8 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.eraser.onStagePointerUp(e); } else if (tool === 'rect') { this.tools.rect.onStagePointerUp(e); + } else if (tool === 'gradient') { + this.tools.gradient.onStagePointerUp(e); } } finally { this.render(); @@ -436,6 +449,8 @@ export class CanvasToolModule extends CanvasModuleBase { await this.tools.eraser.onStagePointerMove(e); } else if (tool === 'rect') { await this.tools.rect.onStagePointerMove(e); + } else if (tool === 'gradient') { + await this.tools.gradient.onStagePointerMove(e); } else { this.manager.stateApi.getSelectedEntityAdapter()?.bufferRenderer.clearBuffer(); } @@ -462,6 +477,7 @@ export class CanvasToolModule extends CanvasModuleBase { if ( selectedEntity && selectedEntity.bufferRenderer.state?.type !== 'rect' && + selectedEntity.bufferRenderer.state?.type !== 'gradient' && selectedEntity.bufferRenderer.hasBuffer() ) { selectedEntity.bufferRenderer.commitBuffer(); @@ -629,6 +645,7 @@ export class CanvasToolModule extends CanvasModuleBase { eraser: this.tools.eraser.repr(), colorPicker: this.tools.colorPicker.repr(), rect: this.tools.rect.repr(), + gradient: this.tools.gradient.repr(), bbox: this.tools.bbox.repr(), view: this.tools.view.repr(), move: this.tools.move.repr(), diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts index f76654d4c80..91428b45216 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSettingsSlice.ts @@ -12,6 +12,8 @@ export type AutoSwitchMode = z.infer; const zTransformSmoothingMode = z.enum(['bilinear', 'bicubic', 'hamming', 'lanczos']); export type TransformSmoothingMode = z.infer; +const zGradientType = z.enum(['linear', 'radial']); + const zCanvasSettingsState = z.object({ /** * Whether to show HUD (Heads-Up Display) on the canvas. @@ -108,6 +110,14 @@ const zCanvasSettingsState = z.object({ * Whether the fill color picker UI is pinned (persistently shown in the canvas overlay). */ fillColorPickerPinned: z.boolean(), + /** + * The gradient tool type. + */ + gradientType: zGradientType.default('linear'), + /** + * Whether the gradient tool clips to the drag gesture. + */ + gradientClipEnabled: z.boolean().default(true), }); type CanvasSettingsState = z.infer; @@ -136,6 +146,8 @@ const getInitialState = (): CanvasSettingsState => ({ fillColorPickerPinned: false, transformSmoothingEnabled: false, transformSmoothingMode: 'bicubic', + gradientType: 'linear', + gradientClipEnabled: true, }); const slice = createSlice({ @@ -227,6 +239,12 @@ const slice = createSlice({ settingsFillColorPickerPinnedSet: (state, action: PayloadAction) => { state.fillColorPickerPinned = action.payload; }, + settingsGradientTypeChanged: (state, action: PayloadAction) => { + state.gradientType = action.payload; + }, + settingsGradientClipToggled: (state) => { + state.gradientClipEnabled = !state.gradientClipEnabled; + }, }, }); @@ -256,6 +274,8 @@ export const { settingsTransformSmoothingModeChanged, settingsStagingAreaAutoSwitchChanged, settingsFillColorPickerPinnedSet, + settingsGradientTypeChanged, + settingsGradientClipToggled, } = slice.actions; export const canvasSettingsSliceConfig: SliceConfig = { @@ -295,3 +315,5 @@ export const selectTransformSmoothingEnabled = createCanvasSettingsSelector( (settings) => settings.transformSmoothingEnabled ); export const selectTransformSmoothingMode = createCanvasSettingsSelector((settings) => settings.transformSmoothingMode); +export const selectGradientType = createCanvasSettingsSelector((settings) => settings.gradientType); +export const selectGradientClipEnabled = createCanvasSettingsSelector((settings) => settings.gradientClipEnabled); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 868e2aa2b78..396e6745903 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -21,6 +21,7 @@ import type { CanvasMetadata, ChannelName, ChannelPoints, + CompositeOperation, ControlLoRAConfig, EntityMovedByPayload, FillStyle, @@ -63,6 +64,7 @@ import type { ControlNetConfig, EntityBrushLineAddedPayload, EntityEraserLineAddedPayload, + EntityGradientAddedPayload, EntityIdentifierPayload, EntityMovedToPayload, EntityRasterizedPayload, @@ -193,6 +195,21 @@ const slice = createSlice({ } layer.adjustments.collapsed = !layer.adjustments.collapsed; }, + rasterLayerGlobalCompositeOperationChanged: ( + state, + action: PayloadAction> + ) => { + const { entityIdentifier, globalCompositeOperation } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + if (globalCompositeOperation === undefined) { + delete layer.globalCompositeOperation; + } else { + layer.globalCompositeOperation = globalCompositeOperation; + } + }, rasterLayerAdded: { reducer: ( state, @@ -202,10 +219,19 @@ const slice = createSlice({ isSelected?: boolean; isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; + mergedEntitiesToDisable?: string[]; addAfter?: string; }> ) => { - const { id, overrides, isSelected, isBookmarked, mergedEntitiesToDelete = [], addAfter } = action.payload; + const { + id, + overrides, + isSelected, + isBookmarked, + mergedEntitiesToDelete = [], + mergedEntitiesToDisable = [], + addAfter, + } = action.payload; const entityState = getRasterLayerState(id, overrides); const index = addAfter @@ -213,6 +239,16 @@ const slice = createSlice({ : state.rasterLayers.entities.length; state.rasterLayers.entities.splice(index, 0, entityState); + // For boolean operations we may want to disable the source layers instead of deleting them + if (mergedEntitiesToDisable.length > 0) { + for (const idToDisable of mergedEntitiesToDisable) { + const entity = state.rasterLayers.entities.find((e) => e.id === idToDisable); + if (entity) { + entity.isEnabled = false; + } + } + } + if (mergedEntitiesToDelete.length > 0) { state.rasterLayers.entities = state.rasterLayers.entities.filter( (entity) => !mergedEntitiesToDelete.includes(entity.id) @@ -221,7 +257,8 @@ const slice = createSlice({ const entityIdentifier = getEntityIdentifier(entityState); - if (isSelected || mergedEntitiesToDelete.length > 0) { + // When sources were either deleted OR disabled, select the new merged layer + if (isSelected || mergedEntitiesToDelete.length > 0 || mergedEntitiesToDisable.length > 0) { state.selectedEntityIdentifier = entityIdentifier; } @@ -234,6 +271,7 @@ const slice = createSlice({ isSelected?: boolean; isBookmarked?: boolean; mergedEntitiesToDelete?: string[]; + mergedEntitiesToDisable?: string[]; addAfter?: string; }) => ({ payload: { ...payload, id: getPrefixedId('raster_layer') }, @@ -1510,6 +1548,17 @@ const slice = createSlice({ // re-render it (reference equality check). I don't like this behaviour. entity.objects.push({ ...rect }); }, + entityGradientAdded: (state, action: PayloadAction) => { + const { entityIdentifier, gradient } = action.payload; + const entity = selectEntity(state, entityIdentifier); + if (!entity) { + return; + } + + // TODO(psyche): If we add the object without splatting, the renderer will see it as the same object and not + // re-render it (reference equality check). I don't like this behaviour. + entity.objects.push({ ...gradient }); + }, entityDeleted: (state, action: PayloadAction) => { const { entityIdentifier } = action.payload; @@ -1738,6 +1787,7 @@ export const { entityBrushLineAdded, entityEraserLineAdded, entityRectAdded, + entityGradientAdded, // Raster layer adjustments rasterLayerAdjustmentsSet, rasterLayerAdjustmentsCancel, @@ -1747,6 +1797,7 @@ export const { rasterLayerAdjustmentsCollapsedToggled, rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, + rasterLayerGlobalCompositeOperationChanged, entityDeleted, entityArrangedForwardOne, entityArrangedToFront, @@ -1862,7 +1913,7 @@ export const canvasSliceConfig: SliceConfig = { }, }; -const doNotGroupMatcher = isAnyOf(entityBrushLineAdded, entityEraserLineAdded, entityRectAdded); +const doNotGroupMatcher = isAnyOf(entityBrushLineAdded, entityEraserLineAdded, entityRectAdded, entityGradientAdded); // Store rapid actions of the same type at most once every x time. // See: https://github.com/omnidan/redux-undo/blob/master/examples/throttled-drag/util/undoFilter.js diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 59d6868bb1a..0913173c9a8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -105,7 +105,7 @@ const zIPMethodV2 = z.enum(['full', 'style', 'composition', 'style_strong', 'sty export type IPMethodV2 = z.infer; export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success; -const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorPicker']); +const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'gradient', 'view', 'bbox', 'colorPicker']); export type Tool = z.infer; const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, { @@ -260,6 +260,42 @@ const zCanvasRectState = z.object({ }); export type CanvasRectState = z.infer; +// Gradient state includes clip metadata so the tool can optionally clip to drag gesture. +const zCanvasLinearGradientState = z.object({ + id: zId, + type: z.literal('gradient'), + gradientType: z.literal('linear'), + rect: zRect, + start: zCoordinate, + end: zCoordinate, + clipCenter: zCoordinate, + clipRadius: z.number().min(0), + clipAngle: z.number(), + clipEnabled: z.boolean().default(true), + bboxRect: zRect, + fgColor: zRgbaColor, + bgColor: zRgbaColor, +}); +const zCanvasRadialGradientState = z.object({ + id: zId, + type: z.literal('gradient'), + gradientType: z.literal('radial'), + rect: zRect, + center: zCoordinate, + radius: z.number().min(0), + clipCenter: zCoordinate, + clipRadius: z.number().min(0), + clipEnabled: z.boolean().default(true), + bboxRect: zRect, + fgColor: zRgbaColor, + bgColor: zRgbaColor, +}); +const zCanvasGradientState = z.discriminatedUnion('gradientType', [ + zCanvasLinearGradientState, + zCanvasRadialGradientState, +]); +export type CanvasGradientState = z.infer; + const zCanvasImageState = z.object({ id: zId, type: z.literal('image'), @@ -275,6 +311,7 @@ const zCanvasObjectState = z.union([ zCanvasRectState, zCanvasBrushLineWithPressureState, zCanvasEraserLineWithPressureState, + zCanvasGradientState, ]); export type CanvasObjectState = z.infer; @@ -484,6 +521,62 @@ const zRasterLayerAdjustments = z.object({ }); export type RasterLayerAdjustments = z.infer; +/** + * Available global composite operations (blend modes) for layers. + * These are the standard Canvas 2D composite operations. + * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation + * NOTE: All of these are supported by canvas layers, but not all are supported by CSS blend modes (live rendering). + */ +const COMPOSITE_OPERATIONS = [ + 'darken', + 'multiply', + 'color-burn', + 'lighten', + 'screen', + 'color-dodge', + 'lighter', + 'overlay', + 'soft-light', + 'hard-light', + 'difference', + 'exclusion', + 'xor', + 'hue', + 'saturation', + 'color', + 'luminosity', + 'source-over', + 'source-in', + 'source-out', + 'source-atop', + 'destination-over', + 'destination-in', + 'destination-out', + 'destination-atop', + 'copy', +] as const; + +export type CompositeOperation = (typeof COMPOSITE_OPERATIONS)[number]; + +// Subset of color blend modes for UI selection. All are supported by both Konva and CSS. +export const COLOR_BLEND_MODES: CompositeOperation[] = [ + 'source-over', + 'darken', + 'multiply', + 'color-burn', + 'lighten', + 'screen', + 'color-dodge', + 'overlay', + 'soft-light', + 'hard-light', + 'difference', + 'hue', + 'saturation', + 'color', + 'luminosity', +]; + const zCanvasRasterLayerState = zCanvasEntityBase.extend({ type: z.literal('raster_layer'), position: zCoordinate, @@ -491,6 +584,8 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({ objects: z.array(zCanvasObjectState), // Optional per-layer color adjustments (simple + curves). When undefined, no adjustments are applied. adjustments: zRasterLayerAdjustments.optional(), + // Optional per-layer composite operation. When undefined, defaults to 'source-over'. + globalCompositeOperation: z.enum(COMPOSITE_OPERATIONS).optional(), }); export type CanvasRasterLayerState = z.infer; @@ -830,6 +925,7 @@ export type EntityEraserLineAddedPayload = EntityIdentifierPayload<{ eraserLine: CanvasEraserLineState | CanvasEraserLineWithPressureState; }>; export type EntityRectAddedPayload = EntityIdentifierPayload<{ rect: CanvasRectState }>; +export type EntityGradientAddedPayload = EntityIdentifierPayload<{ gradient: CanvasGradientState }>; export type EntityRasterizedPayload = EntityIdentifierPayload<{ imageObject: CanvasImageState; position: Coordinate;