diff --git a/src/components/adjustments/Curves.tsx b/src/components/adjustments/Curves.tsx index 0a0ef551..2cb2297f 100644 --- a/src/components/adjustments/Curves.tsx +++ b/src/components/adjustments/Curves.tsx @@ -1,13 +1,15 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { RotateCcw, Copy, ClipboardPaste } from 'lucide-react'; -import { ActiveChannel, Adjustments, Coord } from '../../utils/adjustments'; +import { RotateCcw, Copy, ClipboardPaste, Spline, SlidersHorizontal } from 'lucide-react'; +import { ActiveChannel, Adjustments, Coord, ParametricCurveSettings } from '../../utils/adjustments'; import { Theme, OPTION_SEPARATOR } from '../ui/AppProperties'; import { useContextMenu } from '../../context/ContextMenuContext'; import Text from '../ui/Text'; +import Slider from '../ui/Slider'; import { TextColors, TextVariants, TextWeights } from '../../types/typography'; let curveClipboard: Array | null = null; +let parametricClipboard: ParametricCurveSettings | null = null; export interface ChannelConfig { [index: string]: ColorData; @@ -31,6 +33,207 @@ interface CurveGraphProps { onDragStateChange?: (isDragging: boolean) => void; } +function getDefaultParametricCurve(): ParametricCurveSettings { + return { + darks: 0, + shadows: 0, + highlights: 0, + lights: 0, + split1: 25, + split2: 50, + split3: 75, + }; +} + +function getDefaultParametricCurveChannels() { + return { + [ActiveChannel.Luma]: getDefaultParametricCurve(), + [ActiveChannel.Red]: getDefaultParametricCurve(), + [ActiveChannel.Green]: getDefaultParametricCurve(), + [ActiveChannel.Blue]: getDefaultParametricCurve(), + }; +} + +function isDefaultParametricCurve(settings: ParametricCurveSettings | undefined) { + if (!settings) return true; + const defaults = getDefaultParametricCurve(); + return ( + settings.darks === defaults.darks && + settings.shadows === defaults.shadows && + settings.highlights === defaults.highlights && + settings.lights === defaults.lights && + settings.split1 === defaults.split1 && + settings.split2 === defaults.split2 && + settings.split3 === defaults.split3 + ); +} + +function getInfluence(slider: string, splitPointValue: number): number { + + if (slider === 'shadows') { + if (splitPointValue < 0 || splitPointValue > 0.30) return 0; + const t = splitPointValue / 0.30; + const influence = Math.sin(t * Math.PI);n + return influence; + } + else if (slider === 'darks') { + if (splitPointValue < 0.20 || splitPointValue > 0.55) return 0; + const t = (splitPointValue - 0.20) / 0.35; + const influence = Math.sin(t * Math.PI); + return influence; + } + else if (slider === 'lights') { + if (splitPointValue < 0.45 || splitPointValue > 0.80) return 0; + const t = (splitPointValue - 0.45) / 0.35; + const influence = Math.sin(t * Math.PI); + return influence; + } + else if (slider === 'highlights') { + if (splitPointValue < 0.70 || splitPointValue > 1.0) return 0; + const t = (splitPointValue - 0.70) / 0.30; + const influence = Math.sin(t * Math.PI); + return influence; + } + return 0; +} + +const MAX_OFFSET = 0.25; + +function computeControlPoints(settings: ParametricCurveSettings) { + const normShadows = settings.shadows / 100; + const normDarks = settings.darks / 100; + const normLights = settings.lights / 100; + const normHighlights = settings.highlights / 100; + + const split1 = settings.split1 / 100; + const split2 = settings.split2 / 100; + const split3 = settings.split3 / 100; + + function offsetAt(splitPointNorm: number): number { + let off = 0; + off += normShadows * getInfluence('shadows', splitPointNorm) * MAX_OFFSET; + off += normDarks * getInfluence('darks', splitPointNorm) * MAX_OFFSET; + off += normLights * getInfluence('lights', splitPointNorm) * MAX_OFFSET; + off += normHighlights * getInfluence('highlights', splitPointNorm) * MAX_OFFSET; + return off; + } + + let y1 = split1 + offsetAt(split1); + let y2 = split2 + offsetAt(split2); + let y3 = split3 + offsetAt(split3); + + const minGap = 0.01; + + y1 = Math.min(Math.max(y1, 0.02), Math.min(y2 - minGap, 0.98)); + y2 = Math.min(Math.max(y2, y1 + minGap), Math.min(y3 - minGap, 0.95)); + y3 = Math.min(Math.max(y3, y2 + minGap), 0.98); + + return { y1, y2, y3 }; +} + +function createMonotonicSpline(xp: number[], yp: number[]) { + const n = xp.length; + const m = new Array(n); + const delta = []; + + for (let i = 0; i < n - 1; i++) { + delta.push((yp[i + 1] - yp[i]) / (xp[i + 1] - xp[i])); + } + + for (let i = 0; i < n; i++) { + if (i === 0) { + m[i] = delta[0]; + } else if (i === n - 1) { + m[i] = delta[n - 2]; + } else { + if (delta[i - 1] * delta[i] <= 0) { + m[i] = 0; + } else { + const w1 = 2 * (xp[i + 1] - xp[i]); + const w2 = 2 * (xp[i] - xp[i - 1]); + m[i] = (w1 * delta[i - 1] + w2 * delta[i]) / (w1 + w2); + } + } + } + + for (let i = 0; i < n - 1; i++) { + if (delta[i] === 0) { + m[i] = 0; + m[i + 1] = 0; + } else { + const alpha = m[i] / delta[i]; + const beta = m[i + 1] / delta[i]; + + if (alpha * alpha + beta * beta > 9) { + const tau = 3.0 / Math.sqrt(alpha * alpha + beta * beta); + m[i] = tau * alpha * delta[i]; + m[i + 1] = tau * beta * delta[i]; + } + } + } + + return function (t: number) { + if (t <= xp[0]) return yp[0]; + if (t >= xp[n - 1]) return yp[n - 1]; + + let idx = 0; + for (let i = 0; i < n - 1; i++) { + if (t >= xp[i] && t <= xp[i + 1]) { + idx = i; + break; + } + } + + const x0 = xp[idx], x1 = xp[idx + 1]; + const y0 = yp[idx], y1 = yp[idx + 1]; + const m0 = m[idx], m1 = m[idx + 1]; + const h = x1 - x0; + const tNorm = (t - x0) / h; + const t2 = tNorm * tNorm; + const t3 = t2 * tNorm; + + const h00 = 2 * t3 - 3 * t2 + 1; + const h10 = t3 - 2 * t2 + tNorm; + const h01 = -2 * t3 + 3 * t2; + const h11 = t3 - t2; + + return h00 * y0 + h10 * m0 * h + h01 * y1 + h11 * m1 * h; + }; +} + +function buildParametricCurve(settings: ParametricCurveSettings, pointCount = 256): Array { + const split1 = settings.split1 / 100; + const split2 = settings.split2 / 100; + const split3 = settings.split3 / 100; + + const { y1, y2, y3 } = computeControlPoints(settings); + + const xPoints = [0, split1, split2, split3, 1]; + const yPoints = [0, y1, y2, y3, 1]; + + const spline = createMonotonicSpline(xPoints, yPoints); + + const points: Array = []; + + for (let i = 0; i < pointCount; i++) { + const x = (i / (pointCount - 1)) * 255; + const t = x / 255; + let y = spline(t); + y = Math.max(0, Math.min(1, y)); + + points.push({ + x, + y: y * 255, + }); + } + + return points; +} + +function buildParametricCurvePreview(settings: ParametricCurveSettings): Array { + return buildParametricCurve(settings, 256); +} + function getCurvePath(points: Array) { if (points.length < 2) return ''; @@ -149,6 +352,20 @@ function isDefaultCurve(points: Array | undefined) { return p1.x === 0 && p1.y === 0 && p2.x === 255 && p2.y === 255; } +function areCurvesEqual(a: Array | undefined, b: Array | undefined) { + if (a === b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + + for (let i = 0; i < a.length; i++) { + if (a[i].x !== b[i].x || a[i].y !== b[i].y) { + return false; + } + } + + return true; +} + export default function CurveGraph({ adjustments, setAdjustments, @@ -160,8 +377,11 @@ export default function CurveGraph({ const { showContextMenu } = useContextMenu(); const [activeChannel, setActiveChannel] = useState(ActiveChannel.Luma); const [draggingPointIndex, setDraggingPointIndex] = useState(null); + const [draggingSplitKey, setDraggingSplitKey] = useState<'split1' | 'split2' | 'split3' | null>(null); const [localPoints, setLocalPoints] = useState | null>(null); const [isHovered, setIsHovered] = useState(false); + const [curveMode, setCurveMode] = useState<'point' | 'parametric'>('point'); + const [activeParametricChannel, setActiveParametricChannel] = useState(ActiveChannel.Luma); const containerRef = useRef(null); const svgRef = useRef(null); @@ -171,12 +391,24 @@ export default function CurveGraph({ const propPointsRef = useRef | undefined>(undefined); const isHoveredRef = useRef(false); + const parametricCurves = adjustments?.parametricCurve || getDefaultParametricCurveChannels(); + const parametricCurve = parametricCurves[activeParametricChannel] || getDefaultParametricCurve(); + const isParametricMode = curveMode === 'parametric'; + + const parametricPreviewPoints = useMemo(() => buildParametricCurvePreview(parametricCurve), [parametricCurve]); + useEffect(() => { activeChannelRef.current = activeChannel; setLocalPoints(null); setDraggingPointIndex(null); }, [activeChannel]); + useEffect(() => { + if (curveMode === 'parametric') { + setActiveParametricChannel(ActiveChannel.Luma); + } + }, [curveMode]); + useEffect(() => { propPointsRef.current = adjustments?.curves?.[activeChannel]; }, [adjustments?.curves, activeChannel]); @@ -189,10 +421,10 @@ export default function CurveGraph({ }, [adjustments?.curves?.[activeChannel], draggingPointIndex]); useEffect(() => { - const isDragging = draggingPointIndex !== null; + const isDragging = draggingPointIndex !== null || draggingSplitKey !== null; onDragStateChange?.(isDragging); draggingIndexRef.current = draggingPointIndex; - }, [draggingPointIndex, onDragStateChange]); + }, [draggingPointIndex, draggingSplitKey, onDragStateChange]); useEffect(() => { const handleGlobalMouseMove = (e: MouseEvent) => { @@ -222,7 +454,7 @@ export default function CurveGraph({ if (index === null) return; const currentPoints = localPointsRef.current || propPointsRef.current; - if (!currentPoints) return; + if (!currentPoints || isParametricMode) return; const svg = svgRef.current; if (!svg) return; @@ -256,8 +488,47 @@ export default function CurveGraph({ })); }; + const handleParametricMouseMove = (e: any) => { + if (!draggingSplitKey) return; + + const svg = svgRef.current; + if (!svg) return; + + const rect = svg.getBoundingClientRect(); + const rawX = ((e.clientX - rect.left) / rect.width) * 100; + + const minGap = 10; + let nextValue = Math.max(0, Math.min(100, rawX)); + + if (draggingSplitKey === 'split1') { + nextValue = Math.min(nextValue, parametricCurve.split2 - minGap); + nextValue = Math.max(nextValue, 10); + } else if (draggingSplitKey === 'split2') { + nextValue = Math.max(nextValue, parametricCurve.split1 + minGap); + nextValue = Math.min(nextValue, parametricCurve.split3 - minGap); + } else if (draggingSplitKey === 'split3') { + nextValue = Math.max(nextValue, parametricCurve.split2 + minGap); + nextValue = Math.min(nextValue, 90); + } + + setAdjustments((prev: Adjustments) => { + const parametricChannels = prev.parametricCurve || getDefaultParametricCurveChannels(); + return { + ...prev, + parametricCurve: { + ...parametricChannels, + [activeParametricChannel]: { + ...(parametricChannels[activeParametricChannel] || getDefaultParametricCurve()), + [draggingSplitKey]: nextValue, + }, + }, + }; + }); + }; + const handleMouseUp = () => { setDraggingPointIndex(null); + setDraggingSplitKey(null); draggingIndexRef.current = null; localPointsRef.current = null; onDragStateChange?.(false); @@ -266,13 +537,17 @@ export default function CurveGraph({ if (draggingPointIndex !== null) { window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); + } else if (draggingSplitKey !== null) { + window.addEventListener('mousemove', handleParametricMouseMove); + window.addEventListener('mouseup', handleMouseUp); } return () => { window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mousemove', handleParametricMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; - }, [draggingPointIndex, setAdjustments, onDragStateChange]); + }, [draggingPointIndex, draggingSplitKey, setAdjustments, onDragStateChange, parametricCurve, isParametricMode, activeParametricChannel]); const isLightTheme = theme === Theme.Light || theme === Theme.Arctic; const histogramOpacity = isLightTheme ? 0.6 : 0.15; @@ -285,10 +560,12 @@ export default function CurveGraph({ }; const propPoints = adjustments?.curves?.[activeChannel]; - const points = localPoints ?? propPoints; - const { color, data: histogramData } = channelConfig[activeChannel]; + const pointModePoints = localPoints ?? propPoints; + const points = isParametricMode ? parametricPreviewPoints : pointModePoints; + const renderChannel = isParametricMode ? activeParametricChannel : activeChannel; + const { color, data: histogramData } = channelConfig[renderChannel]; - if (!propPoints || !points) { + if ((!propPoints && !isParametricMode) || !points) { return ( { + setAdjustments((prev: Adjustments) => { + const parametricChannels = prev.parametricCurve || getDefaultParametricCurveChannels(); + + return { + ...prev, + parametricCurve: { + ...parametricChannels, + [activeParametricChannel]: { + ...(parametricChannels[activeParametricChannel] || getDefaultParametricCurve()), + [key]: value, + }, + }, + }; + }); + }; + const handlePointMouseDown = (e: any, index: number) => { + if (isParametricMode) return; + e.preventDefault(); e.stopPropagation(); @@ -326,11 +622,13 @@ export default function CurveGraph({ }; const handlePointContextMenu = (e: React.MouseEvent, index: number) => { - if (index > 0 && index < points.length - 1) { + if (isParametricMode || !pointModePoints) return; + + if (index > 0 && index < pointModePoints.length - 1) { e.preventDefault(); e.stopPropagation(); - const newPoints = points.filter((_, i) => i !== index); + const newPoints = pointModePoints.filter((_, i) => i !== index); setLocalPoints(newPoints); localPointsRef.current = newPoints; @@ -343,6 +641,7 @@ export default function CurveGraph({ }; const handleContainerMouseDown = (e: any) => { + if (isParametricMode || !pointModePoints) return; if (e.button !== 0 || e.target.tagName === 'circle') { return; } @@ -350,7 +649,7 @@ export default function CurveGraph({ onDragStateChange?.(true); const { x, y } = getMousePos(e); - const newPoints = [...points, { x, y }].sort((a: Coord, b: Coord) => a.x - b.x); + const newPoints = [...pointModePoints, { x, y }].sort((a: Coord, b: Coord) => a.x - b.x); const newPointIndex = newPoints.findIndex((p: Coord) => p.x === x && p.y === y); setLocalPoints(newPoints); @@ -366,6 +665,30 @@ export default function CurveGraph({ }; const handleDoubleClick = () => { + if (isParametricMode) { + const defaultCurvePoints = [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ]; + + setAdjustments((prev: Adjustments) => { + const parametricChannels = prev.parametricCurve || getDefaultParametricCurveChannels(); + + return { + ...prev, + parametricCurve: { + ...parametricChannels, + [activeParametricChannel]: getDefaultParametricCurve(), + }, + curves: { + ...prev.curves, + [activeParametricChannel]: defaultCurvePoints, + }, + }; + }); + return; + } + const defaultPoints = [ { x: 0, y: 0 }, { x: 255, y: 255 }, @@ -378,12 +701,76 @@ export default function CurveGraph({ })); }; + const handleResetParametric = () => { + const defaultCurvePoints = [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ]; + + setAdjustments((prev: Adjustments) => { + const parametricChannels = prev.parametricCurve || getDefaultParametricCurveChannels(); + + return { + ...prev, + parametricCurve: { + ...parametricChannels, + [activeParametricChannel]: getDefaultParametricCurve(), + }, + curves: { + ...prev.curves, + [activeParametricChannel]: defaultCurvePoints, + }, + }; + }); + }; + const handleContextMenu = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); + if (isParametricMode) { + const handleCopyParametric = () => { + parametricClipboard = { ...parametricCurve }; + }; + + const handlePasteParametric = () => { + if (!parametricClipboard) return; + setAdjustments((prev: Adjustments) => { + const parametricChannels = prev.parametricCurve || getDefaultParametricCurveChannels(); + return { + ...prev, + parametricCurve: { + ...parametricChannels, + [activeParametricChannel]: { ...parametricClipboard }, + }, + }; + }); + }; + + showContextMenu(e.clientX, e.clientY, [ + { + label: `Copy ${activeParametricChannel.charAt(0).toUpperCase() + activeParametricChannel.slice(1)} Settings`, + icon: Copy, + onClick: handleCopyParametric, + }, + { + label: 'Paste Settings', + icon: ClipboardPaste, + onClick: handlePasteParametric, + disabled: !parametricClipboard, + }, + { type: OPTION_SEPARATOR }, + { + label: `Reset ${activeParametricChannel.charAt(0).toUpperCase() + activeParametricChannel.slice(1)} Parametric Curve`, + icon: RotateCcw, + onClick: handleResetParametric, + }, + ]); + return; + } + const handleCopy = () => { - curveClipboard = points.map((p) => ({ ...p })); + curveClipboard = pointModePoints.map((p) => ({ ...p })); }; const handlePaste = () => { @@ -471,33 +858,72 @@ export default function CurveGraph({ showContextMenu(e.clientX, e.clientY, options); }; + const splitPositions = [ + { key: 'split1' as const, value: parametricCurve.split1 }, + { key: 'split2' as const, value: parametricCurve.split2 }, + { key: 'split3' as const, value: parametricCurve.split3 }, + ]; + return (
-
-
- {Object.keys(channelConfig).map((channel: any) => ( - - ))} +
+
+ + +
+ +
+ {Object.keys(channelConfig).map((channel: any) => { + const selected = isParametricMode ? activeParametricChannel === channel : activeChannel === channel; + + return ( + + ); + })}
@@ -517,7 +943,7 @@ export default function CurveGraph({ {histogramData && ( + {isParametricMode && + splitPositions.map(({ key, value }) => { + const x = (value / 100) * 255; + return ; + })} + - {points.map((p: Coord, i: number) => ( - handlePointMouseDown(e, i)} - onContextMenu={(e: React.MouseEvent) => handlePointContextMenu(e, i)} - r="6" - stroke="#1e1e1e" - strokeWidth="2" - /> - ))} + {!isParametricMode && + points.map((p: Coord, i: number) => ( + handlePointMouseDown(e, i)} + onContextMenu={(e: React.MouseEvent) => handlePointContextMenu(e, i)} + r="6" + stroke="#1e1e1e" + strokeWidth="2" + /> + ))} + + {isParametricMode && ( +
+
+
+
+ +
+ + {splitPositions.map(({ key, value }) => ( + + ))} +
+
+
+ )}
+ {isParametricMode && !isForMask && ( +
+ + Parametric Channel: {activeParametricChannel.charAt(0).toUpperCase() + activeParametricChannel.slice(1)} + + updateParametricValue('highlights', parseFloat(e.target.value))} + /> + updateParametricValue('lights', parseFloat(e.target.value))} + /> + updateParametricValue('darks', parseFloat(e.target.value))} + /> + updateParametricValue('shadows', parseFloat(e.target.value))} + /> +
+ )}
); } diff --git a/src/utils/adjustments.tsx b/src/utils/adjustments.tsx index 1d2cd0a2..ca166cb3 100644 --- a/src/utils/adjustments.tsx +++ b/src/utils/adjustments.tsx @@ -139,6 +139,7 @@ export interface Adjustments { colorNoiseReduction: number; contrast: number; curves: Curves; + parametricCurve?: ParametricCurve; crop: Crop | null; dehaze: number; exposure: number; @@ -237,6 +238,24 @@ export interface Coord { y: number; } +export interface ParametricCurveSettings { + darks: number; + shadows: number; + highlights: number; + lights: number; + split1: number; + split2: number; + split3: number; +} + +export interface ParametricCurve { + [index: string]: ParametricCurveSettings; + blue: ParametricCurveSettings; + green: ParametricCurveSettings; + luma: ParametricCurveSettings; + red: ParametricCurveSettings; +} + export interface Curves { [index: string]: Array; blue: Array;