Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
3eff44a
visual adjustment filters
dunkeroni Aug 10, 2025
fff92e9
apply filters to operations
dunkeroni Aug 10, 2025
cb8c1db
curves editor
dunkeroni Aug 10, 2025
e20960a
log scale and panel width compatibility
dunkeroni Aug 10, 2025
4c3dcdd
fix disable toggle reverts to simple view
dunkeroni Aug 10, 2025
77229b8
Fix tint not shifting green in negative direction
dunkeroni Aug 10, 2025
e642227
Finish button on adjustments
dunkeroni Aug 13, 2025
e9bfe93
remove extra title
dunkeroni Aug 13, 2025
78f8701
remove redundant en.json colors
dunkeroni Aug 13, 2025
2d4db61
clean up right click menu
dunkeroni Aug 13, 2025
0f078fe
move memoized slider to component
dunkeroni Aug 15, 2025
7f49acf
move constants in curves editor
dunkeroni Aug 15, 2025
66d043d
curves editor syntax and structure fixes
dunkeroni Aug 15, 2025
d67e89b
fix: crop to bbox doubles adjustment filters
dunkeroni Aug 15, 2025
80aa2d0
remove extra casts and types from filters.ts
dunkeroni Aug 16, 2025
f94312f
simplify adjustments type to optional not null
dunkeroni Sep 7, 2025
0a77fc9
use default factory on reset
dunkeroni Sep 7, 2025
3bdb1ea
blue mode switch indicator
dunkeroni Sep 7, 2025
6dc6e78
splitup adjustment panel objects
dunkeroni Sep 7, 2025
c844c8e
fix several points of curve editor jank
dunkeroni Sep 7, 2025
bed0e54
layout fixes
dunkeroni Sep 7, 2025
fd4f46f
remove extra edit comments
dunkeroni Sep 7, 2025
0993e07
defaultValue on adjusters
dunkeroni Sep 7, 2025
0933916
allow negative sharpness to soften
dunkeroni Sep 7, 2025
a545794
remove unknown type annotations
dunkeroni Sep 7, 2025
3a603b3
minor padding changes
dunkeroni Sep 7, 2025
38ce6bb
feat(ui): tweak layouts, use react conventions, disabled state
psychedelicious Sep 11, 2025
cf10435
tidy(ui): move some histogram drawing logic out of components and int…
psychedelicious Sep 11, 2025
be18c03
feat(ui): single action to reset adjustments
psychedelicious Sep 11, 2025
d939a61
feat(ui): tweak adjustments panel styling
psychedelicious Sep 11, 2025
507ee0c
fix(ui): points where x=255 sorted incorrectly
psychedelicious Sep 11, 2025
72c2a7d
refactor(ui): make layer adjustments schemas/types composable
psychedelicious Sep 11, 2025
d7426b6
feat(ui): better types & runtime guarantees for filter data stored in…
psychedelicious Sep 11, 2025
e8eb976
fix(ui): sharpness range
psychedelicious Sep 11, 2025
4e2b76a
perf(ui): use narrow selectors in adjustments to reduce rerenders
psychedelicious Sep 11, 2025
07e339a
tidy(ui): split curves graph into own component
psychedelicious Sep 11, 2025
2434460
perf(ui): optimize curves graph component
psychedelicious Sep 11, 2025
e9c39f7
chore(ui): lint
psychedelicious Sep 11, 2025
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
18 changes: 18 additions & 0 deletions invokeai/frontend/web/public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2083,6 +2083,24 @@
"pullBboxIntoLayerError": "Problem Pulling BBox Into Layer",
"pullBboxIntoReferenceImageOk": "Bbox Pulled Into ReferenceImage",
"pullBboxIntoReferenceImageError": "Problem Pulling BBox Into ReferenceImage",
"addAdjustments": "Add Adjustments",
"removeAdjustments": "Remove Adjustments",
"adjustments": {
"simple": "Simple",
"curves": "Curves",
"heading": "Adjustments",
"expand": "Expand adjustments",
"collapse": "Collapse adjustments",
"brightness": "Brightness",
"contrast": "Contrast",
"saturation": "Saturation",
"temperature": "Temperature",
"tint": "Tint",
"sharpness": "Sharpness",
"finish": "Finish",
"reset": "Reset",
"master": "Master"
},
"regionIsEmpty": "Selected region is empty",
"mergeVisible": "Merge Visible",
"mergeDown": "Merge Down",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CanvasEntityHeader } from 'features/controlLayers/components/common/Can
import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions';
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 { CanvasEntityStateGate } from 'features/controlLayers/contexts/CanvasEntityStateGate';
import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext';
import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
Expand Down Expand Up @@ -39,6 +40,7 @@ export const RasterLayer = memo(({ id }: Props) => {
<Spacer />
<CanvasEntityHeaderCommonActions />
</CanvasEntityHeader>
<RasterLayerAdjustmentsPanel />
<DndDropTarget
dndTarget={replaceCanvasEntityObjectsWithImageDndTarget}
dndTargetData={dndTargetData}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Button, ButtonGroup, Flex, IconButton, Switch, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { RasterLayerCurvesAdjustmentsEditor } from 'features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor';
import { RasterLayerSimpleAdjustmentsEditor } from 'features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import {
rasterLayerAdjustmentsCancel,
rasterLayerAdjustmentsCollapsedToggled,
rasterLayerAdjustmentsEnabledToggled,
rasterLayerAdjustmentsModeChanged,
rasterLayerAdjustmentsReset,
rasterLayerAdjustmentsSet,
} from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import React, { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiCaretDownBold, PiCheckBold, PiTrashBold } from 'react-icons/pi';

export const RasterLayerAdjustmentsPanel = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext<'raster_layer'>();
const canvasManager = useCanvasManager();

const selectHasAdjustments = useMemo(() => {
return createSelector(selectCanvasSlice, (canvas) => Boolean(selectEntity(canvas, entityIdentifier)?.adjustments));
}, [entityIdentifier]);

const hasAdjustments = useAppSelector(selectHasAdjustments);

const selectMode = useMemo(() => {
return createSelector(
selectCanvasSlice,
(canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.mode ?? 'simple'
);
}, [entityIdentifier]);
const mode = useAppSelector(selectMode);

const selectEnabled = useMemo(() => {
return createSelector(
selectCanvasSlice,
(canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.enabled ?? false
);
}, [entityIdentifier]);
const enabled = useAppSelector(selectEnabled);

const selectCollapsed = useMemo(() => {
return createSelector(
selectCanvasSlice,
(canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.collapsed ?? false
);
}, [entityIdentifier]);
const collapsed = useAppSelector(selectCollapsed);

const onToggleEnabled = useCallback(() => {
dispatch(rasterLayerAdjustmentsEnabledToggled({ entityIdentifier }));
}, [dispatch, entityIdentifier]);

const onReset = useCallback(() => {
// Reset values to defaults but keep adjustments present; preserve enabled/collapsed/mode
dispatch(rasterLayerAdjustmentsReset({ entityIdentifier }));
}, [dispatch, entityIdentifier]);

const onCancel = useCallback(() => {
// Clear out adjustments entirely
dispatch(rasterLayerAdjustmentsCancel({ entityIdentifier }));
}, [dispatch, entityIdentifier]);

const onToggleCollapsed = useCallback(() => {
dispatch(rasterLayerAdjustmentsCollapsedToggled({ entityIdentifier }));
}, [dispatch, entityIdentifier]);

const onClickModeSimple = useCallback(
() => dispatch(rasterLayerAdjustmentsModeChanged({ entityIdentifier, mode: 'simple' })),
[dispatch, entityIdentifier]
);

const onClickModeCurves = useCallback(
() => dispatch(rasterLayerAdjustmentsModeChanged({ entityIdentifier, mode: 'curves' })),
[dispatch, entityIdentifier]
);

const onFinish = useCallback(async () => {
// Bake current visual into layer pixels, then clear adjustments
const adapter = canvasManager.getAdapter(entityIdentifier);
if (!adapter || adapter.type !== 'raster_layer_adapter') {
return;
}
const rect = adapter.transformer.getRelativeRect();
try {
await adapter.renderer.rasterize({ rect, replaceObjects: true });
// Clear adjustments after baking
dispatch(rasterLayerAdjustmentsSet({ entityIdentifier, adjustments: null }));
} catch {
// no-op; leave state unchanged on failure
}
}, [canvasManager, entityIdentifier, dispatch]);

// Hide the panel entirely until adjustments are added via context menu
if (!hasAdjustments) {
return null;
}

return (
<>
<Flex px={2} pb={2} alignItems="center" gap={2}>
<IconButton
aria-label={collapsed ? t('controlLayers.adjustments.expand') : t('controlLayers.adjustments.collapse')}
size="sm"
variant="ghost"
onClick={onToggleCollapsed}
icon={
<PiCaretDownBold
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 0.2s' }}
/>
}
/>
<Text fontWeight={600} flex={1}>
Adjustments
</Text>
<ButtonGroup size="sm" isAttached variant="outline">
<Button onClick={onClickModeSimple} colorScheme={mode === 'simple' ? 'invokeBlue' : undefined}>
{t('controlLayers.adjustments.simple')}
</Button>
<Button onClick={onClickModeCurves} colorScheme={mode === 'curves' ? 'invokeBlue' : undefined}>
{t('controlLayers.adjustments.curves')}
</Button>
</ButtonGroup>
<Switch isChecked={enabled} onChange={onToggleEnabled} />
<IconButton
aria-label={t('controlLayers.adjustments.cancel')}
size="md"
onClick={onCancel}
isDisabled={!hasAdjustments}
colorScheme="red"
icon={<PiTrashBold />}
variant="ghost"
/>
<IconButton
aria-label={t('controlLayers.adjustments.reset')}
size="md"
onClick={onReset}
isDisabled={!hasAdjustments}
icon={<PiArrowCounterClockwiseBold />}
variant="ghost"
/>
<IconButton
aria-label={t('controlLayers.adjustments.finish')}
size="md"
onClick={onFinish}
isDisabled={!hasAdjustments}
colorScheme="green"
icon={<PiCheckBold />}
variant="ghost"
/>
</Flex>

{!collapsed && mode === 'simple' && <RasterLayerSimpleAdjustmentsEditor />}

{!collapsed && mode === 'curves' && <RasterLayerCurvesAdjustmentsEditor />}
</>
);
});

RasterLayerAdjustmentsPanel.displayName = 'RasterLayerAdjustmentsPanel';
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useEntityAdapterContext } from 'features/controlLayers/contexts/EntityAdapterContext';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import { rasterLayerAdjustmentsCurvesUpdated } from 'features/controlLayers/store/canvasSlice';
import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors';
import type { ChannelName, ChannelPoints, CurvesAdjustmentsConfig } from 'features/controlLayers/store/types';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { RasterLayerCurvesAdjustmentsGraph } from './RasterLayerCurvesAdjustmentsGraph';

const DEFAULT_POINTS: ChannelPoints = [
[0, 0],
[255, 255],
];

const DEFAULT_CURVES: CurvesAdjustmentsConfig = {
master: DEFAULT_POINTS,
r: DEFAULT_POINTS,
g: DEFAULT_POINTS,
b: DEFAULT_POINTS,
};

type ChannelHistograms = Record<ChannelName, number[] | null>;

const calculateHistogramsFromImageData = (imageData: ImageData): ChannelHistograms | null => {
try {
const data = imageData.data;
const len = data.length / 4;
const master = new Array<number>(256).fill(0);
const r = new Array<number>(256).fill(0);
const g = new Array<number>(256).fill(0);
const b = new Array<number>(256).fill(0);
// sample every 4th pixel to lighten work
for (let i = 0; i < len; i += 4) {
const idx = i * 4;
const rv = data[idx] as number;
const gv = data[idx + 1] as number;
const bv = data[idx + 2] as number;
const m = Math.round(0.2126 * rv + 0.7152 * gv + 0.0722 * bv);
if (m >= 0 && m < 256) {
master[m] = (master[m] ?? 0) + 1;
}
if (rv >= 0 && rv < 256) {
r[rv] = (r[rv] ?? 0) + 1;
}
if (gv >= 0 && gv < 256) {
g[gv] = (g[gv] ?? 0) + 1;
}
if (bv >= 0 && bv < 256) {
b[bv] = (b[bv] ?? 0) + 1;
}
}
return {
master,
r,
g,
b,
};
} catch {
return null;
}
};

export const RasterLayerCurvesAdjustmentsEditor = memo(() => {
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext<'raster_layer'>();
const adapter = useEntityAdapterContext<'raster_layer'>('raster_layer');
const { t } = useTranslation();
const selectCurves = useMemo(() => {
return createSelector(
selectCanvasSlice,
(canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.curves ?? DEFAULT_CURVES
);
}, [entityIdentifier]);
const curves = useAppSelector(selectCurves);

const selectIsDisabled = useMemo(() => {
return createSelector(
selectCanvasSlice,
(canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.enabled !== true
);
}, [entityIdentifier]);
const isDisabled = useAppSelector(selectIsDisabled);
// The canvas cache for the layer serves as a proxy for when the layer changes and can be used to trigger histo recalc
const canvasCache = useStore(adapter.$canvasCache);

const [histMaster, setHistMaster] = useState<number[] | null>(null);
const [histR, setHistR] = useState<number[] | null>(null);
const [histG, setHistG] = useState<number[] | null>(null);
const [histB, setHistB] = useState<number[] | null>(null);

const recalcHistogram = useCallback(() => {
try {
const rect = adapter.transformer.getRelativeRect();
if (rect.width === 0 || rect.height === 0) {
setHistMaster(Array(256).fill(0));
setHistR(Array(256).fill(0));
setHistG(Array(256).fill(0));
setHistB(Array(256).fill(0));
return;
}
const imageData = adapter.renderer.getImageData({ rect });
const h = calculateHistogramsFromImageData(imageData);
if (h) {
setHistMaster(h.master);
setHistR(h.r);
setHistG(h.g);
setHistB(h.b);
}
} catch {
// ignore
}
}, [adapter]);

useEffect(() => {
recalcHistogram();
}, [canvasCache, recalcHistogram]);

const onChangePoints = useCallback(
(channel: ChannelName, pts: ChannelPoints) => {
dispatch(rasterLayerAdjustmentsCurvesUpdated({ entityIdentifier, channel, points: pts }));
},
[dispatch, entityIdentifier]
);

// Memoize per-channel change handlers to avoid inline lambdas in JSX
const onChangeMaster = useCallback((pts: ChannelPoints) => onChangePoints('master', pts), [onChangePoints]);
const onChangeR = useCallback((pts: ChannelPoints) => onChangePoints('r', pts), [onChangePoints]);
const onChangeG = useCallback((pts: ChannelPoints) => onChangePoints('g', pts), [onChangePoints]);
const onChangeB = useCallback((pts: ChannelPoints) => onChangePoints('b', pts), [onChangePoints]);

return (
<Flex
direction="column"
gap={2}
px={3}
pb={3}
opacity={isDisabled ? 0.3 : 1}
pointerEvents={isDisabled ? 'none' : 'auto'}
>
<Box display="grid" gridTemplateColumns="repeat(2, minmax(0, 1fr))" gap={4}>
<RasterLayerCurvesAdjustmentsGraph
title={t('controlLayers.adjustments.master')}
channel="master"
points={curves.master}
histogram={histMaster}
onChange={onChangeMaster}
/>
<RasterLayerCurvesAdjustmentsGraph
title={t('common.red')}
channel="r"
points={curves.r}
histogram={histR}
onChange={onChangeR}
/>
<RasterLayerCurvesAdjustmentsGraph
title={t('common.green')}
channel="g"
points={curves.g}
histogram={histG}
onChange={onChangeG}
/>
<RasterLayerCurvesAdjustmentsGraph
title={t('common.blue')}
channel="b"
points={curves.b}
histogram={histB}
onChange={onChangeB}
/>
</Box>
</Flex>
);
});

RasterLayerCurvesAdjustmentsEditor.displayName = 'RasterLayerCurvesEditor';
Loading
Loading