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 (
+ }>
+
+
+ );
+});
+
+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;