-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(ui): Raster Layer Color Adjusters #8420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
psychedelicious
merged 38 commits into
invoke-ai:main
from
dunkeroni:raster_layer_adjustments
Sep 11, 2025
Merged
Changes from all commits
Commits
Show all changes
38 commits
Select commit
Hold shift + click to select a range
3eff44a
visual adjustment filters
dunkeroni fff92e9
apply filters to operations
dunkeroni cb8c1db
curves editor
dunkeroni e20960a
log scale and panel width compatibility
dunkeroni 4c3dcdd
fix disable toggle reverts to simple view
dunkeroni 77229b8
Fix tint not shifting green in negative direction
dunkeroni e642227
Finish button on adjustments
dunkeroni e9bfe93
remove extra title
dunkeroni 78f8701
remove redundant en.json colors
dunkeroni 2d4db61
clean up right click menu
dunkeroni 0f078fe
move memoized slider to component
dunkeroni 7f49acf
move constants in curves editor
dunkeroni 66d043d
curves editor syntax and structure fixes
dunkeroni d67e89b
fix: crop to bbox doubles adjustment filters
dunkeroni 80aa2d0
remove extra casts and types from filters.ts
dunkeroni f94312f
simplify adjustments type to optional not null
dunkeroni 0a77fc9
use default factory on reset
dunkeroni 3bdb1ea
blue mode switch indicator
dunkeroni 6dc6e78
splitup adjustment panel objects
dunkeroni c844c8e
fix several points of curve editor jank
dunkeroni bed0e54
layout fixes
dunkeroni fd4f46f
remove extra edit comments
dunkeroni 0993e07
defaultValue on adjusters
dunkeroni 0933916
allow negative sharpness to soften
dunkeroni a545794
remove unknown type annotations
dunkeroni 3a603b3
minor padding changes
dunkeroni 38ce6bb
feat(ui): tweak layouts, use react conventions, disabled state
psychedelicious cf10435
tidy(ui): move some histogram drawing logic out of components and int…
psychedelicious be18c03
feat(ui): single action to reset adjustments
psychedelicious d939a61
feat(ui): tweak adjustments panel styling
psychedelicious 507ee0c
fix(ui): points where x=255 sorted incorrectly
psychedelicious 72c2a7d
refactor(ui): make layer adjustments schemas/types composable
psychedelicious d7426b6
feat(ui): better types & runtime guarantees for filter data stored in…
psychedelicious e8eb976
fix(ui): sharpness range
psychedelicious 4e2b76a
perf(ui): use narrow selectors in adjustments to reduce rerenders
psychedelicious 07e339a
tidy(ui): split curves graph into own component
psychedelicious 2434460
perf(ui): optimize curves graph component
psychedelicious e9c39f7
chore(ui): lint
psychedelicious File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
167 changes: 167 additions & 0 deletions
167
...end/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
179 changes: 179 additions & 0 deletions
179
.../src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.