diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 8a6bd7b337e..e98ee7d0ebb 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2077,6 +2077,11 @@ "pullBboxIntoReferenceImageError": "Problem Pulling BBox Into ReferenceImage", "addAdjustments": "Add Adjustments", "removeAdjustments": "Remove Adjustments", + "compositeOperation": { + "label": "Blend Mode", + "add": "Add Blend Mode", + "remove": "Remove Blend Mode" + }, "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] + ); + + 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..4b1bb04c72b 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 { 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,6 +27,7 @@ export const RasterLayerMenuItems = memo(() => { + 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..5cb1bb4ed22 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCompositeOperation.tsx @@ -0,0 +1,36 @@ +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 { + dispatch( + rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: 'source-over' }) + ); + } + }, [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/CanvasBackgroundModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts index b700392c05f..d5e3826b3c2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasBackgroundModule.ts @@ -96,6 +96,15 @@ export class CanvasBackgroundModule extends CanvasModuleBase { }; this.checkboardPattern.src = 'anonymous'; this.checkboardPattern.src = this.config.CHECKERBOARD_PATTERN_DATAURL; + + // Set CSS isolation on the background layer to prevent blend modes from affecting it + // This creates a stacking context so that raster/control layers with mix-blend-mode + // will only blend with each other, not with the background + const backgroundCanvas = this.konva.layer.getCanvas()._canvas; + if (backgroundCanvas) { + backgroundCanvas.style.isolation = 'isolate'; + } + this.render(); }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts index 3f419387439..b94cb8ff42d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasCompositorModule.ts @@ -226,12 +226,17 @@ 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/CanvasEntityAdapterControlLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts index 06620584fc5..bca9efb19ee 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts @@ -59,6 +59,9 @@ export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase< if (!prevState || this.state.opacity !== prevState.opacity) { this.syncOpacity(); } + if (!prevState || this.state.globalCompositeOperation !== prevState.globalCompositeOperation) { + this.syncGlobalCompositeOperation(); + } if (!prevState || this.state.withTransparencyEffect !== prevState.withTransparencyEffect) { this.renderer.updateTransparencyEffect(); } @@ -68,6 +71,40 @@ export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase< this.renderer.updateTransparencyEffect(); }; + private syncGlobalCompositeOperation = () => { + this.log.trace('Syncing globalCompositeOperation'); + const operation = this.state.globalCompositeOperation ?? 'source-over'; + + // Map globalCompositeOperation to CSS mix-blend-mode + // CSS mix-blend-mode is applied to the canvas DOM element to control how it blends with other layers + const mixBlendModeMap: Record = { + 'source-over': 'normal', + 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', + }; + + const mixBlendMode = mixBlendModeMap[operation] || 'normal'; + + // Access the underlying canvas DOM element and set CSS mix-blend-mode + const canvasElement = this.konva.layer.getCanvas()._canvas; + if (canvasElement) { + canvasElement.style.mixBlendMode = mixBlendMode; + } + }; + getCanvas = (rect?: Rect): HTMLCanvasElement => { this.log.trace({ rect }, 'Getting canvas'); // The opacity may have been changed in response to user selecting a different entity category, so we must restore 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..0c4937f8a3e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts @@ -60,6 +60,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)) { @@ -81,6 +84,40 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< return omit(this.state, keysToOmit); }; + private syncGlobalCompositeOperation = () => { + this.log.trace('Syncing globalCompositeOperation'); + const operation = this.state.globalCompositeOperation ?? 'source-over'; + + // Map globalCompositeOperation to CSS mix-blend-mode + // CSS mix-blend-mode is applied to the canvas DOM element to control how it blends with other layers + const mixBlendModeMap: Record = { + 'source-over': 'normal', + 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', + }; + + const mixBlendMode = mixBlendModeMap[operation] || 'normal'; + + // Access the underlying canvas DOM element and set CSS mix-blend-mode + const canvasElement = this.konva.layer.getCanvas()._canvas; + if (canvasElement) { + canvasElement.style.mixBlendMode = mixBlendMode; + } + }; + private syncAdjustmentsFilter = () => { const a = this.state.adjustments; const apply = !!a && a.enabled; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index f7eef4a6454..c39ba95b10b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -191,6 +191,23 @@ const slice = createSlice({ } layer.adjustments.collapsed = !layer.adjustments.collapsed; }, + rasterLayerGlobalCompositeOperationChanged: ( + state, + action: PayloadAction< + EntityIdentifierPayload<{ globalCompositeOperation?: GlobalCompositeOperation }, 'raster_layer'> + > + ) => { + 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 +1736,7 @@ export const { rasterLayerAdjustmentsCollapsedToggled, rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, + rasterLayerGlobalCompositeOperationChanged, entityDeleted, entityArrangedForwardOne, entityArrangedToFront, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts new file mode 100644 index 00000000000..83e14f8ee78 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/compositeOperations.ts @@ -0,0 +1,35 @@ +/** + * Available global composite operations (blend modes) for layers. + * These are the standard CSS composite operations. + * @see https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation + */ +export 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]; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 87c173d7cca..d094be7daa7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -1,5 +1,6 @@ import { deepClone } from 'common/util/deepClone'; import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; +import { COMPOSITE_OPERATIONS } from 'features/controlLayers/store/compositeOperations'; import { zMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; import { zParameterCanvasCoherenceMode, @@ -462,6 +463,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;