From 3eff44ad5431ac0a7ac63a593a566bc69d2cce1e Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 10 Aug 2025 00:47:40 -0400 Subject: [PATCH 01/38] visual adjustment filters --- invokeai/frontend/web/public/locales/en.json | 13 ++ .../components/RasterLayer/RasterLayer.tsx | 3 + .../RasterLayerAdjustmentsPanel.tsx | 175 +++++++++++++++++ .../RasterLayer/RasterLayerMenuItems.tsx | 33 +++- .../CanvasEntity/CanvasEntityAdapterBase.ts | 4 +- .../CanvasEntityAdapterRasterLayer.ts | 83 +++++++- .../features/controlLayers/konva/filters.ts | 182 ++++++++++++++++++ .../controlLayers/store/canvasSlice.ts | 164 ++++++++++++++++ .../src/features/controlLayers/store/types.ts | 26 +++ .../src/features/controlLayers/store/util.ts | 1 + 10 files changed, 679 insertions(+), 5 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 43530efca28..1fea0786015 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2083,6 +2083,19 @@ "pullBboxIntoLayerError": "Problem Pulling BBox Into Layer", "pullBboxIntoReferenceImageOk": "Bbox Pulled Into ReferenceImage", "pullBboxIntoReferenceImageError": "Problem Pulling BBox Into ReferenceImage", + "addAdjustments": "Add Adjustments", + "removeAdjustments": "Remove Adjustments", + "adjustments": { + "heading": "Adjustments", + "expand": "Expand adjustments", + "collapse": "Collapse adjustments", + "brightness": "Brightness", + "contrast": "Contrast", + "saturation": "Saturation", + "temperature": "Temperature", + "tint": "Tint", + "sharpness": "Sharpness" + }, "regionIsEmpty": "Selected region is empty", "mergeVisible": "Merge Visible", "mergeDown": "Merge Down", 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 ddaefb1073e..c0133687816 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -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'; @@ -39,6 +40,8 @@ export const RasterLayer = memo(({ id }: Props) => { + {/* Show adjustments UI only when adjustments exist */} + { + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + 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 simple = layer?.adjustments?.simple ?? { + brightness: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + sharpness: 0, + }; + + const onToggleEnabled = useCallback( + (v: boolean) => { + dispatch( + rasterLayerAdjustmentsSet({ entityIdentifier, adjustments: { enabled: v, collapsed: false, mode: 'simple' } }) + ); + }, + [dispatch, entityIdentifier] + ); + + 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 slider = useMemo( + () => + ({ + row: (label: string, value: number, onChange: (v: number) => void, min = -1, max = 1, step = 0.01) => ( + + + + {label} + + + + + + ), + }) as const, + [] + ); + + 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) => onToggleEnabled(e.target.checked), + [onToggleEnabled] + ); + + // Hide the panel entirely until adjustments are added via context menu + if (!hasAdjustments) { + return null; + } + + return ( + <> + + + } + /> + + Adjustments + + + + + + {!collapsed && ( + <> + {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)} + + )} + + ); +}); + +RasterLayerAdjustmentsPanel.displayName = 'RasterLayerAdjustmentsPanel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx index 65a16a7b4f9..a3b7b0d48dc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -1,4 +1,5 @@ -import { MenuDivider } from '@invoke-ai/ui-library'; +import { MenuDivider, MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IconMenuItemGroup } from 'common/components/IconMenuItem'; import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; import { CanvasEntityMenuItemsCropToBbox } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCropToBbox'; @@ -11,9 +12,33 @@ import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/compon import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; import { RasterLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu'; import { RasterLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu'; -import { memo } from 'react'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { rasterLayerAdjustmentsReset, rasterLayerAdjustmentsSet } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; export const RasterLayerMenuItems = 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 hasAdjustments = Boolean(layer?.adjustments); + const onToggleAdjustmentsPresence = useCallback(() => { + if (hasAdjustments) { + dispatch(rasterLayerAdjustmentsReset({ entityIdentifier })); + } else { + dispatch( + rasterLayerAdjustmentsSet({ + entityIdentifier, + adjustments: { enabled: true, collapsed: false, mode: 'simple' }, + }) + ); + } + }, [dispatch, entityIdentifier, hasAdjustments]); + return ( <> @@ -22,6 +47,10 @@ export const RasterLayerMenuItems = memo(() => { + + {hasAdjustments ? t('controlLayers.removeAdjustments') : t('controlLayers.addAdjustments')} + + diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts index 6c55e949377..2b45f61b291 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts @@ -475,7 +475,7 @@ export abstract class CanvasEntityAdapterBase { @@ -74,4 +80,79 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< const keysToOmit: (keyof CanvasRasterLayerState)[] = ['name', 'isLocked']; return omit(this.state, keysToOmit); }; + + private syncAdjustmentsFilter = () => { + const a = this.state.adjustments; + const apply = !!a && a.enabled; + // The filter operates on the renderer's object group; we can set filters at the group level via renderer + const group = this.renderer.konva.objectGroup; + if (apply) { + const filters = group.filters() ?? []; + let nextFilters = filters.filter((f: unknown) => f !== AdjustmentsSimpleFilter && f !== AdjustmentsCurvesFilter); + if (a.mode === 'simple') { + group.setAttr('adjustmentsSimple', a.simple); + group.setAttr('adjustmentsCurves', null); + nextFilters = [...nextFilters, AdjustmentsSimpleFilter]; + } else { + // Build LUTs and set curves attr + const master = buildCurveLUT(a.curves.master); + const r = buildCurveLUT(a.curves.r); + const g = buildCurveLUT(a.curves.g); + const b = buildCurveLUT(a.curves.b); + group.setAttr('adjustmentsCurves', { master, r, g, b }); + group.setAttr('adjustmentsSimple', null); + nextFilters = [...nextFilters, AdjustmentsCurvesFilter]; + } + group.filters(nextFilters); + this._throttledCacheRefresh(); + } else { + // Remove our filter if present + const filters = (group.filters() ?? []).filter( + (f: unknown) => f !== AdjustmentsSimpleFilter && f !== AdjustmentsCurvesFilter + ); + group.filters(filters); + group.setAttr('adjustmentsSimple', null); + group.setAttr('adjustmentsCurves', null); + this._throttledCacheRefresh(); + } + }; + + private _throttledCacheRefresh = throttle(() => this.renderer.syncKonvaCache(true), 50); + + private haveAdjustmentsChanged = (prevState: CanvasRasterLayerState, currState: CanvasRasterLayerState): boolean => { + const pa = prevState.adjustments; + const ca = currState.adjustments; + if (pa === ca) { + return false; + } + if (!pa || !ca) { + return true; + } + if (pa.enabled !== ca.enabled) { + return true; + } + if (pa.mode !== ca.mode) { + return true; + } + // simple params + const ps = pa.simple; + const cs = ca.simple; + if ( + ps.brightness !== cs.brightness || + ps.contrast !== cs.contrast || + ps.saturation !== cs.saturation || + ps.temperature !== cs.temperature || + ps.tint !== cs.tint || + ps.sharpness !== cs.sharpness + ) { + return true; + } + // curves reference (UI not implemented yet) - if arrays differ by ref, consider changed + const pc = pa.curves; + const cc = ca.curves; + if (pc !== cc) { + return true; + } + return false; + }; } diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts index 34c5c9ac5de..159d2c6da3d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts @@ -20,3 +20,185 @@ export const LightnessToAlphaFilter = (imageData: ImageData): void => { imageData.data[i * 4 + 3] = Math.min(a, (cMin + cMax) / 2); } }; + +// Utility clamp +const clamp = (v: number, min: number, max: number) => (v < min ? min : v > max ? max : v); + +type SimpleAdjustParams = { + brightness: number; // -1..1 (additive) + contrast: number; // -1..1 (scale around 128) + saturation: number; // -1..1 + temperature: number; // -1..1 (blue<->yellow approx) + tint: number; // -1..1 (green<->magenta approx) + sharpness: number; // -1..1 (light unsharp mask) +}; + +/** + * Per-layer simple adjustments filter (brightness, contrast, saturation, temp, tint, sharpness). + * + * Parameters are read from the Konva node attr `adjustmentsSimple` set by the adapter. + */ +type KonvaFilterThis = { getAttr?: (key: string) => unknown }; +export const AdjustmentsSimpleFilter = function (this: KonvaFilterThis, imageData: ImageData): void { + const params = (this?.getAttr?.('adjustmentsSimple') as SimpleAdjustParams | undefined) ?? null; + if (!params) { + return; + } + + const { brightness, contrast, saturation, temperature, tint, sharpness } = params; + + const data = imageData.data; + const len = data.length / 4; + const width = (imageData as ImageData & { width: number }).width ?? 0; + const height = (imageData as ImageData & { height: number }).height ?? 0; + + // Precompute factors + const brightnessShift = brightness * 255; // additive shift + const contrastFactor = 1 + contrast; // scale around 128 + + // Temperature/Tint multipliers + const tempK = 0.5; + const tintK = 0.5; + const rTempMul = 1 + temperature * tempK; + const bTempMul = 1 - temperature * tempK; + const rTintMul = 1 + (tint > 0 ? tint * tintK : -tint * 0); + const gTintMul = 1 - Math.abs(tint) * tintK; + const bTintMul = 1 + (tint > 0 ? tint * tintK : -tint * 0); + + // Saturation matrix (HSL-based approximation via luma coefficients) + const lumaR = 0.2126; + const lumaG = 0.7152; + const lumaB = 0.0722; + const S = 1 + saturation; // 0..2 + const m00 = lumaR * (1 - S) + S; + const m01 = lumaG * (1 - S); + const m02 = lumaB * (1 - S); + const m10 = lumaR * (1 - S); + const m11 = lumaG * (1 - S) + S; + const m12 = lumaB * (1 - S); + const m20 = lumaR * (1 - S); + const m21 = lumaG * (1 - S); + const m22 = lumaB * (1 - S) + S; + + // First pass: apply per-pixel color adjustments (excluding sharpness) + for (let i = 0; i < len; i++) { + const idx = i * 4; + let r = data[idx + 0] as number; + let g = data[idx + 1] as number; + let b = data[idx + 2] as number; + const a = data[idx + 3] as number; + + // Brightness (additive) + r = r + brightnessShift; + g = g + brightnessShift; + b = b + brightnessShift; + + // Contrast around mid-point 128 + r = (r - 128) * contrastFactor + 128; + g = (g - 128) * contrastFactor + 128; + b = (b - 128) * contrastFactor + 128; + + // Temperature (R/B axis) and Tint (G vs Magenta) + r = r * rTempMul * rTintMul; + g = g * gTintMul; + b = b * bTempMul * bTintMul; + + // Saturation via matrix + const r2 = r * m00 + g * m01 + b * m02; + const g2 = r * m10 + g * m11 + b * m12; + const b2 = r * m20 + g * m21 + b * m22; + + data[idx + 0] = clamp(r2, 0, 255); + data[idx + 1] = clamp(g2, 0, 255); + data[idx + 2] = clamp(b2, 0, 255); + data[idx + 3] = a; + } + + // Optional sharpen (simple unsharp mask with 3x3 kernel) + if (Math.abs(sharpness) > 1e-3 && width > 2 && height > 2) { + const src = new Uint8ClampedArray(data); // copy of modified data + const a = Math.max(-1, Math.min(1, sharpness)) * 0.5; // amount + const center = 1 + 4 * a; + const neighbor = -a; + for (let y = 1; y < height - 1; y++) { + for (let x = 1; x < width - 1; x++) { + const idx = (y * width + x) * 4; + for (let c = 0; c < 3; c++) { + const centerPx = src[idx + c] ?? 0; + const leftPx = src[idx - 4 + c] ?? 0; + const rightPx = src[idx + 4 + c] ?? 0; + const topPx = src[idx - width * 4 + c] ?? 0; + const bottomPx = src[idx + width * 4 + c] ?? 0; + const v = centerPx * center + leftPx * neighbor + rightPx * neighbor + topPx * neighbor + bottomPx * neighbor; + data[idx + c] = clamp(v, 0, 255); + } + // preserve alpha + } + } + } +}; + +// Build a 256-length LUT from 0..255 control points (linear interpolation for v1) +export const buildCurveLUT = (points: Array<[number, number]>): number[] => { + if (!points || points.length === 0) { + return Array.from({ length: 256 }, (_, i) => i); + } + const pts = points + .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] as [number, number]) + .sort((a, b) => a[0] - b[0]); + if ((pts[0]?.[0] ?? 0) !== 0) { + pts.unshift([0, pts[0]?.[1] ?? 0]); + } + const last = pts[pts.length - 1]; + if ((last?.[0] ?? 255) !== 255) { + pts.push([255, last?.[1] ?? 255]); + } + const lut = new Array(256); + let j = 0; + for (let x = 0; x <= 255; x++) { + while (j < pts.length - 2 && x > (pts[j + 1]?.[0] ?? 255)) { + j++; + } + const p0 = pts[j] ?? [0, 0]; + const p1 = pts[j + 1] ?? [255, 255]; + const [x0, y0] = p0; + const [x1, y1] = p1; + const t = x1 === x0 ? 0 : (x - x0) / (x1 - x0); + const y = y0 + (y1 - y0) * t; + lut[x] = clamp(Math.round(y), 0, 255); + } + return lut; +}; + +type CurvesAdjustParams = { + master: number[]; + r: number[]; + g: number[]; + b: number[]; +}; + +// Curves filter: apply master curve, then per-channel curves +export const AdjustmentsCurvesFilter = function (this: KonvaFilterThis, imageData: ImageData): void { + const params = (this?.getAttr?.('adjustmentsCurves') as CurvesAdjustParams | undefined) ?? null; + if (!params) { + return; + } + const { master, r, g, b } = params; + if (!master || !r || !g || !b) { + return; + } + const data = imageData.data; + const len = data.length / 4; + for (let i = 0; i < len; i++) { + const idx = i * 4; + const r0 = data[idx + 0] as number; + const g0 = data[idx + 1] as number; + const b0 = data[idx + 2] as number; + const rm = master[r0] ?? r0; + const gm = master[g0] ?? g0; + const bm = master[b0] ?? b0; + data[idx + 0] = clamp(r[rm] ?? rm, 0, 255); + data[idx + 1] = clamp(g[gm] ?? gm, 0, 255); + data[idx + 2] = clamp(b[bm] ?? bm, 0, 255); + } +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 46f1e620bdd..ae5b9b243da 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -104,6 +104,165 @@ const slice = createSlice({ reducers: { // undoable canvas state //#region Raster layers + rasterLayerAdjustmentsSet: ( + state, + action: PayloadAction< + EntityIdentifierPayload< + { + adjustments: + | NonNullable + | { enabled?: boolean; collapsed?: boolean; mode?: 'simple' | 'curves' } + | null; + }, + 'raster_layer' + > + > + ) => { + const { entityIdentifier, adjustments } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + if (adjustments === null) { + layer.adjustments = null; + return; + } + if (layer.adjustments === null) { + layer.adjustments = { + version: 1, + enabled: true, + collapsed: false, + mode: 'simple', + simple: { brightness: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, sharpness: 0 }, + curves: { + master: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + b: [ + [0, 0], + [255, 255], + ], + }, + }; + } + if (typeof adjustments === 'object' && adjustments !== null && 'version' in adjustments) { + layer.adjustments = merge(layer.adjustments, adjustments as NonNullable); + } else { + // Shallow toggles only + const partial = adjustments as { enabled?: boolean; collapsed?: boolean; mode?: 'simple' | 'curves' }; + layer.adjustments = merge(layer.adjustments, partial); + } + }, + rasterLayerAdjustmentsReset: (state, action: PayloadAction>) => { + const { entityIdentifier } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + layer.adjustments = null; + }, + rasterLayerAdjustmentsSimpleUpdated: ( + state, + action: PayloadAction< + EntityIdentifierPayload< + { + simple: Partial['simple']>>; + }, + 'raster_layer' + > + > + ) => { + const { entityIdentifier, simple } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + if (!layer.adjustments) { + // initialize baseline + layer.adjustments = { + version: 1, + enabled: true, + collapsed: false, + mode: 'simple', + simple: { brightness: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, sharpness: 0 }, + curves: { + master: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + b: [ + [0, 0], + [255, 255], + ], + }, + }; + } + layer.adjustments.simple = merge(layer.adjustments.simple, simple); + }, + rasterLayerAdjustmentsCurvesUpdated: ( + state, + action: PayloadAction< + EntityIdentifierPayload< + { + channel: 'master' | 'r' | 'g' | 'b'; + points: Array<[number, number]>; + }, + 'raster_layer' + > + > + ) => { + const { entityIdentifier, channel, points } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + if (!layer.adjustments) { + // initialize baseline + layer.adjustments = { + version: 1, + enabled: true, + collapsed: false, + mode: 'curves', + simple: { brightness: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, sharpness: 0 }, + curves: { + master: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + b: [ + [0, 0], + [255, 255], + ], + }, + }; + } + layer.adjustments.curves[channel] = points; + }, rasterLayerAdded: { reducer: ( state, @@ -1658,6 +1817,11 @@ export const { entityBrushLineAdded, entityEraserLineAdded, entityRectAdded, + // Raster layer adjustments + rasterLayerAdjustmentsSet, + rasterLayerAdjustmentsReset, + rasterLayerAdjustmentsSimpleUpdated, + rasterLayerAdjustmentsCurvesUpdated, 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 d0a3414a572..79304e1652f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -383,6 +383,32 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({ position: zCoordinate, opacity: zOpacity, objects: z.array(zCanvasObjectState), + // Optional per-layer color adjustments (simple + curves). When null/undefined, no adjustments are applied. + adjustments: z + .object({ + version: z.literal(1), + enabled: z.boolean(), + collapsed: z.boolean(), + mode: z.enum(['simple', 'curves']), + simple: z.object({ + // All simple params normalized to [-1, 1] except sharpness [0, 1] + brightness: z.number().gte(-1).lte(1), + contrast: z.number().gte(-1).lte(1), + saturation: z.number().gte(-1).lte(1), + temperature: z.number().gte(-1).lte(1), + tint: z.number().gte(-1).lte(1), + sharpness: z.number().gte(0).lte(1), + }), + curves: z.object({ + // Curves are arrays of [x, y] control points in 0..255 space (no strict monotonic checks here) + master: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), + r: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), + g: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), + b: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), + }), + }) + .optional() + .nullable(), }); 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 88798a048df..a203230b0a8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -187,6 +187,7 @@ export const getRasterLayerState = ( objects: [], opacity: 1, position: { x: 0, y: 0 }, + adjustments: null, }; merge(entityState, overrides); return entityState; From fff92e92f638eb7aa981d22f1cabddde3617e068 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 10 Aug 2025 01:43:11 -0400 Subject: [PATCH 02/38] apply filters to operations --- .../controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts | 2 +- .../konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts | 2 +- .../konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts index 2b45f61b291..5995e80663c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts @@ -571,7 +571,7 @@ export abstract class CanvasEntityAdapterBase => { const { rect } = this.manager.stateApi.getBbox(); const rasterizeResult = await withResultAsync(() => - this.renderer.rasterize({ rect, replaceObjects: true, attrs: { opacity: 1, filters: [] } }) + this.renderer.rasterize({ rect, replaceObjects: true, attrs: { opacity: 1 } }) ); if (rasterizeResult.isErr()) { toast({ status: 'error', title: 'Failed to crop to bbox' }); diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts index 7e31b594fac..06620584fc5 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterControlLayer.ts @@ -72,7 +72,7 @@ export class CanvasEntityAdapterControlLayer extends CanvasEntityAdapterBase< this.log.trace({ rect }, 'Getting canvas'); // The opacity may have been changed in response to user selecting a different entity category, so we must restore // the original opacity before rendering the canvas - const attrs: GroupConfig = { opacity: this.state.opacity, filters: [] }; + const attrs: GroupConfig = { opacity: this.state.opacity }; const canvas = this.renderer.getCanvas({ rect, attrs }); return canvas; }; diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts index d2fdd12448f..cd8dee6d2f3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts @@ -71,7 +71,7 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< this.log.trace({ rect }, 'Getting canvas'); // The opacity may have been changed in response to user selecting a different entity category, so we must restore // the original opacity before rendering the canvas - const attrs: GroupConfig = { opacity: this.state.opacity, filters: [] }; + const attrs: GroupConfig = { opacity: this.state.opacity }; const canvas = this.renderer.getCanvas({ rect, attrs }); return canvas; }; From cb8c1db0153a30ee7f741470be4664af25de5333 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 10 Aug 2025 02:32:46 -0400 Subject: [PATCH 03/38] curves editor --- .../RasterLayerAdjustmentsPanel.tsx | 37 +- .../RasterLayer/RasterLayerCurvesEditor.tsx | 368 ++++++++++++++++++ 2 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index 5a159a58f6c..40d6a262da0 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -1,5 +1,6 @@ import { Button, + ButtonGroup, CompositeNumberInput, CompositeSlider, Flex, @@ -10,6 +11,7 @@ import { Text, } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { RasterLayerCurvesEditor } from 'features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerAdjustmentsCurvesUpdated, @@ -30,6 +32,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { 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, @@ -82,6 +85,28 @@ export const RasterLayerAdjustmentsPanel = memo(() => { ); }, [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( () => ({ @@ -152,13 +177,21 @@ export const RasterLayerAdjustmentsPanel = memo(() => { Adjustments + + + + - {!collapsed && ( + {!collapsed && mode === 'simple' && ( <> {slider.row(t('controlLayers.adjustments.brightness'), simple.brightness, onBrightness)} {slider.row(t('controlLayers.adjustments.contrast'), simple.contrast, onContrast)} @@ -168,6 +201,8 @@ export const RasterLayerAdjustmentsPanel = memo(() => { {slider.row(t('controlLayers.adjustments.sharpness'), simple.sharpness, onSharpness, 0, 1, 0.01)} )} + + {!collapsed && mode === 'curves' && } ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx new file mode 100644 index 00000000000..5123860c331 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx @@ -0,0 +1,368 @@ +import { Flex, Text } from '@invoke-ai/ui-library'; +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 { selectEntity } from 'features/controlLayers/store/selectors'; +import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +const DEFAULT_POINTS: Array<[number, number]> = [ + [0, 0], + [255, 255], +]; + +type Channel = 'master' | 'r' | 'g' | 'b'; + +const channelColor: Record = { + master: '#888', + r: '#e53e3e', + g: '#38a169', + b: '#3182ce', +}; + +const clamp = (v: number, min: number, max: number) => (v < min ? min : v > max ? max : v); + +const sortPoints = (pts: Array<[number, number]>) => + [...pts] + .sort((a, b) => a[0] - b[0]) + .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] as [number, number]); + +type CurveGraphProps = { + title: string; + channel: Channel; + points: Array<[number, number]> | undefined; + histogram: number[] | null; + onChange: (pts: Array<[number, number]>) => void; +}; + +const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { + const { title, channel, points, histogram, onChange } = props; + const canvasRef = useRef(null); + const [localPoints, setLocalPoints] = useState>(sortPoints(points ?? DEFAULT_POINTS)); + const [dragIndex, setDragIndex] = useState(null); + + useEffect(() => { + setLocalPoints(sortPoints(points ?? DEFAULT_POINTS)); + }, [points]); + + const width = 256; + const height = 160; + + const draw = useCallback(() => { + const c = canvasRef.current; + if (!c) { + return; + } + c.width = width; + c.height = height; + const ctx = c.getContext('2d'); + if (!ctx) { + return; + } + + // background + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = '#111'; + ctx.fillRect(0, 0, width, height); + + // grid + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 1; + for (let i = 0; i <= 4; i++) { + const y = (i * height) / 4; + ctx.beginPath(); + ctx.moveTo(0, y + 0.5); + ctx.lineTo(width, y + 0.5); + ctx.stroke(); + } + for (let i = 0; i <= 4; i++) { + const x = (i * width) / 4; + ctx.beginPath(); + ctx.moveTo(x + 0.5, 0); + ctx.lineTo(x + 0.5, height); + ctx.stroke(); + } + + // histogram + if (histogram) { + const max = Math.max(1, ...histogram); + ctx.fillStyle = '#5557'; + for (let x = 0; x < 256; x++) { + const v = histogram[x] ?? 0; + const h = Math.round((v / max) * (height - 4)); + ctx.fillRect(x, height - h, 1, h); + } + } + + // curve + const pts = sortPoints(localPoints); + ctx.strokeStyle = channelColor[channel]; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const [x, y] = pts[i]!; + const cx = x; + const cy = height - (y / 255) * height; + if (i === 0) { + ctx.moveTo(cx, cy); + } else { + ctx.lineTo(cx, cy); + } + } + ctx.stroke(); + + // control points + for (let i = 0; i < pts.length; i++) { + const [x, y] = pts[i]!; + const cx = x; + const cy = height - (y / 255) * height; + ctx.fillStyle = '#000'; + ctx.beginPath(); + ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = channelColor[channel]; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + + // title + ctx.fillStyle = '#bbb'; + ctx.font = '12px sans-serif'; + ctx.fillText(title, 6, 14); + }, [channel, height, histogram, localPoints, title, width]); + + useEffect(() => { + draw(); + }, [draw]); + + const getNearestPointIndex = useCallback( + (mx: number, my: number) => { + // map canvas y to [0..255] + const yVal = clamp(Math.round(255 - (my / height) * 255), 0, 255); + const xVal = clamp(Math.round(mx), 0, 255); + let best = -1; + let bestDist = 9999; + for (let i = 0; i < localPoints.length; i++) { + const [px, py] = localPoints[i]!; + const dx = px - xVal; + const dy = py - yVal; + const d = dx * dx + dy * dy; + if (d < bestDist) { + best = i; + bestDist = d; + } + } + if (best !== -1 && bestDist <= 20 * 20) { + return best; + } + return -1; + }, + [height, localPoints] + ); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const idx = getNearestPointIndex(mx, my); + if (idx !== -1 && idx !== 0 && idx !== localPoints.length - 1) { + setDragIndex(idx); + return; + } + // add new point + const xVal = clamp(Math.round(mx), 0, 255); + const yVal = clamp(Math.round(255 - (my / height) * 255), 0, 255); + const next = sortPoints([...localPoints, [xVal, yVal]]); + setLocalPoints(next); + setDragIndex(next.findIndex(([x, y]) => x === xVal && y === yVal)); + }, + [getNearestPointIndex, height, localPoints] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (dragIndex === null) { + return; + } + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); + const mx = clamp(Math.round(e.clientX - rect.left), 0, 255); + const myPx = clamp(Math.round(255 - ((e.clientY - rect.top) / height) * 255), 0, 255); + setLocalPoints((prev) => { + const next = [...prev]; + // clamp endpoints to ends and keep them immutable + if (dragIndex === 0) { + return prev; + } + if (dragIndex === prev.length - 1) { + return prev; + } + next[dragIndex] = [mx, myPx]; + return sortPoints(next); + }); + }, + [dragIndex, height] + ); + + const commit = useCallback( + (pts: Array<[number, number]>) => { + onChange(sortPoints(pts)); + }, + [onChange] + ); + + const handlePointerUp = useCallback(() => { + setDragIndex(null); + commit(localPoints); + }, [commit, localPoints]); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const idx = getNearestPointIndex(mx, my); + if (idx > 0 && idx < localPoints.length - 1) { + const next = localPoints.filter((_, i) => i !== idx); + setLocalPoints(next); + commit(next); + } + }, + [commit, getNearestPointIndex, localPoints] + ); + + const canvasStyle = useMemo( + () => ({ width: '100%', height: height, touchAction: 'none', borderRadius: 4, background: '#111' }), + [height] + ); + + return ( + + ); +}); + +export const RasterLayerCurvesEditor = memo(() => { + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const adapter = useEntityAdapterContext<'raster_layer'>('raster_layer'); + const layer = useAppSelector((s) => selectEntity(s.canvas.present, entityIdentifier)) as + | CanvasRasterLayerState + | undefined; + + const [histMaster, setHistMaster] = useState(null); + const [histR, setHistR] = useState(null); + const [histG, setHistG] = useState(null); + const [histB, setHistB] = useState(null); + + const pointsMaster = layer?.adjustments?.curves.master ?? DEFAULT_POINTS; + const pointsR = layer?.adjustments?.curves.r ?? DEFAULT_POINTS; + const pointsG = layer?.adjustments?.curves.g ?? DEFAULT_POINTS; + const pointsB = layer?.adjustments?.curves.b ?? DEFAULT_POINTS; + + 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 data = imageData.data; + const len = data.length / 4; + const master = new Array(256).fill(0); + const r = new Array(256).fill(0); + const g = new Array(256).fill(0); + const b = new Array(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; + } + } + setHistMaster(master); + setHistR(r); + setHistG(g); + setHistB(b); + } catch { + // ignore + } + }, [adapter]); + + useEffect(() => { + recalcHistogram(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layer?.objects, layer?.adjustments]); + + const onChangePoints = useCallback( + (channel: Channel, pts: Array<[number, number]>) => { + dispatch(rasterLayerAdjustmentsCurvesUpdated({ entityIdentifier, channel, points: pts })); + }, + [dispatch, entityIdentifier] + ); + + // Memoize per-channel change handlers to avoid inline lambdas in JSX + const onChangeMaster = useCallback((pts: Array<[number, number]>) => onChangePoints('master', pts), [onChangePoints]); + const onChangeR = useCallback((pts: Array<[number, number]>) => onChangePoints('r', pts), [onChangePoints]); + const onChangeG = useCallback((pts: Array<[number, number]>) => onChangePoints('g', pts), [onChangePoints]); + const onChangeB = useCallback((pts: Array<[number, number]>) => onChangePoints('b', pts), [onChangePoints]); + + const gridStyles: React.CSSProperties = useMemo( + () => ({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 }), + [] + ); + + return ( + + + Curves + +
+ + + + +
+
+ ); +}); + +RasterLayerCurvesEditor.displayName = 'RasterLayerCurvesEditor'; From e20960a40ed600b81155659887e2c323a3b7ce39 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 10 Aug 2025 02:44:06 -0400 Subject: [PATCH 04/38] log scale and panel width compatibility --- .../RasterLayer/RasterLayerCurvesEditor.tsx | 141 +++++++++++++----- 1 file changed, 102 insertions(+), 39 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx index 5123860c331..7b1af3c5d9b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx @@ -48,6 +48,31 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const width = 256; const height = 160; + // inner margins to keep a small buffer from edges (left/right/bottom) and space for title at top + const MARGIN_LEFT = 8; + const MARGIN_RIGHT = 8; + const MARGIN_TOP = 14; + const MARGIN_BOTTOM = 10; + const INNER_WIDTH = width - MARGIN_LEFT - MARGIN_RIGHT; + const INNER_HEIGHT = height - MARGIN_TOP - MARGIN_BOTTOM; + + // helpers to map value-space [0..255] to canvas pixels (respecting inner margins) + const valueToCanvasX = useCallback( + (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * INNER_WIDTH, + [INNER_WIDTH] + ); + const valueToCanvasY = useCallback( + (y: number) => MARGIN_TOP + INNER_HEIGHT - (clamp(y, 0, 255) / 255) * INNER_HEIGHT, + [INNER_HEIGHT] + ); + const canvasToValueX = useCallback( + (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / INNER_WIDTH) * 255), 0, 255), + [INNER_WIDTH] + ); + const canvasToValueY = useCallback( + (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / INNER_HEIGHT) * 255), 0, 255), + [INNER_HEIGHT] + ); const draw = useCallback(() => { const c = canvasRef.current; @@ -66,32 +91,37 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { ctx.fillStyle = '#111'; ctx.fillRect(0, 0, width, height); - // grid + // grid inside inner rect ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { - const y = (i * height) / 4; + const y = MARGIN_TOP + (i * INNER_HEIGHT) / 4; ctx.beginPath(); - ctx.moveTo(0, y + 0.5); - ctx.lineTo(width, y + 0.5); + ctx.moveTo(MARGIN_LEFT + 0.5, y + 0.5); + ctx.lineTo(MARGIN_LEFT + INNER_WIDTH - 0.5, y + 0.5); ctx.stroke(); } for (let i = 0; i <= 4; i++) { - const x = (i * width) / 4; + const x = MARGIN_LEFT + (i * INNER_WIDTH) / 4; ctx.beginPath(); - ctx.moveTo(x + 0.5, 0); - ctx.lineTo(x + 0.5, height); + ctx.moveTo(x + 0.5, MARGIN_TOP + 0.5); + ctx.lineTo(x + 0.5, MARGIN_TOP + INNER_HEIGHT - 0.5); ctx.stroke(); } // histogram if (histogram) { - const max = Math.max(1, ...histogram); + // logarithmic histogram for readability when values vary widely + const logHist = histogram.map((v) => Math.log10((v ?? 0) + 1)); + const max = Math.max(1e-6, ...logHist); ctx.fillStyle = '#5557'; - for (let x = 0; x < 256; x++) { - const v = histogram[x] ?? 0; - const h = Math.round((v / max) * (height - 4)); - ctx.fillRect(x, height - h, 1, h); + const binW = Math.max(1, INNER_WIDTH / 256); + for (let i = 0; i < 256; i++) { + const v = logHist[i] ?? 0; + const h = Math.round((v / max) * (INNER_HEIGHT - 2)); + const x = MARGIN_LEFT + Math.floor(i * binW); + const y = MARGIN_TOP + INNER_HEIGHT - h; + ctx.fillRect(x, y, Math.ceil(binW), h); } } @@ -102,8 +132,8 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { ctx.beginPath(); for (let i = 0; i < pts.length; i++) { const [x, y] = pts[i]!; - const cx = x; - const cy = height - (y / 255) * height; + const cx = valueToCanvasX(x); + const cy = valueToCanvasY(y); if (i === 0) { ctx.moveTo(cx, cy); } else { @@ -115,8 +145,8 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { // control points for (let i = 0; i < pts.length; i++) { const [x, y] = pts[i]!; - const cx = x; - const cy = height - (y / 255) * height; + const cx = valueToCanvasX(x); + const cy = valueToCanvasY(y); ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); @@ -129,18 +159,31 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { // title ctx.fillStyle = '#bbb'; ctx.font = '12px sans-serif'; - ctx.fillText(title, 6, 14); - }, [channel, height, histogram, localPoints, title, width]); + ctx.fillText(title, MARGIN_LEFT + 2, Math.max(12, MARGIN_TOP - 2)); + }, [ + MARGIN_LEFT, + MARGIN_TOP, + INNER_HEIGHT, + INNER_WIDTH, + channel, + height, + histogram, + localPoints, + title, + valueToCanvasX, + valueToCanvasY, + width, + ]); useEffect(() => { draw(); }, [draw]); const getNearestPointIndex = useCallback( - (mx: number, my: number) => { - // map canvas y to [0..255] - const yVal = clamp(Math.round(255 - (my / height) * 255), 0, 255); - const xVal = clamp(Math.round(mx), 0, 255); + (mxCanvas: number, myCanvas: number) => { + // convert canvas px to value-space [0..255] + const xVal = canvasToValueX(mxCanvas); + const yVal = canvasToValueY(myCanvas); let best = -1; let bestDist = 9999; for (let i = 0; i < localPoints.length; i++) { @@ -158,29 +201,35 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { } return -1; }, - [height, localPoints] + [canvasToValueX, canvasToValueY, localPoints] ); const handlePointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); - const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const idx = getNearestPointIndex(mx, my); + const c = canvasRef.current; + if (!c) { + return; + } + const rect = c.getBoundingClientRect(); + const scaleX = c.width / rect.width; + const scaleY = c.height / rect.height; + const mxCanvas = (e.clientX - rect.left) * scaleX; + const myCanvas = (e.clientY - rect.top) * scaleY; + const idx = getNearestPointIndex(mxCanvas, myCanvas); if (idx !== -1 && idx !== 0 && idx !== localPoints.length - 1) { setDragIndex(idx); return; } // add new point - const xVal = clamp(Math.round(mx), 0, 255); - const yVal = clamp(Math.round(255 - (my / height) * 255), 0, 255); + const xVal = canvasToValueX(mxCanvas); + const yVal = canvasToValueY(myCanvas); const next = sortPoints([...localPoints, [xVal, yVal]]); setLocalPoints(next); setDragIndex(next.findIndex(([x, y]) => x === xVal && y === yVal)); }, - [getNearestPointIndex, height, localPoints] + [canvasToValueX, canvasToValueY, getNearestPointIndex, localPoints] ); const handlePointerMove = useCallback( @@ -190,9 +239,17 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { if (dragIndex === null) { return; } - const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); - const mx = clamp(Math.round(e.clientX - rect.left), 0, 255); - const myPx = clamp(Math.round(255 - ((e.clientY - rect.top) / height) * 255), 0, 255); + const c = canvasRef.current; + if (!c) { + return; + } + const rect = c.getBoundingClientRect(); + const scaleX = c.width / rect.width; + const scaleY = c.height / rect.height; + const mxCanvas = (e.clientX - rect.left) * scaleX; + const myCanvas = (e.clientY - rect.top) * scaleY; + const mxVal = canvasToValueX(mxCanvas); + const myVal = canvasToValueY(myCanvas); setLocalPoints((prev) => { const next = [...prev]; // clamp endpoints to ends and keep them immutable @@ -202,11 +259,11 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { if (dragIndex === prev.length - 1) { return prev; } - next[dragIndex] = [mx, myPx]; + next[dragIndex] = [mxVal, myVal]; return sortPoints(next); }); }, - [dragIndex, height] + [canvasToValueX, canvasToValueY, dragIndex] ); const commit = useCallback( @@ -225,10 +282,16 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const idx = getNearestPointIndex(mx, my); + const c = canvasRef.current; + if (!c) { + return; + } + const rect = c.getBoundingClientRect(); + const scaleX = c.width / rect.width; + const scaleY = c.height / rect.height; + const mxCanvas = (e.clientX - rect.left) * scaleX; + const myCanvas = (e.clientY - rect.top) * scaleY; + const idx = getNearestPointIndex(mxCanvas, myCanvas); if (idx > 0 && idx < localPoints.length - 1) { const next = localPoints.filter((_, i) => i !== idx); setLocalPoints(next); From 4c3dcddf36099d1981ca97ddf6d1b136a800506e Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 10 Aug 2025 18:42:48 -0400 Subject: [PATCH 05/38] fix disable toggle reverts to simple view --- .../components/RasterLayer/RasterLayerAdjustmentsPanel.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index 40d6a262da0..b0b1f3dfc4e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -44,9 +44,8 @@ export const RasterLayerAdjustmentsPanel = memo(() => { const onToggleEnabled = useCallback( (v: boolean) => { - dispatch( - rasterLayerAdjustmentsSet({ entityIdentifier, adjustments: { enabled: v, collapsed: false, mode: 'simple' } }) - ); + // Only toggle the enabled state; preserve current mode/collapsed so users can A/B compare + dispatch(rasterLayerAdjustmentsSet({ entityIdentifier, adjustments: { enabled: v } })); }, [dispatch, entityIdentifier] ); From 77229b82aa99ceb878794a98893a89aeaafb8662 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 10 Aug 2025 18:55:47 -0400 Subject: [PATCH 06/38] Fix tint not shifting green in negative direction --- .../web/src/features/controlLayers/konva/filters.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts index 159d2c6da3d..6a8a704e136 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts @@ -61,9 +61,12 @@ export const AdjustmentsSimpleFilter = function (this: KonvaFilterThis, imageDat const tintK = 0.5; const rTempMul = 1 + temperature * tempK; const bTempMul = 1 - temperature * tempK; - const rTintMul = 1 + (tint > 0 ? tint * tintK : -tint * 0); - const gTintMul = 1 - Math.abs(tint) * tintK; - const bTintMul = 1 + (tint > 0 ? tint * tintK : -tint * 0); + // Tint: green <-> magenta. Positive = magenta (R/B up, G down). Negative = green (G up, R/B down). + const t = clamp(tint, -1, 1) * tintK; + const mag = Math.abs(t); + const rTintMul = t >= 0 ? 1 + mag : 1 - mag; + const gTintMul = t >= 0 ? 1 - mag : 1 + mag; + const bTintMul = t >= 0 ? 1 + mag : 1 - mag; // Saturation matrix (HSL-based approximation via luma coefficients) const lumaR = 0.2126; From e642227a94e0c1f9e22e9d8183f1809f3becaca8 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 13 Aug 2025 01:35:38 -0400 Subject: [PATCH 07/38] Finish button on adjustments --- invokeai/frontend/web/public/locales/en.json | 10 ++- .../components/RasterLayer/RasterLayer.tsx | 1 - .../RasterLayerAdjustmentsPanel.tsx | 29 +++++++-- .../RasterLayer/RasterLayerCurvesEditor.tsx | 65 ++++++++++++------- 4 files changed, 76 insertions(+), 29 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 1fea0786015..b94c44187fc 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2086,6 +2086,8 @@ "addAdjustments": "Add Adjustments", "removeAdjustments": "Remove Adjustments", "adjustments": { + "simple": "Simple", + "curves": "Curves", "heading": "Adjustments", "expand": "Expand adjustments", "collapse": "Collapse adjustments", @@ -2094,7 +2096,13 @@ "saturation": "Saturation", "temperature": "Temperature", "tint": "Tint", - "sharpness": "Sharpness" + "sharpness": "Sharpness", + "finish": "Finish", + "reset": "Reset", + "master": "Master", + "red": "Red", + "green": "Green", + "blue": "Blue" }, "regionIsEmpty": "Selected region is empty", "mergeVisible": "Merge Visible", 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 c0133687816..13dc30dea20 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -40,7 +40,6 @@ export const RasterLayer = memo(({ id }: Props) => { - {/* Show adjustments UI only when adjustments exist */} { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const canvasManager = useCanvasManager(); const layer = useAppSelector((s) => selectEntity(s.canvas.present, entityIdentifier)); const { t } = useTranslation(); @@ -154,6 +156,22 @@ export const RasterLayerAdjustmentsPanel = memo(() => { [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; @@ -178,15 +196,18 @@ export const RasterLayerAdjustmentsPanel = memo(() => { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx index 7b1af3c5d9b..cc0cb082253 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx @@ -6,6 +6,7 @@ import { rasterLayerAdjustmentsCurvesUpdated } from 'features/controlLayers/stor import { selectEntity } from 'features/controlLayers/store/selectors'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; const DEFAULT_POINTS: Array<[number, number]> = [ [0, 0], @@ -48,10 +49,10 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const width = 256; const height = 160; - // inner margins to keep a small buffer from edges (left/right/bottom) and space for title at top + // inner margins to keep a small buffer from edges (left/right/bottom) const MARGIN_LEFT = 8; const MARGIN_RIGHT = 8; - const MARGIN_TOP = 14; + const MARGIN_TOP = 8; const MARGIN_BOTTOM = 10; const INNER_WIDTH = width - MARGIN_LEFT - MARGIN_RIGHT; const INNER_HEIGHT = height - MARGIN_TOP - MARGIN_BOTTOM; @@ -155,11 +156,6 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { ctx.lineWidth = 1.5; ctx.stroke(); } - - // title - ctx.fillStyle = '#bbb'; - ctx.font = '12px sans-serif'; - ctx.fillText(title, MARGIN_LEFT + 2, Math.max(12, MARGIN_TOP - 2)); }, [ MARGIN_LEFT, MARGIN_TOP, @@ -169,7 +165,6 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { height, histogram, localPoints, - title, valueToCanvasX, valueToCanvasY, width, @@ -307,16 +302,21 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { ); return ( - +
+ + {title} + + +
); }); @@ -324,6 +324,7 @@ export const RasterLayerCurvesEditor = memo(() => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); const adapter = useEntityAdapterContext<'raster_layer'>('raster_layer'); + const { t } = useTranslation(); const layer = useAppSelector((s) => selectEntity(s.canvas.present, entityIdentifier)) as | CanvasRasterLayerState | undefined; @@ -410,19 +411,37 @@ export const RasterLayerCurvesEditor = memo(() => { return ( - Curves + {t('controlLayers.adjustments.curves')}
- - - + + +
); From e9bfe93011fd0d72a889874ccc7f53d482578708 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 13 Aug 2025 02:25:50 -0400 Subject: [PATCH 08/38] remove extra title --- .../RasterLayer/RasterLayerCurvesEditor.tsx | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx index cc0cb082253..930afe05873 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx @@ -410,9 +410,6 @@ export const RasterLayerCurvesEditor = memo(() => { return ( - - {t('controlLayers.adjustments.curves')} -
{ histogram={histMaster} onChange={onChangeMaster} /> - - - + + +
); From 78f8701fd427cfd83d61c7992dd8e8ff197cad34 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 13 Aug 2025 03:14:42 -0400 Subject: [PATCH 09/38] remove redundant en.json colors --- invokeai/frontend/web/public/locales/en.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index b94c44187fc..e2739d181a6 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2099,10 +2099,7 @@ "sharpness": "Sharpness", "finish": "Finish", "reset": "Reset", - "master": "Master", - "red": "Red", - "green": "Green", - "blue": "Blue" + "master": "Master" }, "regionIsEmpty": "Selected region is empty", "mergeVisible": "Merge Visible", From 2d4db6190db317f1adc74cc7aa9afeab5b46f1df Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Wed, 13 Aug 2025 03:30:55 -0400 Subject: [PATCH 10/38] clean up right click menu --- .../RasterLayer/RasterLayerMenuItems.tsx | 36 ++---------------- .../RasterLayerMenuItemsAdjustments.tsx | 38 +++++++++++++++++++ 2 files changed, 42 insertions(+), 32 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx index a3b7b0d48dc..708f7f29cd6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -1,5 +1,4 @@ -import { MenuDivider, MenuItem } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { MenuDivider } from '@invoke-ai/ui-library'; import { IconMenuItemGroup } from 'common/components/IconMenuItem'; import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; import { CanvasEntityMenuItemsCropToBbox } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCropToBbox'; @@ -10,35 +9,12 @@ import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/component import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; 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 { RasterLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu'; import { RasterLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu'; -import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { rasterLayerAdjustmentsReset, rasterLayerAdjustmentsSet } from 'features/controlLayers/store/canvasSlice'; -import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; +import { memo } from 'react'; export const RasterLayerMenuItems = 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 hasAdjustments = Boolean(layer?.adjustments); - const onToggleAdjustmentsPresence = useCallback(() => { - if (hasAdjustments) { - dispatch(rasterLayerAdjustmentsReset({ entityIdentifier })); - } else { - dispatch( - rasterLayerAdjustmentsSet({ - entityIdentifier, - adjustments: { enabled: true, collapsed: false, mode: 'simple' }, - }) - ); - } - }, [dispatch, entityIdentifier, hasAdjustments]); - return ( <> @@ -46,14 +22,10 @@ export const RasterLayerMenuItems = memo(() => { - - - {hasAdjustments ? t('controlLayers.removeAdjustments') : t('controlLayers.addAdjustments')} - - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx new file mode 100644 index 00000000000..77a939b7bf9 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx @@ -0,0 +1,38 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { rasterLayerAdjustmentsReset, rasterLayerAdjustmentsSet } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSlidersHorizontalBold } from 'react-icons/pi'; + +export const RasterLayerMenuItemsAdjustments = 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 hasAdjustments = Boolean(layer?.adjustments); + const onToggleAdjustmentsPresence = useCallback(() => { + if (hasAdjustments) { + dispatch(rasterLayerAdjustmentsReset({ entityIdentifier })); + } else { + dispatch( + rasterLayerAdjustmentsSet({ + entityIdentifier, + adjustments: { enabled: true, collapsed: false, mode: 'simple' }, + }) + ); + } + }, [dispatch, entityIdentifier, hasAdjustments]); + + return ( + }> + {hasAdjustments ? t('controlLayers.removeAdjustments') : t('controlLayers.addAdjustments')} + + ); +}); + +RasterLayerMenuItemsAdjustments.displayName = 'RasterLayerMenuItemsAdjustments'; From 0f078fe28ecdf32cb08beba5ad24ff5c0390dd54 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Fri, 15 Aug 2025 00:46:30 -0400 Subject: [PATCH 11/38] move memoized slider to component --- .../RasterLayerAdjustmentsPanel.tsx | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index e4dcfcc8622..fadf871b547 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -20,10 +20,30 @@ import { rasterLayerAdjustmentsSimpleUpdated, } from 'features/controlLayers/store/canvasSlice'; import { selectEntity } from 'features/controlLayers/store/selectors'; -import React, { memo, useCallback, useMemo } from 'react'; +import React, { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; +type AdjustmentSliderRowProps = { + label: string; + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; +}; +const AdjustmentSliderRow = ({ label, value, onChange, min = -1, max = 1, step = 0.01 }: AdjustmentSliderRowProps) => ( + + + + {label} + + + + + +); + export const RasterLayerAdjustmentsPanel = memo(() => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); @@ -108,24 +128,6 @@ export const RasterLayerAdjustmentsPanel = memo(() => { 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) => ( - - - - {label} - - - - - - ), - }) as const, - [] - ); - const onBrightness = useCallback( (v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { brightness: v } })), [dispatch, entityIdentifier] @@ -213,12 +215,12 @@ export const RasterLayerAdjustmentsPanel = memo(() => { {!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)} + + + + + + )} From 7f49acf30be1375d4cf9fced1443ec2c27e9c5c8 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Fri, 15 Aug 2025 00:54:45 -0400 Subject: [PATCH 12/38] move constants in curves editor --- .../RasterLayer/RasterLayerCurvesEditor.tsx | 89 ++++++++----------- 1 file changed, 35 insertions(+), 54 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx index 930afe05873..ab78f01128c 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx @@ -29,6 +29,30 @@ const sortPoints = (pts: Array<[number, number]>) => .sort((a, b) => a[0] - b[0]) .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] as [number, number]); +// Extracted canvas constants and helpers out of the component +const CANVAS_WIDTH = 256; +const CANVAS_HEIGHT = 160; +const MARGIN_LEFT = 8; +const MARGIN_RIGHT = 8; +const MARGIN_TOP = 8; +const MARGIN_BOTTOM = 10; +const INNER_WIDTH = CANVAS_WIDTH - MARGIN_LEFT - MARGIN_RIGHT; +const INNER_HEIGHT = CANVAS_HEIGHT - MARGIN_TOP - MARGIN_BOTTOM; + +const valueToCanvasX = (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * INNER_WIDTH; +const valueToCanvasY = (y: number) => MARGIN_TOP + INNER_HEIGHT - (clamp(y, 0, 255) / 255) * INNER_HEIGHT; +const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / INNER_WIDTH) * 255), 0, 255); +const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / INNER_HEIGHT) * 255), 0, 255); + +// Optional: stable canvas style from constants +const CANVAS_STYLE: React.CSSProperties = { + width: '100%', + height: CANVAS_HEIGHT, + touchAction: 'none', + borderRadius: 4, + background: '#111', +}; + type CurveGraphProps = { title: string; channel: Channel; @@ -47,50 +71,22 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { setLocalPoints(sortPoints(points ?? DEFAULT_POINTS)); }, [points]); - const width = 256; - const height = 160; - // inner margins to keep a small buffer from edges (left/right/bottom) - const MARGIN_LEFT = 8; - const MARGIN_RIGHT = 8; - const MARGIN_TOP = 8; - const MARGIN_BOTTOM = 10; - const INNER_WIDTH = width - MARGIN_LEFT - MARGIN_RIGHT; - const INNER_HEIGHT = height - MARGIN_TOP - MARGIN_BOTTOM; - - // helpers to map value-space [0..255] to canvas pixels (respecting inner margins) - const valueToCanvasX = useCallback( - (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * INNER_WIDTH, - [INNER_WIDTH] - ); - const valueToCanvasY = useCallback( - (y: number) => MARGIN_TOP + INNER_HEIGHT - (clamp(y, 0, 255) / 255) * INNER_HEIGHT, - [INNER_HEIGHT] - ); - const canvasToValueX = useCallback( - (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / INNER_WIDTH) * 255), 0, 255), - [INNER_WIDTH] - ); - const canvasToValueY = useCallback( - (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / INNER_HEIGHT) * 255), 0, 255), - [INNER_HEIGHT] - ); - const draw = useCallback(() => { const c = canvasRef.current; if (!c) { return; } - c.width = width; - c.height = height; + c.width = CANVAS_WIDTH; + c.height = CANVAS_HEIGHT; const ctx = c.getContext('2d'); if (!ctx) { return; } // background - ctx.clearRect(0, 0, width, height); + ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); ctx.fillStyle = '#111'; - ctx.fillRect(0, 0, width, height); + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); // grid inside inner rect ctx.strokeStyle = '#2a2a2a'; @@ -156,19 +152,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { ctx.lineWidth = 1.5; ctx.stroke(); } - }, [ - MARGIN_LEFT, - MARGIN_TOP, - INNER_HEIGHT, - INNER_WIDTH, - channel, - height, - histogram, - localPoints, - valueToCanvasX, - valueToCanvasY, - width, - ]); + }, [channel, histogram, localPoints]); useEffect(() => { draw(); @@ -196,7 +180,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { } return -1; }, - [canvasToValueX, canvasToValueY, localPoints] + [localPoints] ); const handlePointerDown = useCallback( @@ -224,7 +208,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { setLocalPoints(next); setDragIndex(next.findIndex(([x, y]) => x === xVal && y === yVal)); }, - [canvasToValueX, canvasToValueY, getNearestPointIndex, localPoints] + [getNearestPointIndex, localPoints] ); const handlePointerMove = useCallback( @@ -258,7 +242,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { return sortPoints(next); }); }, - [canvasToValueX, canvasToValueY, dragIndex] + [dragIndex] ); const commit = useCallback( @@ -296,10 +280,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { [commit, getNearestPointIndex, localPoints] ); - const canvasStyle = useMemo( - () => ({ width: '100%', height: height, touchAction: 'none', borderRadius: 4, background: '#111' }), - [height] - ); + const canvasStyle = useMemo(() => CANVAS_STYLE, []); return (
@@ -308,8 +289,8 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { Date: Fri, 15 Aug 2025 01:19:23 -0400 Subject: [PATCH 13/38] curves editor syntax and structure fixes --- .../RasterLayer/RasterLayerCurvesEditor.tsx | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx index ab78f01128c..5eda937b8bc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx @@ -1,10 +1,10 @@ -import { Flex, Text } from '@invoke-ai/ui-library'; +import { Flex, Text, Box } from '@invoke-ai/ui-library'; 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 { selectEntity } from 'features/controlLayers/store/selectors'; -import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { selectEntity, selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { createSelector } from '@reduxjs/toolkit'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -283,7 +283,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const canvasStyle = useMemo(() => CANVAS_STYLE, []); return ( -
+ {title} @@ -297,7 +297,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { onDoubleClick={handleDoubleClick} style={canvasStyle} /> -
+ ); }); @@ -306,9 +306,12 @@ export const RasterLayerCurvesEditor = memo(() => { const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); const adapter = useEntityAdapterContext<'raster_layer'>('raster_layer'); const { t } = useTranslation(); - const layer = useAppSelector((s) => selectEntity(s.canvas.present, entityIdentifier)) as - | CanvasRasterLayerState - | undefined; + const selectLayer = useMemo( + () => + createSelector(selectCanvasSlice, (canvas) => selectEntity(canvas, entityIdentifier)), + [entityIdentifier] + ); + const layer = useAppSelector(selectLayer); const [histMaster, setHistMaster] = useState(null); const [histR, setHistR] = useState(null); @@ -368,8 +371,7 @@ export const RasterLayerCurvesEditor = memo(() => { useEffect(() => { recalcHistogram(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layer?.objects, layer?.adjustments]); + }, [layer?.objects, layer?.adjustments, recalcHistogram]); const onChangePoints = useCallback( (channel: Channel, pts: Array<[number, number]>) => { @@ -384,14 +386,9 @@ export const RasterLayerCurvesEditor = memo(() => { const onChangeG = useCallback((pts: Array<[number, number]>) => onChangePoints('g', pts), [onChangePoints]); const onChangeB = useCallback((pts: Array<[number, number]>) => onChangePoints('b', pts), [onChangePoints]); - const gridStyles: React.CSSProperties = useMemo( - () => ({ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 8 }), - [] - ); - return ( -
+ { -
+
); }); From d67e89b64ccddbadde77a66e2da0d7d6e83d1199 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Fri, 15 Aug 2025 19:24:28 -0400 Subject: [PATCH 14/38] fix: crop to bbox doubles adjustment filters --- .../controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts index 5995e80663c..2b45f61b291 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterBase.ts @@ -571,7 +571,7 @@ export abstract class CanvasEntityAdapterBase => { const { rect } = this.manager.stateApi.getBbox(); const rasterizeResult = await withResultAsync(() => - this.renderer.rasterize({ rect, replaceObjects: true, attrs: { opacity: 1 } }) + this.renderer.rasterize({ rect, replaceObjects: true, attrs: { opacity: 1, filters: [] } }) ); if (rasterizeResult.isErr()) { toast({ status: 'error', title: 'Failed to crop to bbox' }); From 80aa2d0b9fa1772e7c710565a527ddaf5e64af46 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Fri, 15 Aug 2025 20:04:22 -0400 Subject: [PATCH 15/38] remove extra casts and types from filters.ts --- .../src/features/controlLayers/konva/filters.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts index 6a8a704e136..a85570d1f74 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts @@ -3,6 +3,9 @@ * https://konvajs.org/docs/filters/Custom_Filter.html */ +import { clamp } from 'es-toolkit/compat'; +import type Konva from 'konva'; + /** * Calculates the lightness (HSL) of a given pixel and sets the alpha channel to that value. * This is useful for edge maps and other masks, to make the black areas transparent. @@ -21,9 +24,6 @@ export const LightnessToAlphaFilter = (imageData: ImageData): void => { } }; -// Utility clamp -const clamp = (v: number, min: number, max: number) => (v < min ? min : v > max ? max : v); - type SimpleAdjustParams = { brightness: number; // -1..1 (additive) contrast: number; // -1..1 (scale around 128) @@ -38,8 +38,7 @@ type SimpleAdjustParams = { * * Parameters are read from the Konva node attr `adjustmentsSimple` set by the adapter. */ -type KonvaFilterThis = { getAttr?: (key: string) => unknown }; -export const AdjustmentsSimpleFilter = function (this: KonvaFilterThis, imageData: ImageData): void { +export const AdjustmentsSimpleFilter = function (this: Konva.Node, imageData: ImageData): void { const params = (this?.getAttr?.('adjustmentsSimple') as SimpleAdjustParams | undefined) ?? null; if (!params) { return; @@ -49,8 +48,8 @@ export const AdjustmentsSimpleFilter = function (this: KonvaFilterThis, imageDat const data = imageData.data; const len = data.length / 4; - const width = (imageData as ImageData & { width: number }).width ?? 0; - const height = (imageData as ImageData & { height: number }).height ?? 0; + const width = imageData.width; + const height = imageData.height; // Precompute factors const brightnessShift = brightness * 255; // additive shift @@ -181,7 +180,7 @@ type CurvesAdjustParams = { }; // Curves filter: apply master curve, then per-channel curves -export const AdjustmentsCurvesFilter = function (this: KonvaFilterThis, imageData: ImageData): void { +export const AdjustmentsCurvesFilter = function (this: Konva.Node, imageData: ImageData): void { const params = (this?.getAttr?.('adjustmentsCurves') as CurvesAdjustParams | undefined) ?? null; if (!params) { return; From f94312f8115897f10525542078de74e0b62ac3ea Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 6 Sep 2025 20:28:29 -0400 Subject: [PATCH 16/38] simplify adjustments type to optional not null --- .../RasterLayerAdjustmentsPanel.tsx | 56 +++++++--- .../RasterLayer/RasterLayerCurvesEditor.tsx | 9 +- .../RasterLayerMenuItemsAdjustments.tsx | 3 +- .../controlLayers/store/canvasSlice.ts | 103 ++---------------- .../src/features/controlLayers/store/types.ts | 5 +- .../src/features/controlLayers/store/util.ts | 30 ++++- 6 files changed, 90 insertions(+), 116 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index fadf871b547..f2755a88fe2 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -20,6 +20,7 @@ import { rasterLayerAdjustmentsSimpleUpdated, } from 'features/controlLayers/store/canvasSlice'; import { selectEntity } from 'features/controlLayers/store/selectors'; +import { makeDefaultRasterLayerAdjustments } from 'features/controlLayers/store/util'; import React, { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; @@ -66,10 +67,15 @@ export const RasterLayerAdjustmentsPanel = memo(() => { 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 } })); + const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); + dispatch( + rasterLayerAdjustmentsSet({ + entityIdentifier, + adjustments: { ...current, enabled: v }, + }) + ); }, - [dispatch, entityIdentifier] + [dispatch, entityIdentifier, layer?.adjustments, mode] ); const onReset = useCallback(() => { @@ -98,26 +104,25 @@ export const RasterLayerAdjustmentsPanel = memo(() => { }, [dispatch, entityIdentifier]); const onToggleCollapsed = useCallback(() => { + const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); dispatch( rasterLayerAdjustmentsSet({ entityIdentifier, - adjustments: { collapsed: !collapsed }, + adjustments: { ...current, collapsed: !collapsed }, }) ); - }, [dispatch, entityIdentifier, collapsed]); + }, [dispatch, entityIdentifier, collapsed, layer?.adjustments, mode]); const onSetMode = useCallback( (nextMode: 'simple' | 'curves') => { - if (!layer?.adjustments) { - return; - } if (nextMode === mode) { return; } + const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(nextMode); dispatch( rasterLayerAdjustmentsSet({ entityIdentifier, - adjustments: { mode: nextMode }, + adjustments: { ...current, mode: nextMode }, }) ); }, @@ -215,12 +220,35 @@ export const RasterLayerAdjustmentsPanel = memo(() => { {!collapsed && mode === 'simple' && ( <> - - - - + + + + - + )} diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx index 5eda937b8bc..6fa5a3aa09b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx @@ -1,10 +1,10 @@ -import { Flex, Text, Box } from '@invoke-ai/ui-library'; +import { Box, Flex, Text } from '@invoke-ai/ui-library'; +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 { selectEntity, selectCanvasSlice } from 'features/controlLayers/store/selectors'; -import { createSelector } from '@reduxjs/toolkit'; +import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -307,8 +307,7 @@ export const RasterLayerCurvesEditor = memo(() => { const adapter = useEntityAdapterContext<'raster_layer'>('raster_layer'); const { t } = useTranslation(); const selectLayer = useMemo( - () => - createSelector(selectCanvasSlice, (canvas) => selectEntity(canvas, entityIdentifier)), + () => createSelector(selectCanvasSlice, (canvas) => selectEntity(canvas, entityIdentifier)), [entityIdentifier] ); const layer = useAppSelector(selectLayer); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx index 77a939b7bf9..4164dc4acbb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx @@ -3,6 +3,7 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerAdjustmentsReset, rasterLayerAdjustmentsSet } from 'features/controlLayers/store/canvasSlice'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { makeDefaultRasterLayerAdjustments } from 'features/controlLayers/store/util'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiSlidersHorizontalBold } from 'react-icons/pi'; @@ -22,7 +23,7 @@ export const RasterLayerMenuItemsAdjustments = memo(() => { dispatch( rasterLayerAdjustmentsSet({ entityIdentifier, - adjustments: { enabled: true, collapsed: false, mode: 'simple' }, + adjustments: makeDefaultRasterLayerAdjustments('simple'), }) ); } diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index ae5b9b243da..a6ef7b4a4cf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -96,6 +96,8 @@ import { initialFLUXRedux, initialIPAdapter, initialT2IAdapter, + makeDefaultRasterLayerAdjustments, + type RasterLayerAdjustments, } from './util'; const slice = createSlice({ @@ -109,10 +111,7 @@ const slice = createSlice({ action: PayloadAction< EntityIdentifierPayload< { - adjustments: - | NonNullable - | { enabled?: boolean; collapsed?: boolean; mode?: 'simple' | 'curves' } - | null; + adjustments: RasterLayerAdjustments | null; }, 'raster_layer' > @@ -124,43 +123,13 @@ const slice = createSlice({ return; } if (adjustments === null) { - layer.adjustments = null; + delete layer.adjustments; return; } - if (layer.adjustments === null) { - layer.adjustments = { - version: 1, - enabled: true, - collapsed: false, - mode: 'simple', - simple: { brightness: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, sharpness: 0 }, - curves: { - master: [ - [0, 0], - [255, 255], - ], - r: [ - [0, 0], - [255, 255], - ], - g: [ - [0, 0], - [255, 255], - ], - b: [ - [0, 0], - [255, 255], - ], - }, - }; - } - if (typeof adjustments === 'object' && adjustments !== null && 'version' in adjustments) { - layer.adjustments = merge(layer.adjustments, adjustments as NonNullable); - } else { - // Shallow toggles only - const partial = adjustments as { enabled?: boolean; collapsed?: boolean; mode?: 'simple' | 'curves' }; - layer.adjustments = merge(layer.adjustments, partial); + if (!layer.adjustments) { + layer.adjustments = makeDefaultRasterLayerAdjustments(adjustments.mode ?? 'simple'); } + layer.adjustments = merge(layer.adjustments, adjustments); }, rasterLayerAdjustmentsReset: (state, action: PayloadAction>) => { const { entityIdentifier } = action.payload; @@ -168,14 +137,14 @@ const slice = createSlice({ if (!layer) { return; } - layer.adjustments = null; + delete layer.adjustments; }, rasterLayerAdjustmentsSimpleUpdated: ( state, action: PayloadAction< EntityIdentifierPayload< { - simple: Partial['simple']>>; + simple: Partial; }, 'raster_layer' > @@ -187,32 +156,7 @@ const slice = createSlice({ return; } if (!layer.adjustments) { - // initialize baseline - layer.adjustments = { - version: 1, - enabled: true, - collapsed: false, - mode: 'simple', - simple: { brightness: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, sharpness: 0 }, - curves: { - master: [ - [0, 0], - [255, 255], - ], - r: [ - [0, 0], - [255, 255], - ], - g: [ - [0, 0], - [255, 255], - ], - b: [ - [0, 0], - [255, 255], - ], - }, - }; + layer.adjustments = makeDefaultRasterLayerAdjustments('simple'); } layer.adjustments.simple = merge(layer.adjustments.simple, simple); }, @@ -234,32 +178,7 @@ const slice = createSlice({ return; } if (!layer.adjustments) { - // initialize baseline - layer.adjustments = { - version: 1, - enabled: true, - collapsed: false, - mode: 'curves', - simple: { brightness: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, sharpness: 0 }, - curves: { - master: [ - [0, 0], - [255, 255], - ], - r: [ - [0, 0], - [255, 255], - ], - g: [ - [0, 0], - [255, 255], - ], - b: [ - [0, 0], - [255, 255], - ], - }, - }; + layer.adjustments = makeDefaultRasterLayerAdjustments('curves'); } layer.adjustments.curves[channel] = points; }, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 79304e1652f..a65a267791f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -383,7 +383,7 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({ position: zCoordinate, opacity: zOpacity, objects: z.array(zCanvasObjectState), - // Optional per-layer color adjustments (simple + curves). When null/undefined, no adjustments are applied. + // Optional per-layer color adjustments (simple + curves). When undefined, no adjustments are applied. adjustments: z .object({ version: z.literal(1), @@ -407,8 +407,7 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({ b: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), }), }) - .optional() - .nullable(), + .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 a203230b0a8..9d72c8a9b69 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -118,6 +118,34 @@ export const initialControlLoRA: ControlLoRAConfig = { weight: 0.75, }; +export type RasterLayerAdjustments = NonNullable; + +export const makeDefaultRasterLayerAdjustments = (mode: 'simple' | 'curves' = 'simple'): RasterLayerAdjustments => ({ + version: 1, + enabled: true, + collapsed: false, + mode, + simple: { brightness: 0, contrast: 0, saturation: 0, temperature: 0, tint: 0, sharpness: 0 }, + curves: { + master: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + b: [ + [0, 0], + [255, 255], + ], + }, +}); + export const getReferenceImageState = (id: string, overrides?: PartialDeep): RefImageState => { const entityState: RefImageState = { id, @@ -187,7 +215,7 @@ export const getRasterLayerState = ( objects: [], opacity: 1, position: { x: 0, y: 0 }, - adjustments: null, + adjustments: undefined, }; merge(entityState, overrides); return entityState; From 0a77fc9d03631d939c94b7cf37b3dbcc6190ee82 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 6 Sep 2025 22:47:37 -0400 Subject: [PATCH 17/38] use default factory on reset --- .../RasterLayerAdjustmentsPanel.tsx | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index f2755a88fe2..fa36ae6121d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -80,28 +80,16 @@ export const RasterLayerAdjustmentsPanel = memo(() => { const onReset = useCallback(() => { // Reset values to defaults but keep adjustments present; preserve enabled/collapsed/mode + const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); + const defaults = makeDefaultRasterLayerAdjustments(current.mode ?? mode); + dispatch( - rasterLayerAdjustmentsSimpleUpdated({ + rasterLayerAdjustmentsSet({ entityIdentifier, - simple: { - brightness: 0, - contrast: 0, - saturation: 0, - temperature: 0, - tint: 0, - sharpness: 0, - }, + adjustments: { ...current, simple: defaults.simple, curves: defaults.curves }, }) ); - 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]); + }, [dispatch, entityIdentifier, layer?.adjustments, mode]); const onToggleCollapsed = useCallback(() => { const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); From 3bdb1eaeae63393ee64ed8eb40545d1681408220 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sat, 6 Sep 2025 23:15:59 -0400 Subject: [PATCH 18/38] blue mode switch indicator --- .../RasterLayer/RasterLayerAdjustmentsPanel.tsx | 13 ++++++++++--- .../web/src/features/controlLayers/store/types.ts | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index fa36ae6121d..51f5bf01a96 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -15,7 +15,6 @@ import { RasterLayerCurvesEditor } from 'features/controlLayers/components/Raste import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { - rasterLayerAdjustmentsCurvesUpdated, rasterLayerAdjustmentsSet, rasterLayerAdjustmentsSimpleUpdated, } from 'features/controlLayers/store/canvasSlice'; @@ -190,10 +189,18 @@ export const RasterLayerAdjustmentsPanel = memo(() => { Adjustments - - diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index a65a267791f..6dcc29a1e09 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -401,10 +401,10 @@ const zCanvasRasterLayerState = zCanvasEntityBase.extend({ }), curves: z.object({ // Curves are arrays of [x, y] control points in 0..255 space (no strict monotonic checks here) - master: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), - r: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), - g: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), - b: z.array(z.tuple([z.number().int().min(0).max(255), z.number().int().min(0).max(255)])).min(2), + master: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), + r: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), + g: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), + b: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), }), }) .optional(), From 6dc6e7899652223ca1eaae8e5b04d76e3bbc3395 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 7 Sep 2025 00:53:07 -0400 Subject: [PATCH 19/38] splitup adjustment panel objects --- .../RasterLayerAdjustmentsPanel.tsx | 181 ++++++------------ ...=> RasterLayerCurvesAdjustmentsEditor.tsx} | 169 ++++++++++------ .../RasterLayerMenuItemsAdjustments.tsx | 4 +- .../RasterLayerSimpleAdjustmentsEditor.tsx | 105 ++++++++++ .../controlLayers/store/canvasSlice.ts | 4 +- 5 files changed, 280 insertions(+), 183 deletions(-) rename invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/{RasterLayerCurvesEditor.tsx => RasterLayerCurvesAdjustmentsEditor.tsx} (67%) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index 51f5bf01a96..4e3f03e084f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -1,20 +1,12 @@ -import { - Button, - ButtonGroup, - CompositeNumberInput, - CompositeSlider, - Flex, - FormControl, - FormLabel, - IconButton, - Switch, - Text, -} from '@invoke-ai/ui-library'; +import { Button, ButtonGroup, Flex, IconButton, Switch, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { RasterLayerCurvesEditor } from 'features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor'; +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, + rasterLayerAdjustmentsCurvesUpdated, rasterLayerAdjustmentsSet, rasterLayerAdjustmentsSimpleUpdated, } from 'features/controlLayers/store/canvasSlice'; @@ -22,27 +14,7 @@ import { selectEntity } from 'features/controlLayers/store/selectors'; import { makeDefaultRasterLayerAdjustments } from 'features/controlLayers/store/util'; import React, { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiCaretDownBold } from 'react-icons/pi'; - -type AdjustmentSliderRowProps = { - label: string; - value: number; - onChange: (v: number) => void; - min?: number; - max?: number; - step?: number; -}; -const AdjustmentSliderRow = ({ label, value, onChange, min = -1, max = 1, step = 0.01 }: AdjustmentSliderRowProps) => ( - - - - {label} - - - - - -); +import { PiArrowCounterClockwiseBold, PiCaretDownBold, PiCheckBold, PiTrashBold } from 'react-icons/pi'; export const RasterLayerAdjustmentsPanel = memo(() => { const dispatch = useAppDispatch(); @@ -55,17 +27,11 @@ export const RasterLayerAdjustmentsPanel = memo(() => { 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, - }; + // simple adjustments handled by RasterLayerSimpleAdjustmentsEditor const onToggleEnabled = useCallback( - (v: boolean) => { + (e: React.ChangeEvent) => { + const v = e.target.checked; const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); dispatch( rasterLayerAdjustmentsSet({ @@ -79,16 +45,33 @@ export const RasterLayerAdjustmentsPanel = memo(() => { const onReset = useCallback(() => { // Reset values to defaults but keep adjustments present; preserve enabled/collapsed/mode - const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); - const defaults = makeDefaultRasterLayerAdjustments(current.mode ?? mode); - dispatch( - rasterLayerAdjustmentsSet({ + rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, - adjustments: { ...current, simple: defaults.simple, curves: defaults.curves }, + simple: { + brightness: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + sharpness: 0, + }, }) ); - }, [dispatch, entityIdentifier, layer?.adjustments, mode]); + 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 onCancel = useCallback(() => { + // Clear out adjustments entirely + dispatch(rasterLayerAdjustmentsCancel({ entityIdentifier })); + }, [dispatch, entityIdentifier]); const onToggleCollapsed = useCallback(() => { const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); @@ -120,36 +103,6 @@ export const RasterLayerAdjustmentsPanel = memo(() => { const onClickModeSimple = useCallback(() => onSetMode('simple'), [onSetMode]); const onClickModeCurves = useCallback(() => onSetMode('curves'), [onSetMode]); - 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) => onToggleEnabled(e.target.checked), - [onToggleEnabled] - ); - const onFinish = useCallback(async () => { // Bake current visual into layer pixels, then clear adjustments const adapter = canvasManager.getAdapter(entityIdentifier); @@ -204,50 +157,38 @@ export const RasterLayerAdjustmentsPanel = memo(() => { {t('controlLayers.adjustments.curves')} - - - + + } + variant="ghost" + /> + } + variant="ghost" + /> + } + variant="ghost" + /> - {!collapsed && mode === 'simple' && ( - <> - - - - - - - - )} + {!collapsed && mode === 'simple' && } - {!collapsed && mode === 'curves' && } + {!collapsed && mode === 'curves' && } ); }); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx similarity index 67% rename from invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx rename to invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 6fa5a3aa09b..6e8c3b8bd79 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -29,28 +29,31 @@ const sortPoints = (pts: Array<[number, number]>) => .sort((a, b) => a[0] - b[0]) .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] as [number, number]); -// Extracted canvas constants and helpers out of the component +// Base canvas logical coordinate system (used for aspect ratio & initial sizing) const CANVAS_WIDTH = 256; const CANVAS_HEIGHT = 160; const MARGIN_LEFT = 8; const MARGIN_RIGHT = 8; const MARGIN_TOP = 8; const MARGIN_BOTTOM = 10; -const INNER_WIDTH = CANVAS_WIDTH - MARGIN_LEFT - MARGIN_RIGHT; -const INNER_HEIGHT = CANVAS_HEIGHT - MARGIN_TOP - MARGIN_BOTTOM; +// Inner size is now computed dynamically per current canvas size (see draw()). -const valueToCanvasX = (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * INNER_WIDTH; -const valueToCanvasY = (y: number) => MARGIN_TOP + INNER_HEIGHT - (clamp(y, 0, 255) / 255) * INNER_HEIGHT; -const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / INNER_WIDTH) * 255), 0, 255); -const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / INNER_HEIGHT) * 255), 0, 255); +// NOTE: The helper conversion functions below were static. For responsive canvas resizing we now +// compute conversions dynamically based on the *current* canvas size. The static helpers remain +// only for fallback / reference to the base geometry. Dynamic versions are created in draw & event handlers. + +// (Removed unused static conversion helpers after refactor to fully dynamic sizing.) // Optional: stable canvas style from constants const CANVAS_STYLE: React.CSSProperties = { width: '100%', - height: CANVAS_HEIGHT, + // Maintain aspect ratio while allowing responsive width. Height is set automatically via aspect-ratio. + aspectRatio: `${CANVAS_WIDTH} / ${CANVAS_HEIGHT}`, + height: 'auto', touchAction: 'none', borderRadius: 4, background: '#111', + display: 'block', }; type CurveGraphProps = { @@ -76,53 +79,79 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { if (!c) { return; } - c.width = CANVAS_WIDTH; - c.height = CANVAS_HEIGHT; + + // Use device pixel ratio for crisp rendering on HiDPI displays. + const dpr = window.devicePixelRatio || 1; + const cssWidth = c.clientWidth || CANVAS_WIDTH; // CSS pixels + const cssHeight = (cssWidth * CANVAS_HEIGHT) / CANVAS_WIDTH; // maintain aspect ratio + + // Ensure the backing store matches current display size * dpr (only if changed). + const targetWidth = Math.round(cssWidth * dpr); + const targetHeight = Math.round(cssHeight * dpr); + if (c.width !== targetWidth || c.height !== targetHeight) { + c.width = targetWidth; + c.height = targetHeight; + } + // Guarantee the CSS height stays synced (width is 100%). + if (c.style.height !== `${cssHeight}px`) { + c.style.height = `${cssHeight}px`; + } + const ctx = c.getContext('2d'); if (!ctx) { return; } - // background - ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + // Reset transform then scale for dpr so we can draw in CSS pixel coordinates. + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + + // Dynamic inner geometry (CSS pixel space) + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + + const valueToCanvasX = (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * innerWidth; + const valueToCanvasY = (y: number) => MARGIN_TOP + innerHeight - (clamp(y, 0, 255) / 255) * innerHeight; + + // Clear & background + ctx.clearRect(0, 0, cssWidth, cssHeight); ctx.fillStyle = '#111'; - ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + ctx.fillRect(0, 0, cssWidth, cssHeight); - // grid inside inner rect + // Grid ctx.strokeStyle = '#2a2a2a'; ctx.lineWidth = 1; for (let i = 0; i <= 4; i++) { - const y = MARGIN_TOP + (i * INNER_HEIGHT) / 4; + const y = MARGIN_TOP + (i * innerHeight) / 4; ctx.beginPath(); ctx.moveTo(MARGIN_LEFT + 0.5, y + 0.5); - ctx.lineTo(MARGIN_LEFT + INNER_WIDTH - 0.5, y + 0.5); + ctx.lineTo(MARGIN_LEFT + innerWidth - 0.5, y + 0.5); ctx.stroke(); } for (let i = 0; i <= 4; i++) { - const x = MARGIN_LEFT + (i * INNER_WIDTH) / 4; + const x = MARGIN_LEFT + (i * innerWidth) / 4; ctx.beginPath(); ctx.moveTo(x + 0.5, MARGIN_TOP + 0.5); - ctx.lineTo(x + 0.5, MARGIN_TOP + INNER_HEIGHT - 0.5); + ctx.lineTo(x + 0.5, MARGIN_TOP + innerHeight - 0.5); ctx.stroke(); } - // histogram + // Histogram if (histogram) { - // logarithmic histogram for readability when values vary widely const logHist = histogram.map((v) => Math.log10((v ?? 0) + 1)); const max = Math.max(1e-6, ...logHist); ctx.fillStyle = '#5557'; - const binW = Math.max(1, INNER_WIDTH / 256); + const binW = Math.max(1, innerWidth / 256); for (let i = 0; i < 256; i++) { const v = logHist[i] ?? 0; - const h = Math.round((v / max) * (INNER_HEIGHT - 2)); + const h = Math.round((v / max) * (innerHeight - 2)); const x = MARGIN_LEFT + Math.floor(i * binW); - const y = MARGIN_TOP + INNER_HEIGHT - h; + const y = MARGIN_TOP + innerHeight - h; ctx.fillRect(x, y, Math.ceil(binW), h); } } - // curve + // Curve const pts = sortPoints(localPoints); ctx.strokeStyle = channelColor[channel]; ctx.lineWidth = 2; @@ -139,7 +168,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { } ctx.stroke(); - // control points + // Control points for (let i = 0; i < pts.length; i++) { const [x, y] = pts[i]!; const cx = valueToCanvasX(x); @@ -159,10 +188,19 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { }, [draw]); const getNearestPointIndex = useCallback( - (mxCanvas: number, myCanvas: number) => { - // convert canvas px to value-space [0..255] - const xVal = canvasToValueX(mxCanvas); - const yVal = canvasToValueY(myCanvas); + (mx: number, my: number) => { + const c = canvasRef.current; + if (!c) { + return -1; + } + const cssWidth = c.clientWidth || CANVAS_WIDTH; + const cssHeight = c.clientHeight || CANVAS_HEIGHT; + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); + const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); + const xVal = canvasToValueX(mx); + const yVal = canvasToValueY(my); let best = -1; let bestDist = 9999; for (let i = 0; i < localPoints.length; i++) { @@ -192,18 +230,21 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { return; } const rect = c.getBoundingClientRect(); - const scaleX = c.width / rect.width; - const scaleY = c.height / rect.height; - const mxCanvas = (e.clientX - rect.left) * scaleX; - const myCanvas = (e.clientY - rect.top) * scaleY; - const idx = getNearestPointIndex(mxCanvas, myCanvas); + const mx = e.clientX - rect.left; // CSS pixel coordinates + const my = e.clientY - rect.top; + const cssWidth = c.clientWidth || CANVAS_WIDTH; + const cssHeight = c.clientHeight || CANVAS_HEIGHT; + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); + const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); + const idx = getNearestPointIndex(mx, my); if (idx !== -1 && idx !== 0 && idx !== localPoints.length - 1) { setDragIndex(idx); return; } - // add new point - const xVal = canvasToValueX(mxCanvas); - const yVal = canvasToValueY(myCanvas); + const xVal = canvasToValueX(mx); + const yVal = canvasToValueY(my); const next = sortPoints([...localPoints, [xVal, yVal]]); setLocalPoints(next); setDragIndex(next.findIndex(([x, y]) => x === xVal && y === yVal)); @@ -223,21 +264,21 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { return; } const rect = c.getBoundingClientRect(); - const scaleX = c.width / rect.width; - const scaleY = c.height / rect.height; - const mxCanvas = (e.clientX - rect.left) * scaleX; - const myCanvas = (e.clientY - rect.top) * scaleY; - const mxVal = canvasToValueX(mxCanvas); - const myVal = canvasToValueY(myCanvas); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const cssWidth = c.clientWidth || CANVAS_WIDTH; + const cssHeight = c.clientHeight || CANVAS_HEIGHT; + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); + const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); + const mxVal = canvasToValueX(mx); + const myVal = canvasToValueY(my); setLocalPoints((prev) => { - const next = [...prev]; - // clamp endpoints to ends and keep them immutable - if (dragIndex === 0) { + if (dragIndex === 0 || dragIndex === prev.length - 1) { return prev; - } - if (dragIndex === prev.length - 1) { - return prev; - } + } // immutable endpoints + const next = [...prev]; next[dragIndex] = [mxVal, myVal]; return sortPoints(next); }); @@ -266,11 +307,9 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { return; } const rect = c.getBoundingClientRect(); - const scaleX = c.width / rect.width; - const scaleY = c.height / rect.height; - const mxCanvas = (e.clientX - rect.left) * scaleX; - const myCanvas = (e.clientY - rect.top) * scaleY; - const idx = getNearestPointIndex(mxCanvas, myCanvas); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const idx = getNearestPointIndex(mx, my); if (idx > 0 && idx < localPoints.length - 1) { const next = localPoints.filter((_, i) => i !== idx); setLocalPoints(next); @@ -280,6 +319,19 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { [commit, getNearestPointIndex, localPoints] ); + // Observe size changes to redraw (responsive behavior) + useEffect(() => { + const c = canvasRef.current; + if (!c) { + return; + } + const ro = new ResizeObserver(() => { + draw(); + }); + ro.observe(c); + return () => ro.disconnect(); + }, [draw]); + const canvasStyle = useMemo(() => CANVAS_STYLE, []); return ( @@ -289,8 +341,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { { +export const RasterLayerCurvesAdjustmentsEditor = memo(() => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); const adapter = useEntityAdapterContext<'raster_layer'>('raster_layer'); @@ -403,4 +454,4 @@ export const RasterLayerCurvesEditor = memo(() => { ); }); -RasterLayerCurvesEditor.displayName = 'RasterLayerCurvesEditor'; +RasterLayerCurvesAdjustmentsEditor.displayName = 'RasterLayerCurvesEditor'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx index 4164dc4acbb..86fac78cb3e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsAdjustments.tsx @@ -1,7 +1,7 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; -import { rasterLayerAdjustmentsReset, rasterLayerAdjustmentsSet } from 'features/controlLayers/store/canvasSlice'; +import { rasterLayerAdjustmentsCancel, rasterLayerAdjustmentsSet } from 'features/controlLayers/store/canvasSlice'; import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; import { makeDefaultRasterLayerAdjustments } from 'features/controlLayers/store/util'; import { memo, useCallback } from 'react'; @@ -18,7 +18,7 @@ export const RasterLayerMenuItemsAdjustments = memo(() => { const hasAdjustments = Boolean(layer?.adjustments); const onToggleAdjustmentsPresence = useCallback(() => { if (hasAdjustments) { - dispatch(rasterLayerAdjustmentsReset({ entityIdentifier })); + dispatch(rasterLayerAdjustmentsCancel({ entityIdentifier })); } else { dispatch( rasterLayerAdjustmentsSet({ diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx new file mode 100644 index 00000000000..45ec42dd7d8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx @@ -0,0 +1,105 @@ +import { CompositeNumberInput, CompositeSlider, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { rasterLayerAdjustmentsSimpleUpdated } from 'features/controlLayers/store/canvasSlice'; +import { selectEntity } from 'features/controlLayers/store/selectors'; +import React, { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +type AdjustmentSliderRowProps = { + label: string; + value: number; + onChange: (v: number) => void; + min?: number; + max?: number; + step?: number; +}; + +const AdjustmentSliderRow = ({ label, value, onChange, min = -1, max = 1, step = 0.01 }: AdjustmentSliderRowProps) => ( + + + + {label} + + + + + +); + +export const RasterLayerSimpleAdjustmentsEditor = memo(() => { + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const { t } = useTranslation(); + const layer = useAppSelector((s) => selectEntity(s.canvas.present, entityIdentifier)); + + const simple = layer?.adjustments?.simple ?? { + brightness: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + sharpness: 0, + }; + + 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] + ); + + return ( + <> + + + + + + + + ); +}); + +RasterLayerSimpleAdjustmentsEditor.displayName = 'RasterLayerSimpleAdjustmentsEditor'; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index a6ef7b4a4cf..73829b7a02a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -131,7 +131,7 @@ const slice = createSlice({ } layer.adjustments = merge(layer.adjustments, adjustments); }, - rasterLayerAdjustmentsReset: (state, action: PayloadAction>) => { + rasterLayerAdjustmentsCancel: (state, action: PayloadAction>) => { const { entityIdentifier } = action.payload; const layer = selectEntity(state, entityIdentifier); if (!layer) { @@ -1738,7 +1738,7 @@ export const { entityRectAdded, // Raster layer adjustments rasterLayerAdjustmentsSet, - rasterLayerAdjustmentsReset, + rasterLayerAdjustmentsCancel, rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, entityDeleted, From c844c8ed2b3984d739cc00e36ecd2b5f060275c7 Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 7 Sep 2025 02:37:14 -0400 Subject: [PATCH 20/38] fix several points of curve editor jank --- .../RasterLayerCurvesAdjustmentsEditor.tsx | 106 +++++++++++++++--- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 6e8c3b8bd79..231088dcadf 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -141,13 +141,42 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const logHist = histogram.map((v) => Math.log10((v ?? 0) + 1)); const max = Math.max(1e-6, ...logHist); ctx.fillStyle = '#5557'; - const binW = Math.max(1, innerWidth / 256); - for (let i = 0; i < 256; i++) { - const v = logHist[i] ?? 0; - const h = Math.round((v / max) * (innerHeight - 2)); - const x = MARGIN_LEFT + Math.floor(i * binW); - const y = MARGIN_TOP + innerHeight - h; - ctx.fillRect(x, y, Math.ceil(binW), h); + + // If there's enough horizontal room, draw each of the 256 bins with exact (possibly fractional) width so they tessellate. + // Otherwise, aggregate multiple bins into per-pixel columns to avoid aliasing. + if (innerWidth >= 256) { + for (let i = 0; i < 256; i++) { + const v = logHist[i] ?? 0; + const h = (v / max) * (innerHeight - 2); + // Exact fractional coordinates for seamless coverage (no gaps as width grows) + const x0 = MARGIN_LEFT + (i / 256) * innerWidth; + const x1 = MARGIN_LEFT + ((i + 1) / 256) * innerWidth; + const w = x1 - x0; + if (w <= 0) { + continue; + } // safety + const y = MARGIN_TOP + innerHeight - h; + ctx.fillRect(x0, y, w, h); + } + } else { + // Aggregate bins per CSS pixel column (similar to previous anti-moire approach) + const columns = Math.max(1, Math.round(innerWidth)); + const binsPerCol = 256 / columns; + for (let col = 0; col < columns; col++) { + const startBin = Math.floor(col * binsPerCol); + const endBin = Math.min(255, Math.floor((col + 1) * binsPerCol - 1)); + let acc = 0; + let count = 0; + for (let b = startBin; b <= endBin; b++) { + acc += logHist[b] ?? 0; + count++; + } + const v = count > 0 ? acc / count : 0; + const h = (v / max) * (innerHeight - 2); + const x = MARGIN_LEFT + col; + const y = MARGIN_TOP + innerHeight - h; + ctx.fillRect(x, y, 1, h); + } } } @@ -229,6 +258,12 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { if (!c) { return; } + // Capture the pointer so we still get pointerup even if released outside the canvas. + try { + c.setPointerCapture(e.pointerId); + } catch { + /* ignore */ + } const rect = c.getBoundingClientRect(); const mx = e.clientX - rect.left; // CSS pixel coordinates const my = e.clientY - rect.top; @@ -275,12 +310,21 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const mxVal = canvasToValueX(mx); const myVal = canvasToValueY(my); setLocalPoints((prev) => { + // Endpoints are immutable; safety check. if (dragIndex === 0 || dragIndex === prev.length - 1) { return prev; - } // immutable endpoints + } + const leftX = prev[dragIndex - 1]![0]; + const rightX = prev[dragIndex + 1]![0]; + // Constrain to strictly between neighbors so ordering is preserved & no crossing. + const minX = Math.min(254, leftX); + const maxX = Math.max(1, rightX); + const clampedX = clamp(mxVal, minX, maxX); + // If neighbors are adjacent (minX > maxX after adjustments), effectively lock X. + const finalX = minX > maxX ? leftX + 1 - 1 /* keep existing */ : clampedX; const next = [...prev]; - next[dragIndex] = [mxVal, myVal]; - return sortPoints(next); + next[dragIndex] = [finalX, myVal]; + return next; // already ordered due to constraints }); }, [dragIndex] @@ -293,10 +337,39 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { [onChange] ); - const handlePointerUp = useCallback(() => { - setDragIndex(null); - commit(localPoints); - }, [commit, localPoints]); + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const c = canvasRef.current; + if (c) { + try { + c.releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + } + setDragIndex(null); + commit(localPoints); + }, + [commit, localPoints] + ); + + const handlePointerCancel = useCallback( + (e: React.PointerEvent) => { + const c = canvasRef.current; + if (c) { + try { + c.releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + } + setDragIndex(null); + commit(localPoints); + }, + [commit, localPoints] + ); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -345,6 +418,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} + onPointerCancel={handlePointerCancel} onDoubleClick={handleDoubleClick} style={canvasStyle} /> @@ -437,8 +511,8 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { const onChangeB = useCallback((pts: Array<[number, number]>) => onChangePoints('b', pts), [onChangePoints]); return ( - - + + Date: Sun, 7 Sep 2025 15:17:02 -0400 Subject: [PATCH 21/38] layout fixes --- .../RasterLayerCurvesAdjustmentsEditor.tsx | 2 +- .../RasterLayerSimpleAdjustmentsEditor.tsx | 16 +++++++--------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 231088dcadf..da54db47901 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -409,7 +409,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { return ( - + {title} ( - - - - {label} - - - + + + {label} + + ); @@ -68,7 +66,7 @@ export const RasterLayerSimpleAdjustmentsEditor = memo(() => { ); return ( - <> + { max={1} step={0.01} /> - + ); }); From fd4f46f7d9e56f68f68c04ad3469b7b70b320d0a Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 7 Sep 2025 15:33:24 -0400 Subject: [PATCH 22/38] remove extra edit comments --- .../RasterLayer/RasterLayerAdjustmentsPanel.tsx | 1 - .../RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx | 9 --------- 2 files changed, 10 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index 4e3f03e084f..b44ae4b1dbb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -27,7 +27,6 @@ export const RasterLayerAdjustmentsPanel = memo(() => { const enabled = Boolean(layer?.adjustments?.enabled); const collapsed = Boolean(layer?.adjustments?.collapsed); const mode = layer?.adjustments?.mode ?? 'simple'; - // simple adjustments handled by RasterLayerSimpleAdjustmentsEditor const onToggleEnabled = useCallback( (e: React.ChangeEvent) => { diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index da54db47901..4f6f58b9ecc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -36,15 +36,7 @@ const MARGIN_LEFT = 8; const MARGIN_RIGHT = 8; const MARGIN_TOP = 8; const MARGIN_BOTTOM = 10; -// Inner size is now computed dynamically per current canvas size (see draw()). -// NOTE: The helper conversion functions below were static. For responsive canvas resizing we now -// compute conversions dynamically based on the *current* canvas size. The static helpers remain -// only for fallback / reference to the base geometry. Dynamic versions are created in draw & event handlers. - -// (Removed unused static conversion helpers after refactor to fully dynamic sizing.) - -// Optional: stable canvas style from constants const CANVAS_STYLE: React.CSSProperties = { width: '100%', // Maintain aspect ratio while allowing responsive width. Height is set automatically via aspect-ratio. @@ -414,7 +406,6 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { Date: Sun, 7 Sep 2025 15:39:48 -0400 Subject: [PATCH 23/38] defaultValue on adjusters --- .../RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx index 9e071450b94..cd1dbd49cce 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx @@ -20,8 +20,8 @@ const AdjustmentSliderRow = ({ label, value, onChange, min = -1, max = 1, step = {label} - - + + ); From 0933916ff902f557b02a07cfeb4eccf7203841af Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 7 Sep 2025 15:48:19 -0400 Subject: [PATCH 24/38] allow negative sharpness to soften --- .../RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx index cd1dbd49cce..36a17b58fde 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx @@ -92,8 +92,8 @@ export const RasterLayerSimpleAdjustmentsEditor = memo(() => { label={t('controlLayers.adjustments.sharpness')} value={simple.sharpness} onChange={onSharpness} - min={0} - max={1} + min={-0.5} + max={0.5} step={0.01} /> From a54579492e808958be0f5c3d3a1303f333c6888a Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 7 Sep 2025 16:31:26 -0400 Subject: [PATCH 25/38] remove unknown type annotations --- .../konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts index cd8dee6d2f3..8723664d258 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasEntity/CanvasEntityAdapterRasterLayer.ts @@ -88,7 +88,7 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< const group = this.renderer.konva.objectGroup; if (apply) { const filters = group.filters() ?? []; - let nextFilters = filters.filter((f: unknown) => f !== AdjustmentsSimpleFilter && f !== AdjustmentsCurvesFilter); + let nextFilters = filters.filter((f) => f !== AdjustmentsSimpleFilter && f !== AdjustmentsCurvesFilter); if (a.mode === 'simple') { group.setAttr('adjustmentsSimple', a.simple); group.setAttr('adjustmentsCurves', null); @@ -108,7 +108,7 @@ export class CanvasEntityAdapterRasterLayer extends CanvasEntityAdapterBase< } else { // Remove our filter if present const filters = (group.filters() ?? []).filter( - (f: unknown) => f !== AdjustmentsSimpleFilter && f !== AdjustmentsCurvesFilter + (f) => f !== AdjustmentsSimpleFilter && f !== AdjustmentsCurvesFilter ); group.filters(filters); group.setAttr('adjustmentsSimple', null); From 3a603b3d873645cd82fb9bdecc7b03f011ce0e3b Mon Sep 17 00:00:00 2001 From: dunkeroni Date: Sun, 7 Sep 2025 17:30:47 -0400 Subject: [PATCH 26/38] minor padding changes --- .../components/RasterLayer/RasterLayerAdjustmentsPanel.tsx | 2 +- .../RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index b44ae4b1dbb..30e06e09430 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -125,7 +125,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { return ( <> - + ( - + {label} @@ -66,7 +66,7 @@ export const RasterLayerSimpleAdjustmentsEditor = memo(() => { ); return ( - + Date: Thu, 11 Sep 2025 14:22:43 +1000 Subject: [PATCH 27/38] feat(ui): tweak layouts, use react conventions, disabled state --- .../RasterLayerAdjustmentsPanel.tsx | 51 +++++++++---------- .../RasterLayerCurvesAdjustmentsEditor.tsx | 43 +++++++++++++--- .../RasterLayerSimpleAdjustmentsEditor.tsx | 40 ++++++++++----- 3 files changed, 86 insertions(+), 48 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index 30e06e09430..91ef385af2b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -1,4 +1,5 @@ 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'; @@ -10,9 +11,9 @@ import { rasterLayerAdjustmentsSet, rasterLayerAdjustmentsSimpleUpdated, } from 'features/controlLayers/store/canvasSlice'; -import { selectEntity } from 'features/controlLayers/store/selectors'; +import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import { makeDefaultRasterLayerAdjustments } from 'features/controlLayers/store/util'; -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold, PiCaretDownBold, PiCheckBold, PiTrashBold } from 'react-icons/pi'; @@ -20,18 +21,22 @@ 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 selectAdjustments = useMemo(() => { + return createSelector(selectCanvasSlice, (canvas) => selectEntity(canvas, entityIdentifier)?.adjustments); + }, [entityIdentifier]); + + const adjustments = useAppSelector(selectAdjustments); 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 hasAdjustments = Boolean(adjustments); + const enabled = Boolean(adjustments?.enabled); + const collapsed = Boolean(adjustments?.collapsed); + const mode = adjustments?.mode ?? 'simple'; const onToggleEnabled = useCallback( (e: React.ChangeEvent) => { const v = e.target.checked; - const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); + const current = adjustments ?? makeDefaultRasterLayerAdjustments(mode); dispatch( rasterLayerAdjustmentsSet({ entityIdentifier, @@ -39,7 +44,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { }) ); }, - [dispatch, entityIdentifier, layer?.adjustments, mode] + [dispatch, entityIdentifier, adjustments, mode] ); const onReset = useCallback(() => { @@ -73,21 +78,21 @@ export const RasterLayerAdjustmentsPanel = memo(() => { }, [dispatch, entityIdentifier]); const onToggleCollapsed = useCallback(() => { - const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(mode); + const current = adjustments ?? makeDefaultRasterLayerAdjustments(mode); dispatch( rasterLayerAdjustmentsSet({ entityIdentifier, adjustments: { ...current, collapsed: !collapsed }, }) ); - }, [dispatch, entityIdentifier, collapsed, layer?.adjustments, mode]); + }, [dispatch, entityIdentifier, collapsed, adjustments, mode]); const onSetMode = useCallback( (nextMode: 'simple' | 'curves') => { if (nextMode === mode) { return; } - const current = layer?.adjustments ?? makeDefaultRasterLayerAdjustments(nextMode); + const current = adjustments ?? makeDefaultRasterLayerAdjustments(nextMode); dispatch( rasterLayerAdjustmentsSet({ entityIdentifier, @@ -95,7 +100,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { }) ); }, - [dispatch, entityIdentifier, layer?.adjustments, mode] + [dispatch, entityIdentifier, adjustments, mode] ); // Memoized click handlers to avoid inline arrow functions in JSX @@ -125,7 +130,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { return ( <> - + { Adjustments - - @@ -161,7 +158,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { aria-label={t('controlLayers.adjustments.cancel')} size="md" onClick={onCancel} - isDisabled={!layer?.adjustments} + isDisabled={!adjustments} colorScheme="red" icon={} variant="ghost" @@ -170,7 +167,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { aria-label={t('controlLayers.adjustments.reset')} size="md" onClick={onReset} - isDisabled={!layer?.adjustments} + isDisabled={!adjustments} icon={} variant="ghost" /> @@ -178,7 +175,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { aria-label={t('controlLayers.adjustments.finish')} size="md" onClick={onFinish} - isDisabled={!layer?.adjustments} + isDisabled={!adjustments} colorScheme="green" icon={} variant="ghost" diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 4f6f58b9ecc..8c02987d307 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, Text } from '@invoke-ai/ui-library'; +import { Box, Flex, IconButton, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useEntityAdapterContext } from 'features/controlLayers/contexts/EntityAdapterContext'; @@ -7,6 +7,7 @@ import { rasterLayerAdjustmentsCurvesUpdated } from 'features/controlLayers/stor import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; const DEFAULT_POINTS: Array<[number, number]> = [ [0, 0], @@ -397,13 +398,25 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { return () => ro.disconnect(); }, [draw]); - const canvasStyle = useMemo(() => CANVAS_STYLE, []); + const resetPoints = useCallback(() => { + setLocalPoints(sortPoints(DEFAULT_POINTS)); + commit(DEFAULT_POINTS); + }, [commit]); return ( - - - {title} - + + + + {title} + + } + aria-label="Reset" + size="sm" + variant="link" + onClick={resetPoints} + /> + ); @@ -427,6 +440,13 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { [entityIdentifier] ); const layer = useAppSelector(selectLayer); + const selectIsDisabled = useMemo(() => { + return createSelector( + selectCanvasSlice, + (canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.enabled !== true + ); + }, [entityIdentifier]); + const isDisabled = useAppSelector(selectIsDisabled); const [histMaster, setHistMaster] = useState(null); const [histR, setHistR] = useState(null); @@ -502,7 +522,14 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { const onChangeB = useCallback((pts: Array<[number, number]>) => onChangePoints('b', pts), [onChangePoints]); return ( - + ); +const DEFAULT_SIMPLE_ADJUSTMENTS = { + brightness: 0, + contrast: 0, + saturation: 0, + temperature: 0, + tint: 0, + sharpness: 0, +}; + export const RasterLayerSimpleAdjustmentsEditor = memo(() => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); const { t } = useTranslation(); - const layer = useAppSelector((s) => selectEntity(s.canvas.present, entityIdentifier)); - - const simple = layer?.adjustments?.simple ?? { - brightness: 0, - contrast: 0, - saturation: 0, - temperature: 0, - tint: 0, - sharpness: 0, - }; + const selectSimpleAdjustments = useMemo(() => { + return createSelector( + selectCanvasSlice, + (canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.simple ?? DEFAULT_SIMPLE_ADJUSTMENTS + ); + }, [entityIdentifier]); + const simple = useAppSelector(selectSimpleAdjustments); + const selectIsDisabled = useMemo(() => { + return createSelector( + selectCanvasSlice, + (canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.enabled !== true + ); + }, [entityIdentifier]); + const isDisabled = useAppSelector(selectIsDisabled); const onBrightness = useCallback( (v: number) => dispatch(rasterLayerAdjustmentsSimpleUpdated({ entityIdentifier, simple: { brightness: v } })), @@ -66,7 +80,7 @@ export const RasterLayerSimpleAdjustmentsEditor = memo(() => { ); return ( - + Date: Thu, 11 Sep 2025 14:47:06 +1000 Subject: [PATCH 28/38] tidy(ui): move some histogram drawing logic out of components and into calblacks --- .../RasterLayerCurvesAdjustmentsEditor.tsx | 457 +++++++++--------- 1 file changed, 236 insertions(+), 221 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 8c02987d307..c1cb2810081 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -16,6 +16,8 @@ const DEFAULT_POINTS: Array<[number, number]> = [ type Channel = 'master' | 'r' | 'g' | 'b'; +type ChannelHistograms = Record; + const channelColor: Record = { master: '#888', r: '#e53e3e', @@ -57,6 +59,183 @@ type CurveGraphProps = { onChange: (pts: Array<[number, number]>) => void; }; +const drawHistogram = ( + c: HTMLCanvasElement, + channel: Channel, + histogram: number[] | null, + points: Array<[number, number]> +) => { + // Use device pixel ratio for crisp rendering on HiDPI displays. + const dpr = window.devicePixelRatio || 1; + const cssWidth = c.clientWidth || CANVAS_WIDTH; // CSS pixels + const cssHeight = (cssWidth * CANVAS_HEIGHT) / CANVAS_WIDTH; // maintain aspect ratio + + // Ensure the backing store matches current display size * dpr (only if changed). + const targetWidth = Math.round(cssWidth * dpr); + const targetHeight = Math.round(cssHeight * dpr); + if (c.width !== targetWidth || c.height !== targetHeight) { + c.width = targetWidth; + c.height = targetHeight; + } + // Guarantee the CSS height stays synced (width is 100%). + if (c.style.height !== `${cssHeight}px`) { + c.style.height = `${cssHeight}px`; + } + + const ctx = c.getContext('2d'); + if (!ctx) { + return; + } + + // Reset transform then scale for dpr so we can draw in CSS pixel coordinates. + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + + // Dynamic inner geometry (CSS pixel space) + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + + const valueToCanvasX = (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * innerWidth; + const valueToCanvasY = (y: number) => MARGIN_TOP + innerHeight - (clamp(y, 0, 255) / 255) * innerHeight; + + // Clear & background + ctx.clearRect(0, 0, cssWidth, cssHeight); + ctx.fillStyle = '#111'; + ctx.fillRect(0, 0, cssWidth, cssHeight); + + // Grid + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 1; + for (let i = 0; i <= 4; i++) { + const y = MARGIN_TOP + (i * innerHeight) / 4; + ctx.beginPath(); + ctx.moveTo(MARGIN_LEFT + 0.5, y + 0.5); + ctx.lineTo(MARGIN_LEFT + innerWidth - 0.5, y + 0.5); + ctx.stroke(); + } + for (let i = 0; i <= 4; i++) { + const x = MARGIN_LEFT + (i * innerWidth) / 4; + ctx.beginPath(); + ctx.moveTo(x + 0.5, MARGIN_TOP + 0.5); + ctx.lineTo(x + 0.5, MARGIN_TOP + innerHeight - 0.5); + ctx.stroke(); + } + + // Histogram + if (histogram) { + const logHist = histogram.map((v) => Math.log10((v ?? 0) + 1)); + const max = Math.max(1e-6, ...logHist); + ctx.fillStyle = '#5557'; + + // If there's enough horizontal room, draw each of the 256 bins with exact (possibly fractional) width so they tessellate. + // Otherwise, aggregate multiple bins into per-pixel columns to avoid aliasing. + if (innerWidth >= 256) { + for (let i = 0; i < 256; i++) { + const v = logHist[i] ?? 0; + const h = (v / max) * (innerHeight - 2); + // Exact fractional coordinates for seamless coverage (no gaps as width grows) + const x0 = MARGIN_LEFT + (i / 256) * innerWidth; + const x1 = MARGIN_LEFT + ((i + 1) / 256) * innerWidth; + const w = x1 - x0; + if (w <= 0) { + continue; + } // safety + const y = MARGIN_TOP + innerHeight - h; + ctx.fillRect(x0, y, w, h); + } + } else { + // Aggregate bins per CSS pixel column (similar to previous anti-moire approach) + const columns = Math.max(1, Math.round(innerWidth)); + const binsPerCol = 256 / columns; + for (let col = 0; col < columns; col++) { + const startBin = Math.floor(col * binsPerCol); + const endBin = Math.min(255, Math.floor((col + 1) * binsPerCol - 1)); + let acc = 0; + let count = 0; + for (let b = startBin; b <= endBin; b++) { + acc += logHist[b] ?? 0; + count++; + } + const v = count > 0 ? acc / count : 0; + const h = (v / max) * (innerHeight - 2); + const x = MARGIN_LEFT + col; + const y = MARGIN_TOP + innerHeight - h; + ctx.fillRect(x, y, 1, h); + } + } + } + + // Curve + const pts = sortPoints(points); + ctx.strokeStyle = channelColor[channel]; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const [x, y] = pts[i]!; + const cx = valueToCanvasX(x); + const cy = valueToCanvasY(y); + if (i === 0) { + ctx.moveTo(cx, cy); + } else { + ctx.lineTo(cx, cy); + } + } + ctx.stroke(); + + // Control points + for (let i = 0; i < pts.length; i++) { + const [x, y] = pts[i]!; + const cx = valueToCanvasX(x); + const cy = valueToCanvasY(y); + ctx.fillStyle = '#000'; + ctx.beginPath(); + ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = channelColor[channel]; + ctx.lineWidth = 1.5; + ctx.stroke(); + } +}; + +const getNearestPointIndex = (c: HTMLCanvasElement, points: Array<[number, number]>, mx: number, my: number) => { + const cssWidth = c.clientWidth || CANVAS_WIDTH; + const cssHeight = c.clientHeight || CANVAS_HEIGHT; + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); + const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); + const xVal = canvasToValueX(mx); + const yVal = canvasToValueY(my); + let best = -1; + let bestDist = 9999; + for (let i = 0; i < points.length; i++) { + const [px, py] = points[i]!; + const dx = px - xVal; + const dy = py - yVal; + const d = dx * dx + dy * dy; + if (d < bestDist) { + best = i; + bestDist = d; + } + } + if (best !== -1 && bestDist <= 20 * 20) { + return best; + } + return -1; +}; + +const canvasXToValueX = (c: HTMLCanvasElement, cx: number): number => { + const cssWidth = c.clientWidth || CANVAS_WIDTH; + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + return clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); +}; + +const canvasYToValueY = (c: HTMLCanvasElement, cy: number) => { + const cssHeight = c.clientHeight || CANVAS_HEIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + return clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); +}; + const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const { title, channel, points, histogram, onChange } = props; const canvasRef = useRef(null); @@ -67,182 +246,14 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { setLocalPoints(sortPoints(points ?? DEFAULT_POINTS)); }, [points]); - const draw = useCallback(() => { + useEffect(() => { const c = canvasRef.current; if (!c) { return; } - - // Use device pixel ratio for crisp rendering on HiDPI displays. - const dpr = window.devicePixelRatio || 1; - const cssWidth = c.clientWidth || CANVAS_WIDTH; // CSS pixels - const cssHeight = (cssWidth * CANVAS_HEIGHT) / CANVAS_WIDTH; // maintain aspect ratio - - // Ensure the backing store matches current display size * dpr (only if changed). - const targetWidth = Math.round(cssWidth * dpr); - const targetHeight = Math.round(cssHeight * dpr); - if (c.width !== targetWidth || c.height !== targetHeight) { - c.width = targetWidth; - c.height = targetHeight; - } - // Guarantee the CSS height stays synced (width is 100%). - if (c.style.height !== `${cssHeight}px`) { - c.style.height = `${cssHeight}px`; - } - - const ctx = c.getContext('2d'); - if (!ctx) { - return; - } - - // Reset transform then scale for dpr so we can draw in CSS pixel coordinates. - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.scale(dpr, dpr); - - // Dynamic inner geometry (CSS pixel space) - const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; - const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; - - const valueToCanvasX = (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * innerWidth; - const valueToCanvasY = (y: number) => MARGIN_TOP + innerHeight - (clamp(y, 0, 255) / 255) * innerHeight; - - // Clear & background - ctx.clearRect(0, 0, cssWidth, cssHeight); - ctx.fillStyle = '#111'; - ctx.fillRect(0, 0, cssWidth, cssHeight); - - // Grid - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 1; - for (let i = 0; i <= 4; i++) { - const y = MARGIN_TOP + (i * innerHeight) / 4; - ctx.beginPath(); - ctx.moveTo(MARGIN_LEFT + 0.5, y + 0.5); - ctx.lineTo(MARGIN_LEFT + innerWidth - 0.5, y + 0.5); - ctx.stroke(); - } - for (let i = 0; i <= 4; i++) { - const x = MARGIN_LEFT + (i * innerWidth) / 4; - ctx.beginPath(); - ctx.moveTo(x + 0.5, MARGIN_TOP + 0.5); - ctx.lineTo(x + 0.5, MARGIN_TOP + innerHeight - 0.5); - ctx.stroke(); - } - - // Histogram - if (histogram) { - const logHist = histogram.map((v) => Math.log10((v ?? 0) + 1)); - const max = Math.max(1e-6, ...logHist); - ctx.fillStyle = '#5557'; - - // If there's enough horizontal room, draw each of the 256 bins with exact (possibly fractional) width so they tessellate. - // Otherwise, aggregate multiple bins into per-pixel columns to avoid aliasing. - if (innerWidth >= 256) { - for (let i = 0; i < 256; i++) { - const v = logHist[i] ?? 0; - const h = (v / max) * (innerHeight - 2); - // Exact fractional coordinates for seamless coverage (no gaps as width grows) - const x0 = MARGIN_LEFT + (i / 256) * innerWidth; - const x1 = MARGIN_LEFT + ((i + 1) / 256) * innerWidth; - const w = x1 - x0; - if (w <= 0) { - continue; - } // safety - const y = MARGIN_TOP + innerHeight - h; - ctx.fillRect(x0, y, w, h); - } - } else { - // Aggregate bins per CSS pixel column (similar to previous anti-moire approach) - const columns = Math.max(1, Math.round(innerWidth)); - const binsPerCol = 256 / columns; - for (let col = 0; col < columns; col++) { - const startBin = Math.floor(col * binsPerCol); - const endBin = Math.min(255, Math.floor((col + 1) * binsPerCol - 1)); - let acc = 0; - let count = 0; - for (let b = startBin; b <= endBin; b++) { - acc += logHist[b] ?? 0; - count++; - } - const v = count > 0 ? acc / count : 0; - const h = (v / max) * (innerHeight - 2); - const x = MARGIN_LEFT + col; - const y = MARGIN_TOP + innerHeight - h; - ctx.fillRect(x, y, 1, h); - } - } - } - - // Curve - const pts = sortPoints(localPoints); - ctx.strokeStyle = channelColor[channel]; - ctx.lineWidth = 2; - ctx.beginPath(); - for (let i = 0; i < pts.length; i++) { - const [x, y] = pts[i]!; - const cx = valueToCanvasX(x); - const cy = valueToCanvasY(y); - if (i === 0) { - ctx.moveTo(cx, cy); - } else { - ctx.lineTo(cx, cy); - } - } - ctx.stroke(); - - // Control points - for (let i = 0; i < pts.length; i++) { - const [x, y] = pts[i]!; - const cx = valueToCanvasX(x); - const cy = valueToCanvasY(y); - ctx.fillStyle = '#000'; - ctx.beginPath(); - ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = channelColor[channel]; - ctx.lineWidth = 1.5; - ctx.stroke(); - } + drawHistogram(c, channel, histogram, localPoints); }, [channel, histogram, localPoints]); - useEffect(() => { - draw(); - }, [draw]); - - const getNearestPointIndex = useCallback( - (mx: number, my: number) => { - const c = canvasRef.current; - if (!c) { - return -1; - } - const cssWidth = c.clientWidth || CANVAS_WIDTH; - const cssHeight = c.clientHeight || CANVAS_HEIGHT; - const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; - const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; - const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); - const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); - const xVal = canvasToValueX(mx); - const yVal = canvasToValueY(my); - let best = -1; - let bestDist = 9999; - for (let i = 0; i < localPoints.length; i++) { - const [px, py] = localPoints[i]!; - const dx = px - xVal; - const dy = py - yVal; - const d = dx * dx + dy * dy; - if (d < bestDist) { - best = i; - bestDist = d; - } - } - if (best !== -1 && bestDist <= 20 * 20) { - return best; - } - return -1; - }, - [localPoints] - ); - const handlePointerDown = useCallback( (e: React.PointerEvent) => { e.preventDefault(); @@ -260,24 +271,18 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const rect = c.getBoundingClientRect(); const mx = e.clientX - rect.left; // CSS pixel coordinates const my = e.clientY - rect.top; - const cssWidth = c.clientWidth || CANVAS_WIDTH; - const cssHeight = c.clientHeight || CANVAS_HEIGHT; - const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; - const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; - const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); - const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); - const idx = getNearestPointIndex(mx, my); + const idx = getNearestPointIndex(c, localPoints, mx, my); if (idx !== -1 && idx !== 0 && idx !== localPoints.length - 1) { setDragIndex(idx); return; } - const xVal = canvasToValueX(mx); - const yVal = canvasToValueY(my); + const xVal = canvasXToValueX(c, mx); + const yVal = canvasYToValueY(c, my); const next = sortPoints([...localPoints, [xVal, yVal]]); setLocalPoints(next); setDragIndex(next.findIndex(([x, y]) => x === xVal && y === yVal)); }, - [getNearestPointIndex, localPoints] + [localPoints] ); const handlePointerMove = useCallback( @@ -294,14 +299,8 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const rect = c.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; - const cssWidth = c.clientWidth || CANVAS_WIDTH; - const cssHeight = c.clientHeight || CANVAS_HEIGHT; - const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; - const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; - const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); - const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); - const mxVal = canvasToValueX(mx); - const myVal = canvasToValueY(my); + const mxVal = canvasXToValueX(c, mx); + const myVal = canvasYToValueY(c, my); setLocalPoints((prev) => { // Endpoints are immutable; safety check. if (dragIndex === 0 || dragIndex === prev.length - 1) { @@ -375,14 +374,14 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const rect = c.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; - const idx = getNearestPointIndex(mx, my); + const idx = getNearestPointIndex(c, localPoints, mx, my); if (idx > 0 && idx < localPoints.length - 1) { const next = localPoints.filter((_, i) => i !== idx); setLocalPoints(next); commit(next); } }, - [commit, getNearestPointIndex, localPoints] + [commit, localPoints] ); // Observe size changes to redraw (responsive behavior) @@ -392,11 +391,11 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { return; } const ro = new ResizeObserver(() => { - draw(); + drawHistogram(c, channel, histogram, localPoints); }); ro.observe(c); return () => ro.disconnect(); - }, [draw]); + }, [channel, histogram, localPoints]); const resetPoints = useCallback(() => { setLocalPoints(sortPoints(DEFAULT_POINTS)); @@ -430,6 +429,45 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { ); }); +const calculateHistogramsFromImageData = (imageData: ImageData): ChannelHistograms | null => { + try { + const data = imageData.data; + const len = data.length / 4; + const master = new Array(256).fill(0); + const r = new Array(256).fill(0); + const g = new Array(256).fill(0); + const b = new Array(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'>(); @@ -469,36 +507,13 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { return; } const imageData = adapter.renderer.getImageData({ rect }); - const data = imageData.data; - const len = data.length / 4; - const master = new Array(256).fill(0); - const r = new Array(256).fill(0); - const g = new Array(256).fill(0); - const b = new Array(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; - } + const h = calculateHistogramsFromImageData(imageData); + if (h) { + setHistMaster(h.master); + setHistR(h.r); + setHistG(h.g); + setHistB(h.b); } - setHistMaster(master); - setHistR(r); - setHistG(g); - setHistB(b); } catch { // ignore } From be18c0306c36a3513637d5e323e88f33243a0670 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 14:55:58 +1000 Subject: [PATCH 29/38] feat(ui): single action to reset adjustments --- .../RasterLayerAdjustmentsPanel.tsx | 25 ++----------------- .../controlLayers/store/canvasSlice.ts | 12 +++++++++ 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index 91ef385af2b..6d195fb9c29 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -7,9 +7,8 @@ import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerP import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerAdjustmentsCancel, - rasterLayerAdjustmentsCurvesUpdated, + rasterLayerAdjustmentsReset, rasterLayerAdjustmentsSet, - rasterLayerAdjustmentsSimpleUpdated, } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import { makeDefaultRasterLayerAdjustments } from 'features/controlLayers/store/util'; @@ -49,27 +48,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { 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(rasterLayerAdjustmentsReset({ entityIdentifier })); }, [dispatch, entityIdentifier]); const onCancel = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 73829b7a02a..1be452043d6 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -131,6 +131,17 @@ const slice = createSlice({ } layer.adjustments = merge(layer.adjustments, adjustments); }, + rasterLayerAdjustmentsReset: (state, action: PayloadAction>) => { + const { entityIdentifier } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer) { + return; + } + if (layer.adjustments) { + layer.adjustments.simple = makeDefaultRasterLayerAdjustments('simple').simple; + layer.adjustments.curves = makeDefaultRasterLayerAdjustments('curves').curves; + } + }, rasterLayerAdjustmentsCancel: (state, action: PayloadAction>) => { const { entityIdentifier } = action.payload; const layer = selectEntity(state, entityIdentifier); @@ -1739,6 +1750,7 @@ export const { // Raster layer adjustments rasterLayerAdjustmentsSet, rasterLayerAdjustmentsCancel, + rasterLayerAdjustmentsReset, rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, entityDeleted, From d939a6135848769b4a996ed5be491cc39efa7a57 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:02:03 +1000 Subject: [PATCH 30/38] feat(ui): tweak adjustments panel styling --- .../components/RasterLayer/RasterLayerAdjustmentsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index 6d195fb9c29..c3665abd367 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -109,7 +109,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { return ( <> - + Date: Thu, 11 Sep 2025 15:16:20 +1000 Subject: [PATCH 31/38] fix(ui): points where x=255 sorted incorrectly --- .../RasterLayerCurvesAdjustmentsEditor.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index c1cb2810081..491bdc3a2d4 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -29,8 +29,18 @@ const clamp = (v: number, min: number, max: number) => (v < min ? min : v > max const sortPoints = (pts: Array<[number, number]>) => [...pts] - .sort((a, b) => a[0] - b[0]) - .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] as [number, number]); + .sort((a, b) => { + const xDiff = a[0] - b[0]; + if (xDiff) { + return xDiff; + } + if (a[0] === 0 || a[0] === 255) { + return a[1] - b[1]; + } + return 0; + }) + // Finally, clamp to valid range and round to integers + .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] satisfies [number, number]); // Base canvas logical coordinate system (used for aspect ratio & initial sizing) const CANVAS_WIDTH = 256; From 72c2a7dec7368afd905eea87d9d0b616cd34df09 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:29:56 +1000 Subject: [PATCH 32/38] refactor(ui): make layer adjustments schemas/types composable --- .../RasterLayerCurvesAdjustmentsEditor.tsx | 37 +++++++------ .../controlLayers/store/canvasSlice.ts | 33 +++--------- .../src/features/controlLayers/store/types.ts | 53 ++++++++++--------- .../src/features/controlLayers/store/util.ts | 3 +- 4 files changed, 55 insertions(+), 71 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 491bdc3a2d4..7b2b905783e 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -5,20 +5,19 @@ import { useEntityAdapterContext } from 'features/controlLayers/contexts/EntityA 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 } from 'features/controlLayers/store/types'; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; -const DEFAULT_POINTS: Array<[number, number]> = [ +const DEFAULT_POINTS: ChannelPoints = [ [0, 0], [255, 255], ]; -type Channel = 'master' | 'r' | 'g' | 'b'; +type ChannelHistograms = Record; -type ChannelHistograms = Record; - -const channelColor: Record = { +const channelColor: Record = { master: '#888', r: '#e53e3e', g: '#38a169', @@ -27,7 +26,7 @@ const channelColor: Record = { const clamp = (v: number, min: number, max: number) => (v < min ? min : v > max ? max : v); -const sortPoints = (pts: Array<[number, number]>) => +const sortPoints = (pts: ChannelPoints) => [...pts] .sort((a, b) => { const xDiff = a[0] - b[0]; @@ -63,17 +62,17 @@ const CANVAS_STYLE: React.CSSProperties = { type CurveGraphProps = { title: string; - channel: Channel; - points: Array<[number, number]> | undefined; + channel: ChannelName; + points: ChannelPoints | undefined; histogram: number[] | null; - onChange: (pts: Array<[number, number]>) => void; + onChange: (pts: ChannelPoints) => void; }; const drawHistogram = ( c: HTMLCanvasElement, - channel: Channel, + channel: ChannelName, histogram: number[] | null, - points: Array<[number, number]> + points: ChannelPoints ) => { // Use device pixel ratio for crisp rendering on HiDPI displays. const dpr = window.devicePixelRatio || 1; @@ -207,7 +206,7 @@ const drawHistogram = ( } }; -const getNearestPointIndex = (c: HTMLCanvasElement, points: Array<[number, number]>, mx: number, my: number) => { +const getNearestPointIndex = (c: HTMLCanvasElement, points: ChannelPoints, mx: number, my: number) => { const cssWidth = c.clientWidth || CANVAS_WIDTH; const cssHeight = c.clientHeight || CANVAS_HEIGHT; const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; @@ -249,7 +248,7 @@ const canvasYToValueY = (c: HTMLCanvasElement, cy: number) => { const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { const { title, channel, points, histogram, onChange } = props; const canvasRef = useRef(null); - const [localPoints, setLocalPoints] = useState>(sortPoints(points ?? DEFAULT_POINTS)); + const [localPoints, setLocalPoints] = useState(sortPoints(points ?? DEFAULT_POINTS)); const [dragIndex, setDragIndex] = useState(null); useEffect(() => { @@ -333,7 +332,7 @@ const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { ); const commit = useCallback( - (pts: Array<[number, number]>) => { + (pts: ChannelPoints) => { onChange(sortPoints(pts)); }, [onChange] @@ -534,17 +533,17 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { }, [layer?.objects, layer?.adjustments, recalcHistogram]); const onChangePoints = useCallback( - (channel: Channel, pts: Array<[number, number]>) => { + (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: Array<[number, number]>) => onChangePoints('master', pts), [onChangePoints]); - const onChangeR = useCallback((pts: Array<[number, number]>) => onChangePoints('r', pts), [onChangePoints]); - const onChangeG = useCallback((pts: Array<[number, number]>) => onChangePoints('g', pts), [onChangePoints]); - const onChangeB = useCallback((pts: Array<[number, number]>) => onChangePoints('b', pts), [onChangePoints]); + 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 ( - > + action: PayloadAction> ) => { const { entityIdentifier, adjustments } = action.payload; const layer = selectEntity(state, entityIdentifier); @@ -152,14 +148,7 @@ const slice = createSlice({ }, rasterLayerAdjustmentsSimpleUpdated: ( state, - action: PayloadAction< - EntityIdentifierPayload< - { - simple: Partial; - }, - 'raster_layer' - > - > + action: PayloadAction }, 'raster_layer'>> ) => { const { entityIdentifier, simple } = action.payload; const layer = selectEntity(state, entityIdentifier); @@ -173,15 +162,7 @@ const slice = createSlice({ }, rasterLayerAdjustmentsCurvesUpdated: ( state, - action: PayloadAction< - EntityIdentifierPayload< - { - channel: 'master' | 'r' | 'g' | 'b'; - points: Array<[number, number]>; - }, - 'raster_layer' - > - > + action: PayloadAction> ) => { const { entityIdentifier, channel, points } = action.payload; const layer = selectEntity(state, entityIdentifier); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 6dcc29a1e09..3774083ca42 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -378,36 +378,41 @@ const zControlLoRAConfig = z.object({ }); export type ControlLoRAConfig = z.infer; +const zSimpleConfig = z.object({ + // All simple params normalized to [-1, 1] except sharpness [0, 1] + brightness: z.number().gte(-1).lte(1), + contrast: z.number().gte(-1).lte(1), + saturation: z.number().gte(-1).lte(1), + temperature: z.number().gte(-1).lte(1), + tint: z.number().gte(-1).lte(1), + sharpness: z.number().gte(0).lte(1), +}); +export type SimpleConfig = z.infer; + +const zUint8 = z.number().int().min(0).max(255); +const zChannelPoints = z.array(z.tuple([zUint8, zUint8])).min(2); +const zChannelName = z.enum(['master', 'r', 'g', 'b']); +const zCurvesConfig = z.record(zChannelName, zChannelPoints); +export type ChannelName = z.infer; +export type ChannelPoints = z.infer; + +const zRasterLayerAdjustments = z.object({ + version: z.literal(1), + enabled: z.boolean(), + collapsed: z.boolean(), + mode: z.enum(['simple', 'curves']), + simple: zSimpleConfig, + curves: zCurvesConfig, +}); +export type RasterLayerAdjustments = z.infer; + const zCanvasRasterLayerState = zCanvasEntityBase.extend({ type: z.literal('raster_layer'), position: zCoordinate, opacity: zOpacity, objects: z.array(zCanvasObjectState), // Optional per-layer color adjustments (simple + curves). When undefined, no adjustments are applied. - adjustments: z - .object({ - version: z.literal(1), - enabled: z.boolean(), - collapsed: z.boolean(), - mode: z.enum(['simple', 'curves']), - simple: z.object({ - // All simple params normalized to [-1, 1] except sharpness [0, 1] - brightness: z.number().gte(-1).lte(1), - contrast: z.number().gte(-1).lte(1), - saturation: z.number().gte(-1).lte(1), - temperature: z.number().gte(-1).lte(1), - tint: z.number().gte(-1).lte(1), - sharpness: z.number().gte(0).lte(1), - }), - curves: z.object({ - // Curves are arrays of [x, y] control points in 0..255 space (no strict monotonic checks here) - master: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), - r: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), - g: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), - b: z.array(z.tuple([z.number().int().gte(0).lte(255), z.number().int().gte(0).lte(255)])).min(2), - }), - }) - .optional(), + adjustments: zRasterLayerAdjustments.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 9d72c8a9b69..cb6e816e320 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/util.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/util.ts @@ -15,6 +15,7 @@ import type { Gemini2_5ReferenceImageConfig, ImageWithDims, IPAdapterConfig, + RasterLayerAdjustments, RefImageState, RgbColor, T2IAdapterConfig, @@ -118,8 +119,6 @@ export const initialControlLoRA: ControlLoRAConfig = { weight: 0.75, }; -export type RasterLayerAdjustments = NonNullable; - export const makeDefaultRasterLayerAdjustments = (mode: 'simple' | 'curves' = 'simple'): RasterLayerAdjustments => ({ version: 1, enabled: true, From d7426b6483031361c7cc27116864f6ae0b3eb237 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:41:33 +1000 Subject: [PATCH 33/38] feat(ui): better types & runtime guarantees for filter data stored in konva node attrs --- .../features/controlLayers/konva/filters.ts | 36 ++++++++----------- .../controlLayers/store/canvasSlice.ts | 4 +-- .../src/features/controlLayers/store/types.ts | 27 ++++++++++---- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts index a85570d1f74..044e6c52b18 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/filters.ts @@ -4,6 +4,7 @@ */ import { clamp } from 'es-toolkit/compat'; +import { zCurvesAdjustmentsLUTs, zSimpleAdjustmentsConfig } from 'features/controlLayers/store/types'; import type Konva from 'konva'; /** @@ -24,25 +25,18 @@ export const LightnessToAlphaFilter = (imageData: ImageData): void => { } }; -type SimpleAdjustParams = { - brightness: number; // -1..1 (additive) - contrast: number; // -1..1 (scale around 128) - saturation: number; // -1..1 - temperature: number; // -1..1 (blue<->yellow approx) - tint: number; // -1..1 (green<->magenta approx) - sharpness: number; // -1..1 (light unsharp mask) -}; - /** * Per-layer simple adjustments filter (brightness, contrast, saturation, temp, tint, sharpness). * * Parameters are read from the Konva node attr `adjustmentsSimple` set by the adapter. */ export const AdjustmentsSimpleFilter = function (this: Konva.Node, imageData: ImageData): void { - const params = (this?.getAttr?.('adjustmentsSimple') as SimpleAdjustParams | undefined) ?? null; - if (!params) { + const paramsRaw = this.getAttr('adjustmentsSimple'); + const parseResult = zSimpleAdjustmentsConfig.safeParse(paramsRaw); + if (!parseResult.success) { return; } + const params = parseResult.data; const { brightness, contrast, saturation, temperature, tint, sharpness } = params; @@ -172,19 +166,19 @@ export const buildCurveLUT = (points: Array<[number, number]>): number[] => { return lut; }; -type CurvesAdjustParams = { - master: number[]; - r: number[]; - g: number[]; - b: number[]; -}; - -// Curves filter: apply master curve, then per-channel curves +/** + * Per-layer curves adjustments filter (master, r, g, b) + * + * Parameters are read from the Konva node attr `adjustmentsCurves` set by the adapter. + */ export const AdjustmentsCurvesFilter = function (this: Konva.Node, imageData: ImageData): void { - const params = (this?.getAttr?.('adjustmentsCurves') as CurvesAdjustParams | undefined) ?? null; - if (!params) { + const paramsRaw = this.getAttr('adjustmentsCurves'); + const parseResult = zCurvesAdjustmentsLUTs.safeParse(paramsRaw); + if (!parseResult.success) { return; } + const params = parseResult.data; + const { master, r, g, b } = params; if (!master || !r || !g || !b) { return; diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 9b30a48677e..933626a3bd3 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -28,7 +28,7 @@ import type { RasterLayerAdjustments, RegionalGuidanceRefImageState, RgbColor, - SimpleConfig, + SimpleAdjustmentsConfig, } from 'features/controlLayers/store/types'; import { calculateNewSize, @@ -148,7 +148,7 @@ const slice = createSlice({ }, rasterLayerAdjustmentsSimpleUpdated: ( state, - action: PayloadAction }, 'raster_layer'>> + action: PayloadAction }, 'raster_layer'>> ) => { const { entityIdentifier, simple } = action.payload; const layer = selectEntity(state, entityIdentifier); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 3774083ca42..b0d9869ffcb 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -378,8 +378,17 @@ const zControlLoRAConfig = z.object({ }); export type ControlLoRAConfig = z.infer; -const zSimpleConfig = z.object({ - // All simple params normalized to [-1, 1] except sharpness [0, 1] +/** + * All simple params normalized to `[-1, 1]` except sharpness `[0, 1]`. + * + * - Brightness: -1 (darken) to 1 (brighten) + * - Contrast: -1 (decrease contrast) to 1 (increase contrast) + * - Saturation: -1 (desaturate) to 1 (saturate) + * - Temperature: -1 (cooler/blue) to 1 (warmer/yellow) + * - Tint: -1 (greener) to 1 (more magenta) + * - Sharpness: 0 (no sharpening) to 1 (maximum sharpening) + */ +export const zSimpleAdjustmentsConfig = z.object({ brightness: z.number().gte(-1).lte(1), contrast: z.number().gte(-1).lte(1), saturation: z.number().gte(-1).lte(1), @@ -387,22 +396,28 @@ const zSimpleConfig = z.object({ tint: z.number().gte(-1).lte(1), sharpness: z.number().gte(0).lte(1), }); -export type SimpleConfig = z.infer; +export type SimpleAdjustmentsConfig = z.infer; const zUint8 = z.number().int().min(0).max(255); const zChannelPoints = z.array(z.tuple([zUint8, zUint8])).min(2); const zChannelName = z.enum(['master', 'r', 'g', 'b']); -const zCurvesConfig = z.record(zChannelName, zChannelPoints); +const zCurvesAdjustmentsConfig = z.record(zChannelName, zChannelPoints); export type ChannelName = z.infer; export type ChannelPoints = z.infer; +/** + * The curves adjustments are stored as LUTs in the Konva node attributes. Konva will use these values when applying + * the filter. + */ +export const zCurvesAdjustmentsLUTs = z.record(zChannelName, z.array(zUint8)); + const zRasterLayerAdjustments = z.object({ version: z.literal(1), enabled: z.boolean(), collapsed: z.boolean(), mode: z.enum(['simple', 'curves']), - simple: zSimpleConfig, - curves: zCurvesConfig, + simple: zSimpleAdjustmentsConfig, + curves: zCurvesAdjustmentsConfig, }); export type RasterLayerAdjustments = z.infer; From e8eb976a4c3c18cbe13d1e9d249448c70a32893c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:59:06 +1000 Subject: [PATCH 34/38] fix(ui): sharpness range --- .../RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx index e3c16c72dbd..d1e0ffbd2e9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx @@ -106,9 +106,8 @@ export const RasterLayerSimpleAdjustmentsEditor = memo(() => { label={t('controlLayers.adjustments.sharpness')} value={simple.sharpness} onChange={onSharpness} - min={-0.5} - max={0.5} - step={0.01} + min={0} + max={1} /> ); From 4e2b76a9637f021c10a6900b2097a6b54ce22c5b Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:33:30 +1000 Subject: [PATCH 35/38] perf(ui): use narrow selectors in adjustments to reduce rerenders dramatically improves the feel of the sliders --- .../RasterLayerAdjustmentsPanel.tsx | 100 +++++++++--------- .../RasterLayerSimpleAdjustmentsEditor.tsx | 56 +++++----- .../controlLayers/store/canvasSlice.ts | 54 +++++++--- 3 files changed, 118 insertions(+), 92 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx index c3665abd367..a0e9733ea99 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerAdjustmentsPanel.tsx @@ -7,44 +7,56 @@ import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerP 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 { makeDefaultRasterLayerAdjustments } from 'features/controlLayers/store/util'; 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 selectAdjustments = useMemo(() => { - return createSelector(selectCanvasSlice, (canvas) => selectEntity(canvas, entityIdentifier)?.adjustments); + + const selectHasAdjustments = useMemo(() => { + return createSelector(selectCanvasSlice, (canvas) => Boolean(selectEntity(canvas, entityIdentifier)?.adjustments)); }, [entityIdentifier]); - const adjustments = useAppSelector(selectAdjustments); - const { t } = useTranslation(); + const hasAdjustments = useAppSelector(selectHasAdjustments); - const hasAdjustments = Boolean(adjustments); - const enabled = Boolean(adjustments?.enabled); - const collapsed = Boolean(adjustments?.collapsed); - const mode = adjustments?.mode ?? 'simple'; - - const onToggleEnabled = useCallback( - (e: React.ChangeEvent) => { - const v = e.target.checked; - const current = adjustments ?? makeDefaultRasterLayerAdjustments(mode); - dispatch( - rasterLayerAdjustmentsSet({ - entityIdentifier, - adjustments: { ...current, enabled: v }, - }) - ); - }, - [dispatch, entityIdentifier, adjustments, mode] - ); + 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 @@ -57,34 +69,18 @@ export const RasterLayerAdjustmentsPanel = memo(() => { }, [dispatch, entityIdentifier]); const onToggleCollapsed = useCallback(() => { - const current = adjustments ?? makeDefaultRasterLayerAdjustments(mode); - dispatch( - rasterLayerAdjustmentsSet({ - entityIdentifier, - adjustments: { ...current, collapsed: !collapsed }, - }) - ); - }, [dispatch, entityIdentifier, collapsed, adjustments, mode]); - - const onSetMode = useCallback( - (nextMode: 'simple' | 'curves') => { - if (nextMode === mode) { - return; - } - const current = adjustments ?? makeDefaultRasterLayerAdjustments(nextMode); - dispatch( - rasterLayerAdjustmentsSet({ - entityIdentifier, - adjustments: { ...current, mode: nextMode }, - }) - ); - }, - [dispatch, entityIdentifier, adjustments, mode] + dispatch(rasterLayerAdjustmentsCollapsedToggled({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + const onClickModeSimple = useCallback( + () => dispatch(rasterLayerAdjustmentsModeChanged({ entityIdentifier, mode: 'simple' })), + [dispatch, entityIdentifier] ); - // Memoized click handlers to avoid inline arrow functions in JSX - const onClickModeSimple = useCallback(() => onSetMode('simple'), [onSetMode]); - const onClickModeCurves = useCallback(() => onSetMode('curves'), [onSetMode]); + const onClickModeCurves = useCallback( + () => dispatch(rasterLayerAdjustmentsModeChanged({ entityIdentifier, mode: 'curves' })), + [dispatch, entityIdentifier] + ); const onFinish = useCallback(async () => { // Bake current visual into layer pixels, then clear adjustments @@ -137,7 +133,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { aria-label={t('controlLayers.adjustments.cancel')} size="md" onClick={onCancel} - isDisabled={!adjustments} + isDisabled={!hasAdjustments} colorScheme="red" icon={} variant="ghost" @@ -146,7 +142,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { aria-label={t('controlLayers.adjustments.reset')} size="md" onClick={onReset} - isDisabled={!adjustments} + isDisabled={!hasAdjustments} icon={} variant="ghost" /> @@ -154,7 +150,7 @@ export const RasterLayerAdjustmentsPanel = memo(() => { aria-label={t('controlLayers.adjustments.finish')} size="md" onClick={onFinish} - isDisabled={!adjustments} + isDisabled={!hasAdjustments} colorScheme="green" icon={} variant="ghost" diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx index d1e0ffbd2e9..42c45e1c36d 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerSimpleAdjustmentsEditor.tsx @@ -4,27 +4,40 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; import { rasterLayerAdjustmentsSimpleUpdated } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; +import type { SimpleAdjustmentsConfig } from 'features/controlLayers/store/types'; import React, { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; type AdjustmentSliderRowProps = { label: string; - value: number; + name: keyof SimpleAdjustmentsConfig; onChange: (v: number) => void; min?: number; max?: number; step?: number; }; -const AdjustmentSliderRow = ({ label, value, onChange, min = -1, max = 1, step = 0.01 }: AdjustmentSliderRowProps) => ( - - - {label} - - - - -); +const AdjustmentSliderRow = ({ label, name, onChange, min = -1, max = 1, step = 0.01 }: AdjustmentSliderRowProps) => { + const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); + const selectValue = useMemo(() => { + return createSelector( + selectCanvasSlice, + (canvas) => + selectEntity(canvas, entityIdentifier)?.adjustments?.simple?.[name] ?? DEFAULT_SIMPLE_ADJUSTMENTS[name] + ); + }, [entityIdentifier, name]); + const value = useAppSelector(selectValue); + + return ( + + + {label} + + + + + ); +}; const DEFAULT_SIMPLE_ADJUSTMENTS = { brightness: 0, @@ -39,13 +52,6 @@ export const RasterLayerSimpleAdjustmentsEditor = memo(() => { const dispatch = useAppDispatch(); const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); const { t } = useTranslation(); - const selectSimpleAdjustments = useMemo(() => { - return createSelector( - selectCanvasSlice, - (canvas) => selectEntity(canvas, entityIdentifier)?.adjustments?.simple ?? DEFAULT_SIMPLE_ADJUSTMENTS - ); - }, [entityIdentifier]); - const simple = useAppSelector(selectSimpleAdjustments); const selectIsDisabled = useMemo(() => { return createSelector( selectCanvasSlice, @@ -83,28 +89,24 @@ export const RasterLayerSimpleAdjustmentsEditor = memo(() => { - + - + >) => { const { entityIdentifier } = action.payload; const layer = selectEntity(state, entityIdentifier); - if (!layer) { + if (!layer?.adjustments) { return; } - if (layer.adjustments) { - layer.adjustments.simple = makeDefaultRasterLayerAdjustments('simple').simple; - layer.adjustments.curves = makeDefaultRasterLayerAdjustments('curves').curves; - } + layer.adjustments.simple = makeDefaultRasterLayerAdjustments('simple').simple; + layer.adjustments.curves = makeDefaultRasterLayerAdjustments('curves').curves; }, rasterLayerAdjustmentsCancel: (state, action: PayloadAction>) => { const { entityIdentifier } = action.payload; @@ -146,18 +144,26 @@ const slice = createSlice({ } delete layer.adjustments; }, + rasterLayerAdjustmentsModeChanged: ( + state, + action: PayloadAction> + ) => { + const { entityIdentifier, mode } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer?.adjustments) { + return; + } + layer.adjustments.mode = mode; + }, rasterLayerAdjustmentsSimpleUpdated: ( state, action: PayloadAction }, 'raster_layer'>> ) => { const { entityIdentifier, simple } = action.payload; const layer = selectEntity(state, entityIdentifier); - if (!layer) { + if (!layer?.adjustments) { return; } - if (!layer.adjustments) { - layer.adjustments = makeDefaultRasterLayerAdjustments('simple'); - } layer.adjustments.simple = merge(layer.adjustments.simple, simple); }, rasterLayerAdjustmentsCurvesUpdated: ( @@ -166,14 +172,33 @@ const slice = createSlice({ ) => { const { entityIdentifier, channel, points } = action.payload; const layer = selectEntity(state, entityIdentifier); - if (!layer) { + if (!layer?.adjustments) { return; } - if (!layer.adjustments) { - layer.adjustments = makeDefaultRasterLayerAdjustments('curves'); - } layer.adjustments.curves[channel] = points; }, + rasterLayerAdjustmentsEnabledToggled: ( + state, + action: PayloadAction> + ) => { + const { entityIdentifier } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer?.adjustments) { + return; + } + layer.adjustments.enabled = !layer.adjustments.enabled; + }, + rasterLayerAdjustmentsCollapsedToggled: ( + state, + action: PayloadAction> + ) => { + const { entityIdentifier } = action.payload; + const layer = selectEntity(state, entityIdentifier); + if (!layer?.adjustments) { + return; + } + layer.adjustments.collapsed = !layer.adjustments.collapsed; + }, rasterLayerAdded: { reducer: ( state, @@ -1732,6 +1757,9 @@ export const { rasterLayerAdjustmentsSet, rasterLayerAdjustmentsCancel, rasterLayerAdjustmentsReset, + rasterLayerAdjustmentsModeChanged, + rasterLayerAdjustmentsEnabledToggled, + rasterLayerAdjustmentsCollapsedToggled, rasterLayerAdjustmentsSimpleUpdated, rasterLayerAdjustmentsCurvesUpdated, entityDeleted, From 07e339a14dd62d8b9d99d3e11a76de6672b7256a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:52:33 +1000 Subject: [PATCH 36/38] tidy(ui): split curves graph into own component --- .../RasterLayerCurvesAdjustmentsEditor.tsx | 454 +----------------- .../RasterLayerCurvesAdjustmentsGraph.tsx | 432 +++++++++++++++++ 2 files changed, 458 insertions(+), 428 deletions(-) create mode 100644 invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 7b2b905783e..6c783493e3a 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -1,4 +1,4 @@ -import { Box, Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { Box, Flex } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useEntityAdapterContext } from 'features/controlLayers/contexts/EntityAdapterContext'; @@ -6,9 +6,10 @@ import { useEntityIdentifierContext } from 'features/controlLayers/contexts/Enti import { rasterLayerAdjustmentsCurvesUpdated } from 'features/controlLayers/store/canvasSlice'; import { selectCanvasSlice, selectEntity } from 'features/controlLayers/store/selectors'; import type { ChannelName, ChannelPoints } from 'features/controlLayers/store/types'; -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; + +import { RasterLayerCurvesAdjustmentsGraph } from './RasterLayerCurvesAdjustmentsGraph'; const DEFAULT_POINTS: ChannelPoints = [ [0, 0], @@ -17,427 +18,6 @@ const DEFAULT_POINTS: ChannelPoints = [ type ChannelHistograms = Record; -const channelColor: Record = { - master: '#888', - r: '#e53e3e', - g: '#38a169', - b: '#3182ce', -}; - -const clamp = (v: number, min: number, max: number) => (v < min ? min : v > max ? max : v); - -const sortPoints = (pts: ChannelPoints) => - [...pts] - .sort((a, b) => { - const xDiff = a[0] - b[0]; - if (xDiff) { - return xDiff; - } - if (a[0] === 0 || a[0] === 255) { - return a[1] - b[1]; - } - return 0; - }) - // Finally, clamp to valid range and round to integers - .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] satisfies [number, number]); - -// Base canvas logical coordinate system (used for aspect ratio & initial sizing) -const CANVAS_WIDTH = 256; -const CANVAS_HEIGHT = 160; -const MARGIN_LEFT = 8; -const MARGIN_RIGHT = 8; -const MARGIN_TOP = 8; -const MARGIN_BOTTOM = 10; - -const CANVAS_STYLE: React.CSSProperties = { - width: '100%', - // Maintain aspect ratio while allowing responsive width. Height is set automatically via aspect-ratio. - aspectRatio: `${CANVAS_WIDTH} / ${CANVAS_HEIGHT}`, - height: 'auto', - touchAction: 'none', - borderRadius: 4, - background: '#111', - display: 'block', -}; - -type CurveGraphProps = { - title: string; - channel: ChannelName; - points: ChannelPoints | undefined; - histogram: number[] | null; - onChange: (pts: ChannelPoints) => void; -}; - -const drawHistogram = ( - c: HTMLCanvasElement, - channel: ChannelName, - histogram: number[] | null, - points: ChannelPoints -) => { - // Use device pixel ratio for crisp rendering on HiDPI displays. - const dpr = window.devicePixelRatio || 1; - const cssWidth = c.clientWidth || CANVAS_WIDTH; // CSS pixels - const cssHeight = (cssWidth * CANVAS_HEIGHT) / CANVAS_WIDTH; // maintain aspect ratio - - // Ensure the backing store matches current display size * dpr (only if changed). - const targetWidth = Math.round(cssWidth * dpr); - const targetHeight = Math.round(cssHeight * dpr); - if (c.width !== targetWidth || c.height !== targetHeight) { - c.width = targetWidth; - c.height = targetHeight; - } - // Guarantee the CSS height stays synced (width is 100%). - if (c.style.height !== `${cssHeight}px`) { - c.style.height = `${cssHeight}px`; - } - - const ctx = c.getContext('2d'); - if (!ctx) { - return; - } - - // Reset transform then scale for dpr so we can draw in CSS pixel coordinates. - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.scale(dpr, dpr); - - // Dynamic inner geometry (CSS pixel space) - const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; - const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; - - const valueToCanvasX = (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * innerWidth; - const valueToCanvasY = (y: number) => MARGIN_TOP + innerHeight - (clamp(y, 0, 255) / 255) * innerHeight; - - // Clear & background - ctx.clearRect(0, 0, cssWidth, cssHeight); - ctx.fillStyle = '#111'; - ctx.fillRect(0, 0, cssWidth, cssHeight); - - // Grid - ctx.strokeStyle = '#2a2a2a'; - ctx.lineWidth = 1; - for (let i = 0; i <= 4; i++) { - const y = MARGIN_TOP + (i * innerHeight) / 4; - ctx.beginPath(); - ctx.moveTo(MARGIN_LEFT + 0.5, y + 0.5); - ctx.lineTo(MARGIN_LEFT + innerWidth - 0.5, y + 0.5); - ctx.stroke(); - } - for (let i = 0; i <= 4; i++) { - const x = MARGIN_LEFT + (i * innerWidth) / 4; - ctx.beginPath(); - ctx.moveTo(x + 0.5, MARGIN_TOP + 0.5); - ctx.lineTo(x + 0.5, MARGIN_TOP + innerHeight - 0.5); - ctx.stroke(); - } - - // Histogram - if (histogram) { - const logHist = histogram.map((v) => Math.log10((v ?? 0) + 1)); - const max = Math.max(1e-6, ...logHist); - ctx.fillStyle = '#5557'; - - // If there's enough horizontal room, draw each of the 256 bins with exact (possibly fractional) width so they tessellate. - // Otherwise, aggregate multiple bins into per-pixel columns to avoid aliasing. - if (innerWidth >= 256) { - for (let i = 0; i < 256; i++) { - const v = logHist[i] ?? 0; - const h = (v / max) * (innerHeight - 2); - // Exact fractional coordinates for seamless coverage (no gaps as width grows) - const x0 = MARGIN_LEFT + (i / 256) * innerWidth; - const x1 = MARGIN_LEFT + ((i + 1) / 256) * innerWidth; - const w = x1 - x0; - if (w <= 0) { - continue; - } // safety - const y = MARGIN_TOP + innerHeight - h; - ctx.fillRect(x0, y, w, h); - } - } else { - // Aggregate bins per CSS pixel column (similar to previous anti-moire approach) - const columns = Math.max(1, Math.round(innerWidth)); - const binsPerCol = 256 / columns; - for (let col = 0; col < columns; col++) { - const startBin = Math.floor(col * binsPerCol); - const endBin = Math.min(255, Math.floor((col + 1) * binsPerCol - 1)); - let acc = 0; - let count = 0; - for (let b = startBin; b <= endBin; b++) { - acc += logHist[b] ?? 0; - count++; - } - const v = count > 0 ? acc / count : 0; - const h = (v / max) * (innerHeight - 2); - const x = MARGIN_LEFT + col; - const y = MARGIN_TOP + innerHeight - h; - ctx.fillRect(x, y, 1, h); - } - } - } - - // Curve - const pts = sortPoints(points); - ctx.strokeStyle = channelColor[channel]; - ctx.lineWidth = 2; - ctx.beginPath(); - for (let i = 0; i < pts.length; i++) { - const [x, y] = pts[i]!; - const cx = valueToCanvasX(x); - const cy = valueToCanvasY(y); - if (i === 0) { - ctx.moveTo(cx, cy); - } else { - ctx.lineTo(cx, cy); - } - } - ctx.stroke(); - - // Control points - for (let i = 0; i < pts.length; i++) { - const [x, y] = pts[i]!; - const cx = valueToCanvasX(x); - const cy = valueToCanvasY(y); - ctx.fillStyle = '#000'; - ctx.beginPath(); - ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = channelColor[channel]; - ctx.lineWidth = 1.5; - ctx.stroke(); - } -}; - -const getNearestPointIndex = (c: HTMLCanvasElement, points: ChannelPoints, mx: number, my: number) => { - const cssWidth = c.clientWidth || CANVAS_WIDTH; - const cssHeight = c.clientHeight || CANVAS_HEIGHT; - const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; - const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; - const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); - const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); - const xVal = canvasToValueX(mx); - const yVal = canvasToValueY(my); - let best = -1; - let bestDist = 9999; - for (let i = 0; i < points.length; i++) { - const [px, py] = points[i]!; - const dx = px - xVal; - const dy = py - yVal; - const d = dx * dx + dy * dy; - if (d < bestDist) { - best = i; - bestDist = d; - } - } - if (best !== -1 && bestDist <= 20 * 20) { - return best; - } - return -1; -}; - -const canvasXToValueX = (c: HTMLCanvasElement, cx: number): number => { - const cssWidth = c.clientWidth || CANVAS_WIDTH; - const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; - return clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); -}; - -const canvasYToValueY = (c: HTMLCanvasElement, cy: number) => { - const cssHeight = c.clientHeight || CANVAS_HEIGHT; - const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; - return clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); -}; - -const CurveGraph = memo(function CurveGraph(props: CurveGraphProps) { - const { title, channel, points, histogram, onChange } = props; - const canvasRef = useRef(null); - const [localPoints, setLocalPoints] = useState(sortPoints(points ?? DEFAULT_POINTS)); - const [dragIndex, setDragIndex] = useState(null); - - useEffect(() => { - setLocalPoints(sortPoints(points ?? DEFAULT_POINTS)); - }, [points]); - - useEffect(() => { - const c = canvasRef.current; - if (!c) { - return; - } - drawHistogram(c, channel, histogram, localPoints); - }, [channel, histogram, localPoints]); - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - const c = canvasRef.current; - if (!c) { - return; - } - // Capture the pointer so we still get pointerup even if released outside the canvas. - try { - c.setPointerCapture(e.pointerId); - } catch { - /* ignore */ - } - const rect = c.getBoundingClientRect(); - const mx = e.clientX - rect.left; // CSS pixel coordinates - const my = e.clientY - rect.top; - const idx = getNearestPointIndex(c, localPoints, mx, my); - if (idx !== -1 && idx !== 0 && idx !== localPoints.length - 1) { - setDragIndex(idx); - return; - } - const xVal = canvasXToValueX(c, mx); - const yVal = canvasYToValueY(c, my); - const next = sortPoints([...localPoints, [xVal, yVal]]); - setLocalPoints(next); - setDragIndex(next.findIndex(([x, y]) => x === xVal && y === yVal)); - }, - [localPoints] - ); - - const handlePointerMove = useCallback( - (e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (dragIndex === null) { - return; - } - const c = canvasRef.current; - if (!c) { - return; - } - const rect = c.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const mxVal = canvasXToValueX(c, mx); - const myVal = canvasYToValueY(c, my); - setLocalPoints((prev) => { - // Endpoints are immutable; safety check. - if (dragIndex === 0 || dragIndex === prev.length - 1) { - return prev; - } - const leftX = prev[dragIndex - 1]![0]; - const rightX = prev[dragIndex + 1]![0]; - // Constrain to strictly between neighbors so ordering is preserved & no crossing. - const minX = Math.min(254, leftX); - const maxX = Math.max(1, rightX); - const clampedX = clamp(mxVal, minX, maxX); - // If neighbors are adjacent (minX > maxX after adjustments), effectively lock X. - const finalX = minX > maxX ? leftX + 1 - 1 /* keep existing */ : clampedX; - const next = [...prev]; - next[dragIndex] = [finalX, myVal]; - return next; // already ordered due to constraints - }); - }, - [dragIndex] - ); - - const commit = useCallback( - (pts: ChannelPoints) => { - onChange(sortPoints(pts)); - }, - [onChange] - ); - - const handlePointerUp = useCallback( - (e: React.PointerEvent) => { - e.preventDefault(); - e.stopPropagation(); - const c = canvasRef.current; - if (c) { - try { - c.releasePointerCapture(e.pointerId); - } catch { - /* ignore */ - } - } - setDragIndex(null); - commit(localPoints); - }, - [commit, localPoints] - ); - - const handlePointerCancel = useCallback( - (e: React.PointerEvent) => { - const c = canvasRef.current; - if (c) { - try { - c.releasePointerCapture(e.pointerId); - } catch { - /* ignore */ - } - } - setDragIndex(null); - commit(localPoints); - }, - [commit, localPoints] - ); - - const handleDoubleClick = useCallback( - (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const c = canvasRef.current; - if (!c) { - return; - } - const rect = c.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - const idx = getNearestPointIndex(c, localPoints, mx, my); - if (idx > 0 && idx < localPoints.length - 1) { - const next = localPoints.filter((_, i) => i !== idx); - setLocalPoints(next); - commit(next); - } - }, - [commit, localPoints] - ); - - // Observe size changes to redraw (responsive behavior) - useEffect(() => { - const c = canvasRef.current; - if (!c) { - return; - } - const ro = new ResizeObserver(() => { - drawHistogram(c, channel, histogram, localPoints); - }); - ro.observe(c); - return () => ro.disconnect(); - }, [channel, histogram, localPoints]); - - const resetPoints = useCallback(() => { - setLocalPoints(sortPoints(DEFAULT_POINTS)); - commit(DEFAULT_POINTS); - }, [commit]); - - return ( - - - - {title} - - } - aria-label="Reset" - size="sm" - variant="link" - onClick={resetPoints} - /> - - - - ); -}); - const calculateHistogramsFromImageData = (imageData: ImageData): ChannelHistograms | null => { try { const data = imageData.data; @@ -555,16 +135,34 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { pointerEvents={isDisabled ? 'none' : 'auto'} > - - - - + + + ); diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx new file mode 100644 index 00000000000..3790b0b4c27 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx @@ -0,0 +1,432 @@ +import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import type { ChannelName, ChannelPoints } from 'features/controlLayers/store/types'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; + +const DEFAULT_POINTS: ChannelPoints = [ + [0, 0], + [255, 255], +]; + +const channelColor: Record = { + master: '#888', + r: '#e53e3e', + g: '#38a169', + b: '#3182ce', +}; + +const clamp = (v: number, min: number, max: number) => (v < min ? min : v > max ? max : v); + +const sortPoints = (pts: ChannelPoints) => + [...pts] + .sort((a, b) => { + const xDiff = a[0] - b[0]; + if (xDiff) { + return xDiff; + } + if (a[0] === 0 || a[0] === 255) { + return a[1] - b[1]; + } + return 0; + }) + // Finally, clamp to valid range and round to integers + .map(([x, y]) => [clamp(Math.round(x), 0, 255), clamp(Math.round(y), 0, 255)] satisfies [number, number]); + +// Base canvas logical coordinate system (used for aspect ratio & initial sizing) +const CANVAS_WIDTH = 256; +const CANVAS_HEIGHT = 160; +const MARGIN_LEFT = 8; +const MARGIN_RIGHT = 8; +const MARGIN_TOP = 8; +const MARGIN_BOTTOM = 10; + +const CANVAS_STYLE: React.CSSProperties = { + width: '100%', + // Maintain aspect ratio while allowing responsive width. Height is set automatically via aspect-ratio. + aspectRatio: `${CANVAS_WIDTH} / ${CANVAS_HEIGHT}`, + height: 'auto', + touchAction: 'none', + borderRadius: 4, + background: '#111', + display: 'block', +}; + +export type CurveGraphProps = { + title: string; + channel: ChannelName; + points: ChannelPoints | undefined; + histogram: number[] | null; + onChange: (pts: ChannelPoints) => void; +}; + +const drawHistogram = ( + c: HTMLCanvasElement, + channel: ChannelName, + histogram: number[] | null, + points: ChannelPoints +) => { + // Use device pixel ratio for crisp rendering on HiDPI displays. + const dpr = window.devicePixelRatio || 1; + const cssWidth = c.clientWidth || CANVAS_WIDTH; // CSS pixels + const cssHeight = (cssWidth * CANVAS_HEIGHT) / CANVAS_WIDTH; // maintain aspect ratio + + // Ensure the backing store matches current display size * dpr (only if changed). + const targetWidth = Math.round(cssWidth * dpr); + const targetHeight = Math.round(cssHeight * dpr); + if (c.width !== targetWidth || c.height !== targetHeight) { + c.width = targetWidth; + c.height = targetHeight; + } + // Guarantee the CSS height stays synced (width is 100%). + if (c.style.height !== `${cssHeight}px`) { + c.style.height = `${cssHeight}px`; + } + + const ctx = c.getContext('2d'); + if (!ctx) { + return; + } + + // Reset transform then scale for dpr so we can draw in CSS pixel coordinates. + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(dpr, dpr); + + // Dynamic inner geometry (CSS pixel space) + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + + const valueToCanvasX = (x: number) => MARGIN_LEFT + (clamp(x, 0, 255) / 255) * innerWidth; + const valueToCanvasY = (y: number) => MARGIN_TOP + innerHeight - (clamp(y, 0, 255) / 255) * innerHeight; + + // Clear & background + ctx.clearRect(0, 0, cssWidth, cssHeight); + ctx.fillStyle = '#111'; + ctx.fillRect(0, 0, cssWidth, cssHeight); + + // Grid + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 1; + for (let i = 0; i <= 4; i++) { + const y = MARGIN_TOP + (i * innerHeight) / 4; + ctx.beginPath(); + ctx.moveTo(MARGIN_LEFT + 0.5, y + 0.5); + ctx.lineTo(MARGIN_LEFT + innerWidth - 0.5, y + 0.5); + ctx.stroke(); + } + for (let i = 0; i <= 4; i++) { + const x = MARGIN_LEFT + (i * innerWidth) / 4; + ctx.beginPath(); + ctx.moveTo(x + 0.5, MARGIN_TOP + 0.5); + ctx.lineTo(x + 0.5, MARGIN_TOP + innerHeight - 0.5); + ctx.stroke(); + } + + // Histogram + if (histogram) { + const logHist = histogram.map((v) => Math.log10((v ?? 0) + 1)); + const max = Math.max(1e-6, ...logHist); + ctx.fillStyle = '#5557'; + + // If there's enough horizontal room, draw each of the 256 bins with exact (possibly fractional) width so they tessellate. + // Otherwise, aggregate multiple bins into per-pixel columns to avoid aliasing. + if (innerWidth >= 256) { + for (let i = 0; i < 256; i++) { + const v = logHist[i] ?? 0; + const h = (v / max) * (innerHeight - 2); + // Exact fractional coordinates for seamless coverage (no gaps as width grows) + const x0 = MARGIN_LEFT + (i / 256) * innerWidth; + const x1 = MARGIN_LEFT + ((i + 1) / 256) * innerWidth; + const w = x1 - x0; + if (w <= 0) { + continue; + } // safety + const y = MARGIN_TOP + innerHeight - h; + ctx.fillRect(x0, y, w, h); + } + } else { + // Aggregate bins per CSS pixel column (similar to previous anti-moire approach) + const columns = Math.max(1, Math.round(innerWidth)); + const binsPerCol = 256 / columns; + for (let col = 0; col < columns; col++) { + const startBin = Math.floor(col * binsPerCol); + const endBin = Math.min(255, Math.floor((col + 1) * binsPerCol - 1)); + let acc = 0; + let count = 0; + for (let b = startBin; b <= endBin; b++) { + acc += logHist[b] ?? 0; + count++; + } + const v = count > 0 ? acc / count : 0; + const h = (v / max) * (innerHeight - 2); + const x = MARGIN_LEFT + col; + const y = MARGIN_TOP + innerHeight - h; + ctx.fillRect(x, y, 1, h); + } + } + } + + // Curve + const pts = sortPoints(points); + ctx.strokeStyle = channelColor[channel]; + ctx.lineWidth = 2; + ctx.beginPath(); + for (let i = 0; i < pts.length; i++) { + const [x, y] = pts[i]!; + const cx = valueToCanvasX(x); + const cy = valueToCanvasY(y); + if (i === 0) { + ctx.moveTo(cx, cy); + } else { + ctx.lineTo(cx, cy); + } + } + ctx.stroke(); + + // Control points + for (let i = 0; i < pts.length; i++) { + const [x, y] = pts[i]!; + const cx = valueToCanvasX(x); + const cy = valueToCanvasY(y); + ctx.fillStyle = '#000'; + ctx.beginPath(); + ctx.arc(cx, cy, 3.5, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = channelColor[channel]; + ctx.lineWidth = 1.5; + ctx.stroke(); + } +}; + +const getNearestPointIndex = (c: HTMLCanvasElement, points: ChannelPoints, mx: number, my: number) => { + const cssWidth = c.clientWidth || CANVAS_WIDTH; + const cssHeight = c.clientHeight || CANVAS_HEIGHT; + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + const canvasToValueX = (cx: number) => clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); + const canvasToValueY = (cy: number) => clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); + const xVal = canvasToValueX(mx); + const yVal = canvasToValueY(my); + let best = -1; + let bestDist = 9999; + for (let i = 0; i < points.length; i++) { + const [px, py] = points[i]!; + const dx = px - xVal; + const dy = py - yVal; + const d = dx * dx + dy * dy; + if (d < bestDist) { + best = i; + bestDist = d; + } + } + if (best !== -1 && bestDist <= 20 * 20) { + return best; + } + return -1; +}; + +const canvasXToValueX = (c: HTMLCanvasElement, cx: number): number => { + const cssWidth = c.clientWidth || CANVAS_WIDTH; + const innerWidth = cssWidth - MARGIN_LEFT - MARGIN_RIGHT; + return clamp(Math.round(((cx - MARGIN_LEFT) / innerWidth) * 255), 0, 255); +}; + +const canvasYToValueY = (c: HTMLCanvasElement, cy: number) => { + const cssHeight = c.clientHeight || CANVAS_HEIGHT; + const innerHeight = cssHeight - MARGIN_TOP - MARGIN_BOTTOM; + return clamp(Math.round(255 - ((cy - MARGIN_TOP) / innerHeight) * 255), 0, 255); +}; + +export const RasterLayerCurvesAdjustmentsGraph = memo((props: CurveGraphProps) => { + const { title, channel, points, histogram, onChange } = props; + const canvasRef = useRef(null); + const [localPoints, setLocalPoints] = useState(sortPoints(points ?? DEFAULT_POINTS)); + const [dragIndex, setDragIndex] = useState(null); + + useEffect(() => { + setLocalPoints(sortPoints(points ?? DEFAULT_POINTS)); + }, [points]); + + useEffect(() => { + const c = canvasRef.current; + if (!c) { + return; + } + drawHistogram(c, channel, histogram, localPoints); + }, [channel, histogram, localPoints]); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const c = canvasRef.current; + if (!c) { + return; + } + // Capture the pointer so we still get pointerup even if released outside the canvas. + try { + c.setPointerCapture(e.pointerId); + } catch { + /* ignore */ + } + const rect = c.getBoundingClientRect(); + const mx = e.clientX - rect.left; // CSS pixel coordinates + const my = e.clientY - rect.top; + const idx = getNearestPointIndex(c, localPoints, mx, my); + if (idx !== -1 && idx !== 0 && idx !== localPoints.length - 1) { + setDragIndex(idx); + return; + } + const xVal = canvasXToValueX(c, mx); + const yVal = canvasYToValueY(c, my); + const next = sortPoints([...localPoints, [xVal, yVal]]); + setLocalPoints(next); + setDragIndex(next.findIndex(([x, y]) => x === xVal && y === yVal)); + }, + [localPoints] + ); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (dragIndex === null) { + return; + } + const c = canvasRef.current; + if (!c) { + return; + } + const rect = c.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const mxVal = canvasXToValueX(c, mx); + const myVal = canvasYToValueY(c, my); + setLocalPoints((prev) => { + // Endpoints are immutable; safety check. + if (dragIndex === 0 || dragIndex === prev.length - 1) { + return prev; + } + const leftX = prev[dragIndex - 1]![0]; + const rightX = prev[dragIndex + 1]![0]; + // Constrain to strictly between neighbors so ordering is preserved & no crossing. + const minX = Math.min(254, leftX); + const maxX = Math.max(1, rightX); + const clampedX = clamp(mxVal, minX, maxX); + // If neighbors are adjacent (minX > maxX after adjustments), effectively lock X. + const finalX = minX > maxX ? leftX + 1 - 1 /* keep existing */ : clampedX; + const next = [...prev]; + next[dragIndex] = [finalX, myVal]; + return next; // already ordered due to constraints + }); + }, + [dragIndex] + ); + + const commit = useCallback( + (pts: ChannelPoints) => { + onChange(sortPoints(pts)); + }, + [onChange] + ); + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const c = canvasRef.current; + if (c) { + try { + c.releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + } + setDragIndex(null); + commit(localPoints); + }, + [commit, localPoints] + ); + + const handlePointerCancel = useCallback( + (e: React.PointerEvent) => { + const c = canvasRef.current; + if (c) { + try { + c.releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + } + setDragIndex(null); + commit(localPoints); + }, + [commit, localPoints] + ); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + const c = canvasRef.current; + if (!c) { + return; + } + const rect = c.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const idx = getNearestPointIndex(c, localPoints, mx, my); + if (idx > 0 && idx < localPoints.length - 1) { + const next = localPoints.filter((_, i) => i !== idx); + setLocalPoints(next); + commit(next); + } + }, + [commit, localPoints] + ); + + // Observe size changes to redraw (responsive behavior) + useEffect(() => { + const c = canvasRef.current; + if (!c) { + return; + } + const ro = new ResizeObserver(() => { + drawHistogram(c, channel, histogram, localPoints); + }); + ro.observe(c); + return () => ro.disconnect(); + }, [channel, histogram, localPoints]); + + const resetPoints = useCallback(() => { + setLocalPoints(sortPoints(DEFAULT_POINTS)); + commit(DEFAULT_POINTS); + }, [commit]); + + return ( + + + + {title} + + } + aria-label="Reset" + size="sm" + variant="link" + onClick={resetPoints} + /> + + + + ); +}); + +RasterLayerCurvesAdjustmentsGraph.displayName = 'RasterLayerCurvesAdjustmentsGraph'; From 24344607f4d858bf1523f90bfbf935b98ee94e04 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:01:07 +1000 Subject: [PATCH 37/38] perf(ui): optimize curves graph component Do not use whole layer as trigger for histo recalc; use the canvas cache of the layer - it more reliably indicates when the layer pixel data has changed, and fixes an issue where we can miss the first histo calc due to race conditiong with async layer bbox calculation. --- .../RasterLayerCurvesAdjustmentsEditor.tsx | 40 +++++++++++-------- .../src/features/controlLayers/store/types.ts | 1 + 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx index 6c783493e3a..9610927e016 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsEditor.tsx @@ -1,11 +1,12 @@ 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 } from 'features/controlLayers/store/types'; +import type { ChannelName, ChannelPoints, CurvesAdjustmentsConfig } from 'features/controlLayers/store/types'; import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,6 +17,13 @@ const DEFAULT_POINTS: ChannelPoints = [ [255, 255], ]; +const DEFAULT_CURVES: CurvesAdjustmentsConfig = { + master: DEFAULT_POINTS, + r: DEFAULT_POINTS, + g: DEFAULT_POINTS, + b: DEFAULT_POINTS, +}; + type ChannelHistograms = Record; const calculateHistogramsFromImageData = (imageData: ImageData): ChannelHistograms | null => { @@ -62,11 +70,14 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { const entityIdentifier = useEntityIdentifierContext<'raster_layer'>(); const adapter = useEntityAdapterContext<'raster_layer'>('raster_layer'); const { t } = useTranslation(); - const selectLayer = useMemo( - () => createSelector(selectCanvasSlice, (canvas) => selectEntity(canvas, entityIdentifier)), - [entityIdentifier] - ); - const layer = useAppSelector(selectLayer); + 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, @@ -74,17 +85,14 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { ); }, [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(null); const [histR, setHistR] = useState(null); const [histG, setHistG] = useState(null); const [histB, setHistB] = useState(null); - const pointsMaster = layer?.adjustments?.curves.master ?? DEFAULT_POINTS; - const pointsR = layer?.adjustments?.curves.r ?? DEFAULT_POINTS; - const pointsG = layer?.adjustments?.curves.g ?? DEFAULT_POINTS; - const pointsB = layer?.adjustments?.curves.b ?? DEFAULT_POINTS; - const recalcHistogram = useCallback(() => { try { const rect = adapter.transformer.getRelativeRect(); @@ -110,7 +118,7 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { useEffect(() => { recalcHistogram(); - }, [layer?.objects, layer?.adjustments, recalcHistogram]); + }, [canvasCache, recalcHistogram]); const onChangePoints = useCallback( (channel: ChannelName, pts: ChannelPoints) => { @@ -138,28 +146,28 @@ export const RasterLayerCurvesAdjustmentsEditor = memo(() => { diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index b0d9869ffcb..d5012bd88c9 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -404,6 +404,7 @@ const zChannelName = z.enum(['master', 'r', 'g', 'b']); const zCurvesAdjustmentsConfig = z.record(zChannelName, zChannelPoints); export type ChannelName = z.infer; export type ChannelPoints = z.infer; +export type CurvesAdjustmentsConfig = z.infer; /** * The curves adjustments are stored as LUTs in the Konva node attributes. Konva will use these values when applying From e9c39f78d9e0c81e8ab03077f4d0f9d2e618bc35 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 11 Sep 2025 17:02:34 +1000 Subject: [PATCH 38/38] chore(ui): lint --- .../RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx index 3790b0b4c27..d8166ef686f 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerCurvesAdjustmentsGraph.tsx @@ -51,7 +51,7 @@ const CANVAS_STYLE: React.CSSProperties = { display: 'block', }; -export type CurveGraphProps = { +type CurveGraphProps = { title: string; channel: ChannelName; points: ChannelPoints | undefined;