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;