Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -41,6 +42,7 @@ export const RasterLayer = memo(({ id }: Props) => {
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<RasterLayerAdjustmentsPanel />
<RasterLayerCompositeOperationSettings />
<DndDropTarget
dndTarget={replaceCanvasEntityObjectsWithImageDndTarget}
dndTargetData={dndTargetData}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Flex, FormControl, FormLabel, Select } 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 { COMPOSITE_OPERATIONS, type CompositeOperation } from 'features/controlLayers/store/compositeOperations';
import type { CanvasRasterLayerState } from 'features/controlLayers/store/types';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

export const RasterLayerCompositeOperationSettings = memo(() => {
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<HTMLSelectElement>) => {
const value = e.target.value as CompositeOperation;
dispatch(rasterLayerGlobalCompositeOperationChanged({ entityIdentifier, globalCompositeOperation: value }));
},
[dispatch, entityIdentifier]
);

if (!showSettings) {
return null;
}

return (
<Flex px={2} pb={2}>
<FormControl>
<FormLabel>{t('controlLayers.compositeOperation.label')}</FormLabel>
<Select value={currentOperation} onChange={onChange} size="sm">
{COMPOSITE_OPERATIONS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</Select>
</FormControl>
</Flex>
);
});

RasterLayerCompositeOperationSettings.displayName = 'RasterLayerCompositeOperationSettings';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,7 @@ export const RasterLayerMenuItems = memo(() => {
<CanvasEntityMenuItemsFilter />
<CanvasEntityMenuItemsSelectObject />
<RasterLayerMenuItemsAdjustments />
<RasterLayerMenuItemsCompositeOperation />
<MenuDivider />
<CanvasEntityMenuItemsMergeDown />
<RasterLayerMenuItemsCopyToSubMenu />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<MenuItem onClick={onToggleCompositeOperationPresence} icon={<PiDropHalfBold />}>
{hasCompositeOperation ? t('controlLayers.compositeOperation.remove') : t('controlLayers.compositeOperation.add')}
</MenuItem>
);
});

RasterLayerMenuItemsCompositeOperation.displayName = 'RasterLayerMenuItemsCompositeOperation';
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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<string, string> = {
'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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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<string, string> = {
'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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1719,6 +1736,7 @@ export const {
rasterLayerAdjustmentsCollapsedToggled,
rasterLayerAdjustmentsSimpleUpdated,
rasterLayerAdjustmentsCurvesUpdated,
rasterLayerGlobalCompositeOperationChanged,
entityDeleted,
entityArrangedForwardOne,
entityArrangedToFront,
Expand Down
Original file line number Diff line number Diff line change
@@ -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];
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<typeof zCanvasRasterLayerState>;

Expand Down