Skip to content
Open
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
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 @@ -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",
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 @@ -191,6 +192,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 @@ -1719,6 +1735,7 @@ export const {
rasterLayerAdjustmentsCollapsedToggled,
rasterLayerAdjustmentsSimpleUpdated,
rasterLayerAdjustmentsCurvesUpdated,
rasterLayerGlobalCompositeOperationChanged,
entityDeleted,
entityArrangedForwardOne,
entityArrangedToFront,
Expand Down
Loading