mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
This commit is contained in:
@@ -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 (
|
||||||
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
|
<span className="text-xs text-muted-foreground w-20 shrink-0 truncate">{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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<HTMLCanvasElement | null>;
|
||||||
|
imageData?: ImageData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HistogramPanel({ imageData }: HistogramPanelProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [stats, setStats] = useState<HistogramStats | null>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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 (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={HIST_WIDTH}
|
||||||
|
height={HIST_HEIGHT}
|
||||||
|
className="w-full rounded border border-border"
|
||||||
|
style={{ imageRendering: "pixelated" }}
|
||||||
|
/>
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-3 gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Mean</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-red-400">{stats.mean[0]}</span>{" "}
|
||||||
|
<span className="text-green-400">{stats.mean[1]}</span>{" "}
|
||||||
|
<span className="text-blue-400">{stats.mean[2]}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">StdDev</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-red-400">{stats.stdDev[0]}</span>{" "}
|
||||||
|
<span className="text-green-400">{stats.stdDev[1]}</span>{" "}
|
||||||
|
<span className="text-blue-400">{stats.stdDev[2]}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">Median</span>
|
||||||
|
<br />
|
||||||
|
<span className="text-red-400">{stats.median[0]}</span>{" "}
|
||||||
|
<span className="text-green-400">{stats.median[1]}</span>{" "}
|
||||||
|
<span className="text-blue-400">{stats.median[2]}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user