Skip to content

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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
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 @@ -2022,6 +2022,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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest splitting this component into 3 components:

  • Main adjustments panel
  • Simple adjustment editor
  • Curves adjustment editor

Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import {
Button,
ButtonGroup,
CompositeNumberInput,
CompositeSlider,
Flex,
FormControl,
FormLabel,
IconButton,
Switch,
Text,
} from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { RasterLayerCurvesEditor } from 'features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor';
import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext';
import {
rasterLayerAdjustmentsCurvesUpdated,
rasterLayerAdjustmentsSet,
rasterLayerAdjustmentsSimpleUpdated,
} from 'features/controlLayers/store/canvasSlice';
import { selectEntity } from 'features/controlLayers/store/selectors';
import React, { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';

export const RasterLayerAdjustmentsPanel = memo(() => {
const dispatch = useAppDispatch();
const entityIdentifier = useEntityIdentifierContext<'raster_layer'>();
const canvasManager = useCanvasManager();
const layer = useAppSelector((s) => selectEntity(s.canvas.present, entityIdentifier));
const { t } = useTranslation();

const hasAdjustments = Boolean(layer?.adjustments);
const enabled = Boolean(layer?.adjustments?.enabled);
const collapsed = Boolean(layer?.adjustments?.collapsed);
const mode = layer?.adjustments?.mode ?? 'simple';
const simple = layer?.adjustments?.simple ?? {
brightness: 0,
contrast: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpness: 0,
};

const onToggleEnabled = useCallback(
(v: boolean) => {
// Only toggle the enabled state; preserve current mode/collapsed so users can A/B compare
dispatch(rasterLayerAdjustmentsSet({ entityIdentifier, adjustments: { enabled: v } }));
},
[dispatch, entityIdentifier]
);
Comment on lines +47 to +53
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic and be merged in to handleToggleEnabled as it is not used anywhere else


const onReset = useCallback(() => {
// Reset values to defaults but keep adjustments present; preserve enabled/collapsed/mode
dispatch(
rasterLayerAdjustmentsSimpleUpdated({
entityIdentifier,
simple: {
brightness: 0,
contrast: 0,
saturation: 0,
temperature: 0,
tint: 0,
sharpness: 0,
},
})
);
const defaultPoints: Array<[number, number]> = [
[0, 0],
[255, 255],
];
dispatch(rasterLayerAdjustmentsCurvesUpdated({ entityIdentifier, channel: 'master', points: defaultPoints }));
dispatch(rasterLayerAdjustmentsCurvesUpdated({ entityIdentifier, channel: 'r', points: defaultPoints }));
dispatch(rasterLayerAdjustmentsCurvesUpdated({ entityIdentifier, channel: 'g', points: defaultPoints }));
dispatch(rasterLayerAdjustmentsCurvesUpdated({ entityIdentifier, channel: 'b', points: defaultPoints }));
}, [dispatch, entityIdentifier]);

const onToggleCollapsed = useCallback(() => {
dispatch(
rasterLayerAdjustmentsSet({
entityIdentifier,
adjustments: { collapsed: !collapsed },
})
);
}, [dispatch, entityIdentifier, collapsed]);

const onSetMode = useCallback(
(nextMode: 'simple' | 'curves') => {
if (!layer?.adjustments) {
return;
}
if (nextMode === mode) {
return;
}
dispatch(
rasterLayerAdjustmentsSet({
entityIdentifier,
adjustments: { mode: nextMode },
})
);
},
[dispatch, entityIdentifier, layer?.adjustments, mode]
);

// Memoized click handlers to avoid inline arrow functions in JSX
const onClickModeSimple = useCallback(() => onSetMode('simple'), [onSetMode]);
const onClickModeCurves = useCallback(() => onSetMode('curves'), [onSetMode]);

const slider = useMemo(
() =>
({
row: (label: string, value: number, onChange: (v: number) => void, min = -1, max = 1, step = 0.01) => (
<FormControl pr={2}>
<Flex alignItems="center" gap={3} mb={1}>
<FormLabel m={0} flexShrink={0} minW="90px">
{label}
</FormLabel>
<CompositeNumberInput value={value} onChange={onChange} min={min} max={max} step={step} flex="0 0 96px" />
</Flex>
<CompositeSlider value={value} onChange={onChange} min={min} max={max} step={step} marks />
Comment on lines +120 to +122
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shoudl add defaultValue to each of these

</FormControl>
),
}) as const,
[]
);
Comment on lines +111 to +127
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should just be a separate component not a memoized object


const onBrightness = useCallback(
(v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { brightness: v } })),
[dispatch, entityIdentifier]
);
const onContrast = useCallback(
(v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { contrast: v } })),
[dispatch, entityIdentifier]
);
const onSaturation = useCallback(
(v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { saturation: v } })),
[dispatch, entityIdentifier]
);
const onTemperature = useCallback(
(v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { temperature: v } })),
[dispatch, entityIdentifier]
);
const onTint = useCallback(
(v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { tint: v } })),
[dispatch, entityIdentifier]
);
const onSharpness = useCallback(
(v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { sharpness: v } })),
[dispatch, entityIdentifier]
);

const handleToggleEnabled = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => onToggleEnabled(e.target.checked),
[onToggleEnabled]
);

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 alignItems="center" gap={3} mt={2} mb={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} isActive={mode === 'simple'}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<Button onClick={onClickModeSimple} isActive={mode === 'simple'}>
<Button onClick={onClickModeSimple} colorScheme={mode === 'simple' ? 'invokeBlue' : undefined}>

{t('controlLayers.adjustments.simple')}
</Button>
<Button onClick={onClickModeCurves} isActive={mode === 'curves'}>
{t('controlLayers.adjustments.curves')}
</Button>
</ButtonGroup>
<Switch isChecked={enabled} onChange={handleToggleEnabled} />
<Button size="sm" onClick={onReset} isDisabled={!layer?.adjustments} colorScheme="red">
{t('controlLayers.adjustments.reset')}
</Button>
<Button size="sm" onClick={onFinish} isDisabled={!layer?.adjustments} colorScheme="green">
{t('controlLayers.adjustments.finish')}
</Button>
</Flex>

{!collapsed && mode === 'simple' && (
<>
{slider.row(t('controlLayers.adjustments.brightness'), simple.brightness, onBrightness)}
{slider.row(t('controlLayers.adjustments.contrast'), simple.contrast, onContrast)}
{slider.row(t('controlLayers.adjustments.saturation'), simple.saturation, onSaturation)}
{slider.row(t('controlLayers.adjustments.temperature'), simple.temperature, onTemperature)}
{slider.row(t('controlLayers.adjustments.tint'), simple.tint, onTint)}
{slider.row(t('controlLayers.adjustments.sharpness'), simple.sharpness, onSharpness, 0, 1, 0.01)}
</>
)}

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

RasterLayerAdjustmentsPanel.displayName = 'RasterLayerAdjustmentsPanel';
Loading