) => {
+ 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]}
+
+
+ )}
+
+ );
+}