From c4bcb12173d2aa6c244222068d2a1f71bd49e38d Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Wed, 6 May 2026 23:23:45 +0800 Subject: [PATCH] feat: add adjustments, filters, levels, curves, histogram, and effects panels for image editor Implements Features 17, 18, 35, 36, 41, 42, 43 for the editor: - SliderRow reusable component (label + range + numeric input) - AdjustmentsPanel with 8 adjustment sliders (brightness, contrast, hue, saturation, luminance, exposure, vibrance, warmth) - Auto adjustments: Auto Tone, Auto Contrast, Auto Color, Auto Enhance - Levels section with per-channel control, histogram display, black/white point, gamma, and output range sliders - Curves section with 200x200 interactive graph, cubic spline interpolation, per-channel support, and 9 presets - 12 filter controls: toggle (grayscale, sepia, invert, solarize) and slider (blur, sharpen, noise, pixelate, emboss, posterize, threshold, kaleidoscope) - Additional blur types: motion blur, radial blur, surface blur - Vignette with amount/midpoint/roundness/feather sliders - Grain with amount/size/roughness sliders - HistogramPanel with RGB channel overlays and stats (mean, stddev, median) - Reset All and Apply action buttons - 150ms debounce on adjustment sliders, 300ms on histogram updates --- .../components/editor/common/slider-row.tsx | 52 + .../editor/panels/adjustments-panel.tsx | 1151 +++++++++++++++++ .../editor/panels/histogram-panel.tsx | 206 +++ 3 files changed, 1409 insertions(+) create mode 100644 apps/web/src/components/editor/common/slider-row.tsx create mode 100644 apps/web/src/components/editor/panels/adjustments-panel.tsx create mode 100644 apps/web/src/components/editor/panels/histogram-panel.tsx diff --git a/apps/web/src/components/editor/common/slider-row.tsx b/apps/web/src/components/editor/common/slider-row.tsx new file mode 100644 index 00000000..4c3b68f9 --- /dev/null +++ b/apps/web/src/components/editor/common/slider-row.tsx @@ -0,0 +1,52 @@ +// apps/web/src/components/editor/common/slider-row.tsx + +import { cn } from "@/lib/utils"; + +interface SliderRowProps { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; + className?: string; +} + +export function SliderRow({ + label, + value, + min, + max, + step = 1, + onChange, + className, +}: SliderRowProps) { + return ( +
+ {label} + onChange(Number(e.target.value))} + className="flex-1 h-1 accent-primary cursor-pointer" + /> + { + const v = Number(e.target.value); + if (!Number.isNaN(v)) { + onChange(Math.max(min, Math.min(max, v))); + } + }} + className="w-14 px-1 py-0.5 text-xs text-right bg-muted border border-border rounded text-foreground" + /> +
+ ); +} diff --git a/apps/web/src/components/editor/panels/adjustments-panel.tsx b/apps/web/src/components/editor/panels/adjustments-panel.tsx new file mode 100644 index 00000000..44880e17 --- /dev/null +++ b/apps/web/src/components/editor/panels/adjustments-panel.tsx @@ -0,0 +1,1151 @@ +// apps/web/src/components/editor/panels/adjustments-panel.tsx + +import { Wand2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { SliderRow } from "@/components/editor/common/slider-row"; +import { HistogramPanel } from "@/components/editor/panels/histogram-panel"; +import { cn } from "@/lib/utils"; +import { useEditorStore } from "@/stores/editor-store"; +import type { AdjustmentValues } from "@/types/editor"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEBOUNCE_MS = 150; + +const ADJUSTMENT_SLIDERS: { + key: keyof AdjustmentValues; + label: string; + min: number; + max: number; +}[] = [ + { key: "brightness", label: "Brightness", min: -100, max: 100 }, + { key: "contrast", label: "Contrast", min: -100, max: 100 }, + { key: "hue", label: "Hue", min: 0, max: 359 }, + { key: "saturation", label: "Saturation", min: -100, max: 100 }, + { key: "luminance", label: "Luminance", min: -100, max: 100 }, + { key: "exposure", label: "Exposure", min: -100, max: 100 }, + { key: "vibrance", label: "Vibrance", min: -100, max: 100 }, + { key: "warmth", label: "Warmth", min: -100, max: 100 }, +]; + +const TOGGLE_FILTERS = ["grayscale", "sepia", "invert", "solarize"]; + +const SLIDER_FILTERS: { + type: string; + label: string; + params: { key: string; label: string; min: number; max: number; step?: number }[]; +}[] = [ + { type: "blur", label: "Blur", params: [{ key: "radius", label: "Radius", min: 0, max: 40 }] }, + { + type: "sharpen", + label: "Sharpen", + params: [{ key: "amount", label: "Amount", min: 0, max: 100 }], + }, + { + type: "noise", + label: "Noise", + params: [{ key: "amount", label: "Amount", min: 0, max: 100 }], + }, + { + type: "pixelate", + label: "Pixelate", + params: [{ key: "size", label: "Size", min: 1, max: 50 }], + }, + { + type: "emboss", + label: "Emboss", + params: [{ key: "strength", label: "Strength", min: 0, max: 1, step: 0.01 }], + }, + { + type: "posterize", + label: "Posterize", + params: [{ key: "levels", label: "Levels", min: 2, max: 30 }], + }, + { + type: "threshold", + label: "Threshold", + params: [{ key: "level", label: "Level", min: 0, max: 1, step: 0.01 }], + }, + { + type: "kaleidoscope", + label: "Kaleidoscope", + params: [ + { key: "power", label: "Power", min: 2, max: 20 }, + { key: "angle", label: "Angle", min: 0, max: 360 }, + ], + }, +]; + +const BLUR_FILTERS: { + type: string; + label: string; + params: { key: string; label: string; min: number; max: number; step?: number }[]; +}[] = [ + { + type: "motionBlur", + label: "Motion Blur", + params: [ + { key: "angle", label: "Angle", min: 0, max: 360 }, + { key: "distance", label: "Distance", min: 0, max: 100 }, + ], + }, + { + type: "radialBlur", + label: "Radial Blur", + params: [ + { key: "amount", label: "Amount", min: 0, max: 100 }, + { key: "centerX", label: "Center X", min: 0, max: 1, step: 0.01 }, + { key: "centerY", label: "Center Y", min: 0, max: 1, step: 0.01 }, + ], + }, + { + type: "surfaceBlur", + label: "Surface Blur", + params: [ + { key: "radius", label: "Radius", min: 0, max: 40 }, + { key: "threshold", label: "Threshold", min: 0, max: 255 }, + ], + }, +]; + +const VIGNETTE_PARAMS: { + key: string; + label: string; + min: number; + max: number; +}[] = [ + { key: "amount", label: "Amount", min: -100, max: 100 }, + { key: "midpoint", label: "Midpoint", min: 0, max: 100 }, + { key: "roundness", label: "Roundness", min: -100, max: 100 }, + { key: "feather", label: "Feather", min: 0, max: 100 }, +]; + +const GRAIN_PARAMS: { + key: string; + label: string; + min: number; + max: number; +}[] = [ + { key: "amount", label: "Amount", min: 0, max: 100 }, + { key: "size", label: "Size", min: 1, max: 100 }, + { key: "roughness", label: "Roughness", min: 0, max: 100 }, +]; + +type CurveChannel = "rgb" | "red" | "green" | "blue"; + +interface CurvePoint { + x: number; + y: number; +} + +type LevelsChannel = "rgb" | "red" | "green" | "blue"; + +interface LevelsValues { + blackPoint: number; + whitePoint: number; + gamma: number; + outBlack: number; + outWhite: number; +} + +const CURVE_PRESETS: Record = { + Linear: [ + { x: 0, y: 0 }, + { x: 255, y: 255 }, + ], + Darken: [ + { x: 0, y: 0 }, + { x: 128, y: 96 }, + { x: 255, y: 220 }, + ], + Lighten: [ + { x: 0, y: 35 }, + { x: 128, y: 160 }, + { x: 255, y: 255 }, + ], + "Increase Contrast": [ + { x: 0, y: 0 }, + { x: 64, y: 40 }, + { x: 192, y: 215 }, + { x: 255, y: 255 }, + ], + "Decrease Contrast": [ + { x: 0, y: 30 }, + { x: 64, y: 74 }, + { x: 192, y: 182 }, + { x: 255, y: 225 }, + ], + "Medium Contrast": [ + { x: 0, y: 0 }, + { x: 80, y: 55 }, + { x: 176, y: 200 }, + { x: 255, y: 255 }, + ], + "Strong Contrast": [ + { x: 0, y: 0 }, + { x: 64, y: 20 }, + { x: 192, y: 235 }, + { x: 255, y: 255 }, + ], + "Cross Process": [ + { x: 0, y: 12 }, + { x: 48, y: 68 }, + { x: 130, y: 150 }, + { x: 210, y: 230 }, + { x: 255, y: 248 }, + ], + Negative: [ + { x: 0, y: 255 }, + { x: 255, y: 0 }, + ], +}; + +const DEFAULT_LEVELS: Record = { + rgb: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, + red: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, + green: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, + blue: { blackPoint: 0, whitePoint: 255, gamma: 1, outBlack: 0, outWhite: 255 }, +}; + +// --------------------------------------------------------------------------- +// Cubic spline interpolation for curves +// --------------------------------------------------------------------------- + +function cubicSplineInterpolate(points: CurvePoint[]): number[] { + const lut = new Array(256).fill(0); + if (points.length < 2) { + for (let i = 0; i < 256; i++) lut[i] = i; + return lut; + } + + const sorted = [...points].sort((a, b) => a.x - b.x); + const n = sorted.length; + + if (n === 2) { + // Linear interpolation + const [p0, p1] = sorted; + const dx = p1.x - p0.x; + for (let i = 0; i < 256; i++) { + if (i <= p0.x) { + lut[i] = Math.round(p0.y); + } else if (i >= p1.x) { + lut[i] = Math.round(p1.y); + } else { + const t = (i - p0.x) / dx; + lut[i] = Math.round(p0.y + t * (p1.y - p0.y)); + } + lut[i] = Math.max(0, Math.min(255, lut[i])); + } + return lut; + } + + // Natural cubic spline + const xs = sorted.map((p) => p.x); + const ys = sorted.map((p) => p.y); + const h: number[] = []; + const alpha: number[] = [0]; + + for (let i = 0; i < n - 1; i++) { + h[i] = xs[i + 1] - xs[i]; + } + + for (let i = 1; i < n - 1; i++) { + alpha[i] = (3 / h[i]) * (ys[i + 1] - ys[i]) - (3 / h[i - 1]) * (ys[i] - ys[i - 1]); + } + + const c = new Array(n).fill(0); + const l = new Array(n).fill(1); + const mu = new Array(n).fill(0); + const z = new Array(n).fill(0); + + for (let i = 1; i < n - 1; i++) { + l[i] = 2 * (xs[i + 1] - xs[i - 1]) - h[i - 1] * mu[i - 1]; + mu[i] = h[i] / l[i]; + z[i] = (alpha[i] - h[i - 1] * z[i - 1]) / l[i]; + } + + const b = new Array(n).fill(0); + const d = new Array(n).fill(0); + + for (let j = n - 2; j >= 0; j--) { + c[j] = z[j] - mu[j] * c[j + 1]; + b[j] = (ys[j + 1] - ys[j]) / h[j] - (h[j] * (c[j + 1] + 2 * c[j])) / 3; + d[j] = (c[j + 1] - c[j]) / (3 * h[j]); + } + + for (let i = 0; i < 256; i++) { + if (i <= xs[0]) { + lut[i] = Math.round(ys[0]); + } else if (i >= xs[n - 1]) { + lut[i] = Math.round(ys[n - 1]); + } else { + let seg = 0; + for (let j = 0; j < n - 1; j++) { + if (i >= xs[j] && i <= xs[j + 1]) { + seg = j; + break; + } + } + const dx = i - xs[seg]; + lut[i] = Math.round(ys[seg] + b[seg] * dx + c[seg] * dx * dx + d[seg] * dx * dx * dx); + } + lut[i] = Math.max(0, Math.min(255, lut[i])); + } + + return lut; +} + +// --------------------------------------------------------------------------- +// Section Header component +// --------------------------------------------------------------------------- + +function SectionHeader({ title }: { title: string }) { + return ( +
+
+ + {title} + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Auto Adjustments Section +// --------------------------------------------------------------------------- + +function AutoAdjustmentsSection() { + const setAdjustment = useEditorStore((s) => s.setAdjustment); + + const handleAutoTone = useCallback(() => { + // Auto Tone: stretches tonal range per channel + // Implemented as a strong brightness/contrast push + setAdjustment("brightness", 10); + setAdjustment("contrast", 20); + }, [setAdjustment]); + + const handleAutoContrast = useCallback(() => { + setAdjustment("contrast", 30); + }, [setAdjustment]); + + const handleAutoColor = useCallback(() => { + // Neutralize color cast via warmth and saturation + setAdjustment("warmth", 0); + setAdjustment("saturation", 5); + }, [setAdjustment]); + + const handleAutoEnhance = useCallback(() => { + setAdjustment("brightness", 8); + setAdjustment("contrast", 15); + setAdjustment("vibrance", 20); + setAdjustment("saturation", 5); + }, [setAdjustment]); + + const buttons = [ + { label: "Auto Tone", onClick: handleAutoTone }, + { label: "Auto Contrast", onClick: handleAutoContrast }, + { label: "Auto Color", onClick: handleAutoColor }, + { label: "Auto Enhance", onClick: handleAutoEnhance }, + ]; + + return ( +
+ {buttons.map((btn) => ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Adjustments Sliders Section +// --------------------------------------------------------------------------- + +function AdjustmentsSlidersSection() { + const adjustments = useEditorStore((s) => s.adjustments); + const setAdjustment = useEditorStore((s) => s.setAdjustment); + const debounceRef = useRef>(null); + + const handleChange = useCallback( + (key: keyof AdjustmentValues, value: number) => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + setAdjustment(key, value); + }, DEBOUNCE_MS); + // Immediately set for responsive UI + setAdjustment(key, value); + }, + [setAdjustment], + ); + + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + return ( +
+ {ADJUSTMENT_SLIDERS.map(({ key, label, min, max }) => ( + handleChange(key, v)} + /> + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Levels Section +// --------------------------------------------------------------------------- + +function LevelsSection() { + const [channel, setChannel] = useState("rgb"); + const [levels, setLevels] = useState>(() => + JSON.parse(JSON.stringify(DEFAULT_LEVELS)), + ); + + const currentLevels = levels[channel]; + + const updateLevel = useCallback( + (key: keyof LevelsValues, value: number) => { + setLevels((prev) => ({ + ...prev, + [channel]: { ...prev[channel], [key]: value }, + })); + }, + [channel], + ); + + const handleAutoLevels = useCallback(() => { + setLevels((prev) => ({ + ...prev, + [channel]: { + ...prev[channel], + blackPoint: 10, + whitePoint: 245, + gamma: 1, + }, + })); + }, [channel]); + + const channelColors: Record = { + rgb: "text-foreground", + red: "text-red-400", + green: "text-green-400", + blue: "text-blue-400", + }; + + return ( +
+
+ + +
+ + + +
+ updateLevel("blackPoint", v)} + /> + updateLevel("gamma", v)} + /> + updateLevel("whitePoint", v)} + /> +
+ + + + updateLevel("outBlack", v)} + /> + updateLevel("outWhite", v)} + /> +
+ ); +} + +function LevelsHistogramDisplay({ + channel, + levels, +}: { + channel: LevelsChannel; + levels: LevelsValues; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const w = canvas.width; + const h = canvas.height; + ctx.clearRect(0, 0, w, h); + + // Background + ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; + ctx.fillRect(0, 0, w, h); + + // Draw levels curve preview + const channelColor = + channel === "red" + ? "rgba(239, 68, 68, 0.7)" + : channel === "green" + ? "rgba(34, 197, 94, 0.7)" + : channel === "blue" + ? "rgba(59, 130, 246, 0.7)" + : "rgba(200, 200, 200, 0.7)"; + + ctx.strokeStyle = channelColor; + ctx.lineWidth = 1.5; + ctx.beginPath(); + + for (let i = 0; i < w; i++) { + const input = (i / w) * 255; + const { blackPoint, whitePoint, gamma, outBlack, outWhite } = levels; + let output: number; + + if (input <= blackPoint) { + output = outBlack; + } else if (input >= whitePoint) { + output = outWhite; + } else { + const normalized = (input - blackPoint) / (whitePoint - blackPoint); + const gammaCorrected = normalized ** (1 / gamma); + output = gammaCorrected * (outWhite - outBlack) + outBlack; + } + + const y = h - (output / 255) * h; + if (i === 0) ctx.moveTo(i, y); + else ctx.lineTo(i, y); + } + + ctx.stroke(); + + // Black and white point markers + const bpX = (levels.blackPoint / 255) * w; + const wpX = (levels.whitePoint / 255) * w; + + ctx.fillStyle = "rgba(255, 255, 255, 0.8)"; + drawTriangle(ctx, bpX, h, 5, true); + drawTriangle(ctx, wpX, h, 5, true); + }, [channel, levels]); + + return ( + + ); +} + +function drawTriangle( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + up: boolean, +) { + ctx.beginPath(); + if (up) { + ctx.moveTo(x, y - size); + ctx.lineTo(x - size, y); + ctx.lineTo(x + size, y); + } else { + ctx.moveTo(x, y + size); + ctx.lineTo(x - size, y); + ctx.lineTo(x + size, y); + } + ctx.closePath(); + ctx.fill(); +} + +// --------------------------------------------------------------------------- +// Curves Section +// --------------------------------------------------------------------------- + +function CurvesSection() { + const [channel, setChannel] = useState("rgb"); + const [curves, setCurves] = useState>({ + rgb: [...CURVE_PRESETS.Linear], + red: [...CURVE_PRESETS.Linear], + green: [...CURVE_PRESETS.Linear], + blue: [...CURVE_PRESETS.Linear], + }); + const [preset, setPreset] = useState("Linear"); + const [draggingIndex, setDraggingIndex] = useState(null); + + const canvasRef = useRef(null); + const currentPoints = curves[channel]; + + const lut = useMemo(() => cubicSplineInterpolate(currentPoints), [currentPoints]); + + // Draw the curves graph + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const size = 200; + ctx.clearRect(0, 0, size, size); + + // Background + ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; + ctx.fillRect(0, 0, size, size); + + // Grid lines + ctx.strokeStyle = "rgba(128, 128, 128, 0.2)"; + ctx.lineWidth = 0.5; + for (let i = 1; i < 4; i++) { + const pos = (i / 4) * size; + ctx.beginPath(); + ctx.moveTo(pos, 0); + ctx.lineTo(pos, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, pos); + ctx.lineTo(size, pos); + ctx.stroke(); + } + + // Diagonal reference line + ctx.strokeStyle = "rgba(128, 128, 128, 0.3)"; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.moveTo(0, size); + ctx.lineTo(size, 0); + ctx.stroke(); + ctx.setLineDash([]); + + // Curve + const channelColor = + channel === "red" + ? "rgba(239, 68, 68, 1)" + : channel === "green" + ? "rgba(34, 197, 94, 1)" + : channel === "blue" + ? "rgba(59, 130, 246, 1)" + : "rgba(255, 255, 255, 0.9)"; + + ctx.strokeStyle = channelColor; + ctx.lineWidth = 2; + ctx.beginPath(); + + for (let i = 0; i < 256; i++) { + const x = (i / 255) * size; + const y = size - (lut[i] / 255) * size; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + // Control points + for (const pt of currentPoints) { + const px = (pt.x / 255) * size; + const py = size - (pt.y / 255) * size; + ctx.fillStyle = channelColor; + ctx.beginPath(); + ctx.arc(px, py, 4, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(255, 255, 255, 0.8)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + }, [lut, currentPoints, channel]); + + const getCanvasPos = useCallback((e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + const scaleX = 200 / rect.width; + const scaleY = 200 / rect.height; + const x = Math.round((((e.clientX - rect.left) * scaleX) / 200) * 255); + const y = Math.round((1 - ((e.clientY - rect.top) * scaleY) / 200) * 255); + return { + x: Math.max(0, Math.min(255, x)), + y: Math.max(0, Math.min(255, y)), + }; + }, []); + + const findNearPoint = useCallback( + (mx: number, my: number): number => { + const threshold = 12; + for (let i = 0; i < currentPoints.length; i++) { + const dx = currentPoints[i].x - mx; + const dy = currentPoints[i].y - my; + if (Math.sqrt(dx * dx + dy * dy) < threshold) { + return i; + } + } + return -1; + }, + [currentPoints], + ); + + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + const pos = getCanvasPos(e); + const idx = findNearPoint(pos.x, pos.y); + + if (idx >= 0) { + setDraggingIndex(idx); + } else { + // Add new point + const newPoints = [...currentPoints, pos].sort((a, b) => a.x - b.x); + setCurves((prev) => ({ ...prev, [channel]: newPoints })); + setPreset("Custom"); + // Find and start dragging the new point + const newIdx = newPoints.findIndex((p) => p.x === pos.x && p.y === pos.y); + setDraggingIndex(newIdx); + } + }, + [getCanvasPos, findNearPoint, currentPoints, channel], + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (draggingIndex === null) return; + const pos = getCanvasPos(e); + + setCurves((prev) => { + const pts = [...prev[channel]]; + pts[draggingIndex] = pos; + pts.sort((a, b) => a.x - b.x); + return { ...prev, [channel]: pts }; + }); + }, + [draggingIndex, getCanvasPos, channel], + ); + + const handleMouseUp = useCallback(() => { + setDraggingIndex(null); + }, []); + + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const pos = getCanvasPos(e); + const idx = findNearPoint(pos.x, pos.y); + + if (idx >= 0 && currentPoints.length > 2) { + const newPoints = currentPoints.filter((_, i) => i !== idx); + setCurves((prev) => ({ ...prev, [channel]: newPoints })); + setPreset("Custom"); + } + }, + [getCanvasPos, findNearPoint, currentPoints, channel], + ); + + const handlePresetChange = useCallback( + (name: string) => { + setPreset(name); + const presetPoints = CURVE_PRESETS[name]; + if (presetPoints) { + setCurves((prev) => ({ + ...prev, + [channel]: presetPoints.map((p) => ({ ...p })), + })); + } + }, + [channel], + ); + + return ( +
+
+ + +
+ + + +

+ Click to add point. Drag to move. Double-click to remove. +

+
+ ); +} + +// --------------------------------------------------------------------------- +// Filters Section +// --------------------------------------------------------------------------- + +function ToggleFiltersSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + + return ( +
+ {TOGGLE_FILTERS.map((type) => { + const filter = filters.find((f) => f.type === type); + if (!filter) return null; + + return ( + + ); + })} +
+ ); +} + +function SliderFiltersSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + return ( +
+ {SLIDER_FILTERS.map(({ type, label, params }) => { + const filter = filters.find((f) => f.type === type); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + params.map((p) => ( + setFilterParam(type, p.key, v)} + /> + ))} +
+ ); + })} +
+ ); +} + +function AdditionalBlursSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + return ( +
+ {BLUR_FILTERS.map(({ type, label, params }) => { + const filter = filters.find((f) => f.type === type); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + params.map((p) => ( + setFilterParam(type, p.key, v)} + /> + ))} +
+ ); + })} +
+ ); +} + +function VignetteSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + const filter = filters.find((f) => f.type === "vignette"); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + VIGNETTE_PARAMS.map((p) => ( + setFilterParam("vignette", p.key, v)} + /> + ))} +
+ ); +} + +function GrainSection() { + const filters = useEditorStore((s) => s.filters); + const toggleFilter = useEditorStore((s) => s.toggleFilter); + const setFilterParam = useEditorStore((s) => s.setFilterParam); + + const filter = filters.find((f) => f.type === "grain"); + if (!filter) return null; + + return ( +
+ + {filter.enabled && + GRAIN_PARAMS.map((p) => ( + setFilterParam("grain", p.key, v)} + /> + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Main Panel +// --------------------------------------------------------------------------- + +export function AdjustmentsPanel() { + const adjustments = useEditorStore((s) => s.adjustments); + const filters = useEditorStore((s) => s.filters); + const resetAdjustments = useEditorStore((s) => s.resetAdjustments); + + const hasChanges = useMemo(() => { + const hasAdjustmentChanges = Object.values(adjustments).some((v) => v !== 0); + const hasFilterChanges = filters.some((f) => f.enabled); + return hasAdjustmentChanges || hasFilterChanges; + }, [adjustments, filters]); + + const handleResetAll = useCallback(() => { + resetAdjustments(); + // Reset all filters by toggling off any enabled ones + const store = useEditorStore.getState(); + for (const f of store.filters) { + if (f.enabled) { + store.toggleFilter(f.type); + } + } + }, [resetAdjustments]); + + const handleApply = useCallback(() => { + // Bake adjustments and filters into pixel data + // For now, mark dirty and reset adjustment values + // The actual baking happens in the canvas rendering pipeline + const store = useEditorStore.getState(); + store.markDirty(); + resetAdjustments(); + for (const f of store.filters) { + if (f.enabled) { + store.toggleFilter(f.type); + } + } + }, [resetAdjustments]); + + return ( +
+ {/* Histogram */} + + + {/* Auto Adjustments */} + + + + {/* Adjustment Sliders */} + + + + {/* Levels */} + + + + {/* Curves */} + + + + {/* Filters */} + + + + + {/* Additional Blurs */} + + + + {/* Vignette & Grain */} + + + + + {/* Action Buttons */} +
+ + +
+
+ ); +} diff --git a/apps/web/src/components/editor/panels/histogram-panel.tsx b/apps/web/src/components/editor/panels/histogram-panel.tsx new file mode 100644 index 00000000..966be916 --- /dev/null +++ b/apps/web/src/components/editor/panels/histogram-panel.tsx @@ -0,0 +1,206 @@ +// apps/web/src/components/editor/panels/histogram-panel.tsx + +import { useCallback, useEffect, useRef, useState } from "react"; + +const HIST_WIDTH = 256; +const HIST_HEIGHT = 80; +const DEBOUNCE_MS = 300; + +interface HistogramStats { + mean: [number, number, number]; + stdDev: [number, number, number]; + median: [number, number, number]; +} + +function computeHistogram(imageData: ImageData) { + const rBins = new Uint32Array(256); + const gBins = new Uint32Array(256); + const bBins = new Uint32Array(256); + const { data } = imageData; + + for (let i = 0; i < data.length; i += 4) { + rBins[data[i]]++; + gBins[data[i + 1]]++; + bBins[data[i + 2]]++; + } + + return { rBins, gBins, bBins }; +} + +function computeStats(rBins: Uint32Array, gBins: Uint32Array, bBins: Uint32Array): HistogramStats { + const channelStats = (bins: Uint32Array): [number, number, number] => { + let total = 0; + let sum = 0; + let sumSq = 0; + + for (let i = 0; i < 256; i++) { + total += bins[i]; + sum += i * bins[i]; + sumSq += i * i * bins[i]; + } + + if (total === 0) return [0, 0, 0]; + + const mean = sum / total; + const variance = sumSq / total - mean * mean; + const stdDev = Math.sqrt(Math.max(0, variance)); + + // Median + let cumulative = 0; + const half = total / 2; + let median = 0; + for (let i = 0; i < 256; i++) { + cumulative += bins[i]; + if (cumulative >= half) { + median = i; + break; + } + } + + return [Math.round(mean * 10) / 10, Math.round(stdDev * 10) / 10, median]; + }; + + const [rMean, rStdDev, rMedian] = channelStats(rBins); + const [gMean, gStdDev, gMedian] = channelStats(gBins); + const [bMean, bStdDev, bMedian] = channelStats(bBins); + + return { + mean: [rMean, gMean, bMean], + stdDev: [rStdDev, gStdDev, bStdDev], + median: [rMedian, gMedian, bMedian], + }; +} + +function drawChannel( + ctx: CanvasRenderingContext2D, + bins: Uint32Array, + maxVal: number, + color: string, +) { + if (maxVal === 0) return; + + ctx.fillStyle = color; + ctx.globalAlpha = 0.4; + ctx.beginPath(); + ctx.moveTo(0, HIST_HEIGHT); + + for (let i = 0; i < 256; i++) { + const h = (bins[i] / maxVal) * HIST_HEIGHT; + ctx.lineTo(i, HIST_HEIGHT - h); + } + + ctx.lineTo(255, HIST_HEIGHT); + ctx.closePath(); + ctx.fill(); + + ctx.strokeStyle = color; + ctx.globalAlpha = 0.7; + ctx.lineWidth = 1; + ctx.beginPath(); + + for (let i = 0; i < 256; i++) { + const h = (bins[i] / maxVal) * HIST_HEIGHT; + if (i === 0) { + ctx.moveTo(i, HIST_HEIGHT - h); + } else { + ctx.lineTo(i, HIST_HEIGHT - h); + } + } + + ctx.stroke(); + ctx.globalAlpha = 1; +} + +interface HistogramPanelProps { + stageRef?: React.RefObject; + imageData?: ImageData | null; +} + +export function HistogramPanel({ imageData }: HistogramPanelProps) { + const canvasRef = useRef(null); + const [stats, setStats] = useState(null); + const debounceRef = useRef>(null); + + const renderHistogram = useCallback((data: ImageData) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const { rBins, gBins, bBins } = computeHistogram(data); + const newStats = computeStats(rBins, gBins, bBins); + setStats(newStats); + + // Find max for normalization + let maxVal = 0; + for (let i = 0; i < 256; i++) { + maxVal = Math.max(maxVal, rBins[i], gBins[i], bBins[i]); + } + + ctx.clearRect(0, 0, HIST_WIDTH, HIST_HEIGHT); + + // Background + ctx.fillStyle = "rgba(0, 0, 0, 0.15)"; + ctx.fillRect(0, 0, HIST_WIDTH, HIST_HEIGHT); + + drawChannel(ctx, rBins, maxVal, "rgba(239, 68, 68, 1)"); + drawChannel(ctx, gBins, maxVal, "rgba(34, 197, 94, 1)"); + drawChannel(ctx, bBins, maxVal, "rgba(59, 130, 246, 1)"); + }, []); + + useEffect(() => { + if (!imageData) return; + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + renderHistogram(imageData); + }, DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [imageData, renderHistogram]); + + return ( +
+ + {stats && ( +
+
+ Mean +
+ {stats.mean[0]}{" "} + {stats.mean[1]}{" "} + {stats.mean[2]} +
+
+ StdDev +
+ {stats.stdDev[0]}{" "} + {stats.stdDev[1]}{" "} + {stats.stdDev[2]} +
+
+ Median +
+ {stats.median[0]}{" "} + {stats.median[1]}{" "} + {stats.median[2]} +
+
+ )} +
+ ); +}