mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'worktree-agent-a87ea474' into feat/image-editor
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