Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d9dea78
feat(canvas): add raster layer blend modes and boolean operations sub…
dunkeroni Oct 29, 2025
7a4c87a
feat(canvas): boolean ops submenu and UI polish
dunkeroni Oct 29, 2025
de37f87
(chore): prettier lint
dunkeroni Oct 29, 2025
f3d9323
add icons to boolean submenu items
dunkeroni Oct 30, 2025
4adaa70
add delete button for color blend operations
dunkeroni Oct 30, 2025
0e535ff
move composite operation type and imports
dunkeroni Nov 1, 2025
47a6092
chore: pnpm eslint
dunkeroni Nov 2, 2025
4484346
update blend modes order
dunkeroni Nov 2, 2025
a006619
update default blend mode to 'color'
dunkeroni Nov 2, 2025
b6a89bd
add i18n for blend modes
dunkeroni Nov 3, 2025
5cd6183
actually use translations for blend modes now
dunkeroni Nov 3, 2025
edcf922
move composite options into types.ts
dunkeroni Nov 13, 2025
8a00690
cleanup and comments
dunkeroni Nov 14, 2025
03f7627
update names
dunkeroni Nov 14, 2025
7c4737f
move constant mapping out of function
dunkeroni Nov 15, 2025
31d7cfc
Merge branch 'main' into feature/raster-blend-boolean
dunkeroni Nov 15, 2025
14c0823
Merge branch 'main' into feature/raster-blend-boolean
dunkeroni Dec 22, 2025
63ff383
Merge branch 'main' into feature/raster-blend-boolean
lstein Dec 28, 2025
5e4a418
Merge branch 'main' into pr/8661
blessedcoolant Jan 31, 2026
15b589a
feat(ui): Refactor Blend Mode Implementation
blessedcoolant Jan 31, 2026
9b9a52d
fix: use source-over instead of normal
blessedcoolant Jan 31, 2026
0e5fe3a
fix: pixel fix for slightly offset action bar labels.
blessedcoolant Jan 31, 2026
58dc597
feat(canvas): boolean raster merge creates new layer and disables sou…
dunkeroni Feb 2, 2026
7db19b2
(fix) lint errors
dunkeroni Feb 2, 2026
5a3020c
remove extra typecast
dunkeroni Feb 2, 2026
6b8a219
Merge branch 'main' into pr/8661
blessedcoolant Feb 3, 2026
3b9fe2a
Merge branch 'main' into pr/8661
blessedcoolant Feb 3, 2026
0a35add
Merge branch 'main' into feature/raster-blend-boolean
lstein Feb 16, 2026
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
28 changes: 28 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2138,6 +2138,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",
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,69 @@
import { Flex, FormControl, FormLabel, Select } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InpaintMaskDeleteModifierButton } from 'features/controlLayers/components/InpaintMask/InpaintMaskDeleteModifierButton';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { rasterLayerGlobalCompositeOperationChanged } from 'features/controlLayers/store/canvasSlice';
import type { CanvasRasterLayerState, CompositeOperation } from 'features/controlLayers/store/types';
import { COLOR_BLEND_MODES } 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]
);

const onDelete = useCallback(() => {
dispatch(
rasterLayerGlobalCompositeOperationChanged({
entityIdentifier,
globalCompositeOperation: undefined,
})
);
}, [dispatch, entityIdentifier]);

if (!showSettings) {
return null;
}

return (
<Flex px={2} pb={2}>
<FormControl>
<FormLabel m={0}>{t('controlLayers.compositeOperation.label')}</FormLabel>
<Flex alignItems="center" mb={1}>
<Select value={currentOperation} onChange={onChange} size="sm" flex={1} mr={2}>
{COLOR_BLEND_MODES.map((op) => (
<option key={op} value={op}>
{t(`controlLayers.compositeOperation.blendModes.${op}`)}
</option>
))}
</Select>
<InpaintMaskDeleteModifierButton onDelete={onDelete} />
</Flex>
</FormControl>
</Flex>
);
});

RasterLayerCompositeOperationSettings.displayName = 'RasterLayerCompositeOperationSettings';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,8 +28,10 @@ export const RasterLayerMenuItems = memo(() => {
<CanvasEntityMenuItemsFilter />
<CanvasEntityMenuItemsSelectObject />
<RasterLayerMenuItemsAdjustments />
<RasterLayerMenuItemsCompositeOperation />
<MenuDivider />
<CanvasEntityMenuItemsMergeDown />
<RasterLayerMenuItemsBooleanSubMenu />
<RasterLayerMenuItemsCopyToSubMenu />
<RasterLayerMenuItemsConvertToSubMenu />
<CanvasEntityMenuItemsCropToBbox />
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<MenuItem {...subMenu.parentMenuItemProps} isDisabled={disabled} icon={<CgPathCrop size={18} />}>
<Menu {...subMenu.menuProps}>
<MenuButton {...subMenu.menuButtonProps}>
<SubMenuButtonContent label={t('controlLayers.booleanOps.label')} />
</MenuButton>
<MenuList {...subMenu.menuListProps}>
<MenuItem onClick={onIntersect} isDisabled={disabled} icon={<CgPathIntersect size={18} />}>
{t('controlLayers.booleanOps.intersect')}
</MenuItem>
<MenuItem onClick={onCutOut} isDisabled={disabled} icon={<CgPathBack size={18} />}>
{t('controlLayers.booleanOps.cutout')}
</MenuItem>
<MenuItem onClick={onCutAway} isDisabled={disabled} icon={<CgPathFront size={18} />}>
{t('controlLayers.booleanOps.cutaway')}
</MenuItem>
<MenuItem onClick={onExclude} isDisabled={disabled} icon={<CgPathExclude size={18} />}>
{t('controlLayers.booleanOps.exclude')}
</MenuItem>
</MenuList>
</Menu>
</MenuItem>
);
});

RasterLayerMenuItemsBooleanSubMenu.displayName = 'RasterLayerMenuItemsBooleanSubMenu';
Original file line number Diff line number Diff line change
@@ -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 (
<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 @@ -24,6 +24,7 @@ import type {
CanvasEntityIdentifier,
CanvasEntityState,
CanvasEntityType,
CompositeOperation,
GenerationMode,
Rect,
} from 'features/controlLayers/store/types';
Expand All @@ -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;
};

/**
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'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'
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -147,12 +170,22 @@ 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) {
return true;
}
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;
}
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type {
CanvasMetadata,
ChannelName,
ChannelPoints,
CompositeOperation,
ControlLoRAConfig,
EntityMovedByPayload,
FillStyle,
Expand Down Expand Up @@ -193,6 +194,21 @@ const slice = createSlice({
}
layer.adjustments.collapsed = !layer.adjustments.collapsed;
},
rasterLayerGlobalCompositeOperationChanged: (
state,
action: PayloadAction<EntityIdentifierPayload<{ globalCompositeOperation?: CompositeOperation }, '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 @@ -1747,6 +1763,7 @@ export const {
rasterLayerAdjustmentsCollapsedToggled,
rasterLayerAdjustmentsSimpleUpdated,
rasterLayerAdjustmentsCurvesUpdated,
rasterLayerGlobalCompositeOperationChanged,
entityDeleted,
entityArrangedForwardOne,
entityArrangedToFront,
Expand Down
Loading