diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 8a6bd7b337e..cbdd15f989a 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2077,6 +2077,34 @@ "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": { + "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", diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx index 13dc30dea20..222397cd602 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -5,6 +5,7 @@ import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/componen import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; import { RasterLayerAdjustmentsPanel } from 'features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel'; +import { RasterLayerCompositeOperationSettings } from 'features/controlLayers/components/RasterLayer/RasterLayerCompositeOperationSettings'; import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate'; import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; @@ -41,6 +42,7 @@ export const RasterLayer = memo(({ id }: Props) => { + { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + + const layer = useAppSelector((s) => + s.canvas.present.rasterLayers.entities.find((e: CanvasRasterLayerState) => e.id === entityIdentifier.id) + ); + + const showSettings = useMemo(() => { + return layer?.globalCompositeOperation !== undefined; + }, [layer]); + + const currentOperation = useMemo(() => { + return layer?.globalCompositeOperation ?? 'source-over'; + }, [layer]); + + const onChange = useCallback( + (e: ChangeEvent) => { + const value = e.target.value as CompositeOperation; + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: value })); + }, + [dispatch, entityIdentifier] + ); + + const onDelete = useCallback(() => { + dispatch( + rasterLayerGlobalCompositeOperationChanged({ + entityIdentifier, + globalCompositeOperation: undefined, + }) + ); + }, [dispatch, entityIdentifier]); + + if (!showSettings) { + return null; + } + + return ( + + + {t('controlLayers.compositeOperation.label')} + + + + + + + ); +}); + +RasterLayerCompositeOperationSettings.displayName = 'RasterLayerCompositeOperationSettings'; 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..cac242a84a1 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,8 @@ 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 { RasterLayerMenuItemsCompositeOperation } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation'; import { RasterLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu'; import { RasterLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu'; import { memo } from 'react'; @@ -26,8 +28,10 @@ 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..8c824a1e302 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsBooleanSubMenu.tsx @@ -0,0 +1,70 @@ +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 { + await canvasManager.compositor.mergeByEntityIdentifiers([entityIdentifierBelowThisOne, entityIdentifier], 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/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx new file mode 100644 index 00000000000..65381c9f325 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx @@ -0,0 +1,35 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDropHalfBold } from 'react-icons/pi'; + +export const RasterLayerMenuItemsCompositeOperation = memo(() => { + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const { t } = useTranslation(); + const layer = useAppSelector((s) => + s.canvas.present.rasterLayers.entities.find((e: CanvasRasterLayerState) => e.id === entityIdentifier.id) + ); + const hasCompositeOperation = layer?.globalCompositeOperation !== undefined; + + const onToggleCompositeOperationPresence = useCallback(() => { + if (hasCompositeOperation) { + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: undefined })); + } else { + // default to color when enabling blend modes + dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'color' })); + } + }, [dispatch, entityIdentifier, hasCompositeOperation]); + + return ( + }> + {hasCompositeOperation ? t('controlLayers.compositeOperation.remove') : t('controlLayers.compositeOperation.add')} + + ); +}); + +RasterLayerMenuItemsCompositeOperation.displayName = 'RasterLayerMenuItemsCompositeOperation'; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 3f419387439..337151581c0 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); } 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/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index f7eef4a6454..bad8ae8098b 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, @@ -191,6 +192,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, @@ -1719,6 +1735,7 @@ export const { rasterLayerAdjustmentsCollapsedToggled, rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, + rasterLayerGlobalCompositeOperationChanged, entityDeleted, entityArrangedForwardOne, entityArrangedToFront, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 87c173d7cca..ea7730424e6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -455,6 +455,61 @@ 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 = [ + 'source-over', + 'source-in', + 'source-out', + 'source-atop', + 'destination-over', + 'destination-in', + 'destination-out', + 'destination-atop', + 'lighter', + 'copy', + 'xor', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +] 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[] = [ + 'color', + 'hue', + 'overlay', + 'soft-light', + 'hard-light', + 'screen', + 'color-burn', + 'color-dodge', + 'multiply', + 'darken', + 'lighten', + 'difference', + 'luminosity', + 'saturation', +]; + const zCanvasRasterLayerState = zCanvasEntityBase.extend({ type: z.literal('raster_layer'), position: zCoordinate, @@ -462,6 +517,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;