diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 2db971d06a6..d042c40d014 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -2488,6 +2488,8 @@
"logDebugInfo": "Log Debug Info",
"locked": "Locked",
"unlocked": "Unlocked",
+ "transparencyLocked": "Transparency Locked",
+ "transparencyUnlocked": "Transparency Unlocked",
"deleteSelected": "Delete Selected",
"stagingOnCanvas": "Staging images on",
"replaceLayer": "Replace Layer",
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..1f3e766156e 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 { RasterLayerIsTransparencyLockedToggle } from 'features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle';
import { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
@@ -38,6 +39,7 @@ export const RasterLayer = memo(({ id }: Props) => {
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle.tsx
new file mode 100644
index 00000000000..94ea540f6d3
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerIsTransparencyLockedToggle.tsx
@@ -0,0 +1,50 @@
+import { IconButton } from '@invoke-ai/ui-library';
+import { createSelector } from '@reduxjs/toolkit';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
+import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy';
+import { rasterLayerIsTransparencyLockedToggled } from 'features/controlLayers/store/canvasSlice';
+import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
+import { memo, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiDropHalfBold, PiDropHalfFill } from 'react-icons/pi';
+
+export const RasterLayerIsTransparencyLockedToggle = memo(() => {
+ const { t } = useTranslation();
+ const entityIdentifier = useEntityIdentifierContext('raster_layer');
+ const isBusy = useCanvasIsBusy();
+ const dispatch = useAppDispatch();
+
+ const selectIsTransparencyLocked = useMemo(
+ () =>
+ createSelector(selectCanvasSlice, (canvas) => {
+ const entity = selectEntity(canvas, entityIdentifier);
+ if (!entity) {
+ return false;
+ }
+ return entity.isTransparencyLocked ?? false;
+ }),
+ [entityIdentifier]
+ );
+
+ const isTransparencyLocked = useAppSelector(selectIsTransparencyLocked);
+
+ const onClick = useCallback(() => {
+ dispatch(rasterLayerIsTransparencyLockedToggled({ entityIdentifier }));
+ }, [dispatch, entityIdentifier]);
+
+ return (
+ : }
+ onClick={onClick}
+ isDisabled={isBusy}
+ />
+ );
+});
+
+RasterLayerIsTransparencyLockedToggle.displayName = 'RasterLayerIsTransparencyLockedToggle';
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts
index 8ce49b00b4e..38321db1a20 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLine.ts
@@ -6,8 +6,11 @@ import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
import type { CanvasBrushLineState } from 'features/controlLayers/store/types';
import Konva from 'konva';
+import type { NodeConfig } from 'konva/lib/Node';
import type { Logger } from 'roarr';
+type GlobalCompositeOperation = NonNullable;
+
export class CanvasObjectBrushLine extends CanvasModuleBase {
readonly type = 'object_brush_line';
readonly id: string;
@@ -46,7 +49,7 @@ export class CanvasObjectBrushLine extends CanvasModuleBase {
tension: 0.3,
lineCap: 'round',
lineJoin: 'round',
- globalCompositeOperation: 'source-over',
+ globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
perfectDrawEnabled: false,
}),
};
@@ -57,12 +60,13 @@ export class CanvasObjectBrushLine extends CanvasModuleBase {
update(state: CanvasBrushLineState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating brush line');
- const { points, color, strokeWidth } = state;
+ const { points, color, strokeWidth, globalCompositeOperation } = state;
this.konva.line.setAttrs({
// A line with only one point will not be rendered, so we duplicate the points to make it visible
points: points.length === 2 ? [...points, ...points] : points,
stroke: rgbaColorToString(color),
strokeWidth,
+ globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
});
this.state = state;
return true;
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts
index 41a9a6d0c81..1dba23718b8 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObject/CanvasObjectBrushLineWithPressure.ts
@@ -7,8 +7,11 @@ import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'
import { getSVGPathDataFromPoints } from 'features/controlLayers/konva/util';
import type { CanvasBrushLineWithPressureState } from 'features/controlLayers/store/types';
import Konva from 'konva';
+import type { NodeConfig } from 'konva/lib/Node';
import type { Logger } from 'roarr';
+type GlobalCompositeOperation = NonNullable;
+
export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
readonly type = 'object_brush_line_with_pressure';
readonly id: string;
@@ -47,7 +50,7 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
name: `${this.type}:path`,
listening: false,
shadowForStrokeEnabled: false,
- globalCompositeOperation: 'source-over',
+ globalCompositeOperation: (state.globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
perfectDrawEnabled: false,
}),
};
@@ -58,8 +61,9 @@ export class CanvasObjectBrushLineWithPressure extends CanvasModuleBase {
update(state: CanvasBrushLineWithPressureState, force = false): boolean {
if (force || this.state !== state) {
this.log.trace({ state }, 'Updating brush line with pressure');
- const { points, color, strokeWidth } = state;
+ const { points, color, strokeWidth, globalCompositeOperation } = state;
this.konva.line.setAttrs({
+ globalCompositeOperation: (globalCompositeOperation ?? 'source-over') as GlobalCompositeOperation,
data: getSVGPathDataFromPoints(points, {
size: strokeWidth / 2,
simulatePressure: false,
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts
index 5404622cc4a..e02a3666501 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasBrushToolModule.ts
@@ -211,6 +211,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
+ // When transparency is locked on a raster layer, use 'source-atop' to only paint on existing opaque pixels
+ const isTransparencyLocked =
+ selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked;
+ const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined;
+
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
// If the pen is down and pressure sensitivity is enabled, add the point with pressure
await selectedEntity.bufferRenderer.setBuffer({
@@ -220,6 +225,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.parent.getClip(selectedEntity.state),
+ globalCompositeOperation,
});
} else {
// Else, add the point without pressure
@@ -230,6 +236,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
strokeWidth: settings.brushWidth,
color: this.manager.stateApi.getCurrentColor(),
clip: this.parent.getClip(selectedEntity.state),
+ globalCompositeOperation,
});
}
};
@@ -268,6 +275,11 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
const normalizedPoint = offsetCoord(cursorPos.relative, selectedEntity.state.position);
const alignedPoint = alignCoordForTool(normalizedPoint, settings.brushWidth);
+ // When transparency is locked on a raster layer, use 'source-atop' to only paint on existing opaque pixels
+ const isTransparencyLocked =
+ selectedEntity.state.type === 'raster_layer' && selectedEntity.state.isTransparencyLocked;
+ const globalCompositeOperation = isTransparencyLocked ? 'source-atop' : undefined;
+
if (e.evt.pointerType === 'pen' && settings.pressureSensitivity) {
// We need to get the last point of the last line to create a straight line if shift is held
const lastLinePoint = getLastPointOfLastLineWithPressure(
@@ -304,6 +316,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
// When shift is held, the line may extend beyond the clip region. Clip only if we are clipping to bbox. If we
// are clipping to stage, we don't need to clip at all.
clip: isShiftDraw && !settings.clipToBbox ? null : this.parent.getClip(selectedEntity.state),
+ globalCompositeOperation,
});
} else {
const lastLinePoint = getLastPointOfLastLine(selectedEntity.state.objects, 'brush_line');
@@ -329,6 +342,7 @@ export class CanvasBrushToolModule extends CanvasModuleBase {
// When shift is held, the line may extend beyond the clip region. Clip only if we are clipping to bbox. If we
// are clipping to stage, we don't need to clip at all.
clip: isShiftDraw && !settings.clipToBbox ? null : this.parent.getClip(selectedEntity.state),
+ globalCompositeOperation,
});
}
};
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
index 79d3963d122..0fff90a5591 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts
@@ -210,6 +210,17 @@ const slice = createSlice({
layer.globalCompositeOperation = globalCompositeOperation;
}
},
+ rasterLayerIsTransparencyLockedToggled: (
+ state,
+ action: PayloadAction>
+ ) => {
+ const { entityIdentifier } = action.payload;
+ const layer = selectEntity(state, entityIdentifier);
+ if (!layer) {
+ return;
+ }
+ layer.isTransparencyLocked = !layer.isTransparencyLocked;
+ },
rasterLayerAdded: {
reducer: (
state,
@@ -1798,6 +1809,7 @@ export const {
rasterLayerAdjustmentsSimpleUpdated,
rasterLayerAdjustmentsCurvesUpdated,
rasterLayerGlobalCompositeOperationChanged,
+ rasterLayerIsTransparencyLockedToggled,
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 40babc7bc85..abd521ba2bf 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -212,6 +212,7 @@ const zCanvasBrushLineState = z.object({
points: zPoints,
color: zRgbaColor,
clip: zRect.nullable(),
+ globalCompositeOperation: z.string().optional(),
});
export type CanvasBrushLineState = z.infer;
@@ -225,6 +226,7 @@ const zCanvasBrushLineWithPressureState = z.object({
points: zPointsWithPressure,
color: zRgbaColor,
clip: zRect.nullable(),
+ globalCompositeOperation: z.string().optional(),
});
export type CanvasBrushLineWithPressureState = z.infer;
@@ -586,6 +588,8 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({
adjustments: zRasterLayerAdjustments.optional(),
// Optional per-layer composite operation. When undefined, defaults to 'source-over'.
globalCompositeOperation: z.enum(COMPOSITE_OPERATIONS).optional(),
+ // When true, brush strokes only paint where existing pixels are non-transparent (preserve alpha).
+ isTransparencyLocked: z.boolean().optional(),
});
export type CanvasRasterLayerState = z.infer;
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/util.ts b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
index f14af4feee7..fcc032058f1 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts
@@ -234,6 +234,7 @@ export const getRasterLayerState = (
type: 'raster_layer',
isEnabled: true,
isLocked: false,
+ isTransparencyLocked: false,
objects: [],
opacity: 1,
position: { x: 0, y: 0 },