forked from syntaxbullet/aurorabot
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
import { useState, useRef, useCallback, useEffect } from "react";
|
|
import { Upload, Download, X, ImageIcon } from "lucide-react";
|
|
import { cn } from "../lib/utils";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CHECKERBOARD: React.CSSProperties = {
|
|
backgroundImage: `
|
|
linear-gradient(45deg, #444 25%, transparent 25%),
|
|
linear-gradient(-45deg, #444 25%, transparent 25%),
|
|
linear-gradient(45deg, transparent 75%, #444 75%),
|
|
linear-gradient(-45deg, transparent 75%, #444 75%)
|
|
`,
|
|
backgroundSize: "16px 16px",
|
|
backgroundPosition: "0 0, 0 8px, 8px -8px, -8px 0px",
|
|
backgroundColor: "#2a2a2a",
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WebGL — shaders + init
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const VERT = `
|
|
attribute vec2 aPos;
|
|
varying vec2 vUV;
|
|
void main() {
|
|
vUV = aPos * 0.5 + 0.5;
|
|
vUV.y = 1.0 - vUV.y;
|
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
}`;
|
|
|
|
// RGB↔HSL math in GLSL — runs massively parallel on GPU
|
|
const FRAG = `
|
|
precision mediump float;
|
|
uniform sampler2D uImage;
|
|
uniform float uHueShift;
|
|
uniform float uSaturation;
|
|
uniform float uLightness;
|
|
varying vec2 vUV;
|
|
|
|
vec3 rgb2hsl(vec3 c) {
|
|
float maxC = max(c.r, max(c.g, c.b));
|
|
float minC = min(c.r, min(c.g, c.b));
|
|
float l = (maxC + minC) * 0.5;
|
|
float d = maxC - minC;
|
|
if (d < 0.001) return vec3(0.0, 0.0, l);
|
|
float s = l > 0.5 ? d / (2.0 - maxC - minC) : d / (maxC + minC);
|
|
float h;
|
|
if (maxC == c.r) h = (c.g - c.b) / d + (c.g < c.b ? 6.0 : 0.0);
|
|
else if (maxC == c.g) h = (c.b - c.r) / d + 2.0;
|
|
else h = (c.r - c.g) / d + 4.0;
|
|
return vec3(h / 6.0, s, l);
|
|
}
|
|
|
|
float hue2rgb(float p, float q, float t) {
|
|
if (t < 0.0) t += 1.0;
|
|
if (t > 1.0) t -= 1.0;
|
|
if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t;
|
|
if (t < 0.5) return q;
|
|
if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0;
|
|
return p;
|
|
}
|
|
|
|
vec3 hsl2rgb(vec3 hsl) {
|
|
float h = hsl.x, s = hsl.y, l = hsl.z;
|
|
if (s < 0.001) return vec3(l);
|
|
float q = l < 0.5 ? l * (1.0 + s) : l + s - l * s;
|
|
float p = 2.0 * l - q;
|
|
return vec3(
|
|
hue2rgb(p, q, h + 1.0 / 3.0),
|
|
hue2rgb(p, q, h),
|
|
hue2rgb(p, q, h - 1.0 / 3.0)
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
vec4 c = texture2D(uImage, vUV);
|
|
if (c.a < 0.001) { gl_FragColor = c; return; }
|
|
vec3 hsl = rgb2hsl(c.rgb);
|
|
hsl.x = fract(hsl.x + uHueShift + 1.0);
|
|
hsl.y = clamp(hsl.y + uSaturation, 0.0, 1.0);
|
|
hsl.z = clamp(hsl.z + uLightness, 0.0, 1.0);
|
|
gl_FragColor = vec4(hsl2rgb(hsl), c.a);
|
|
}`;
|
|
|
|
type GlState = {
|
|
gl: WebGLRenderingContext;
|
|
tex: WebGLTexture;
|
|
uHueShift: WebGLUniformLocation;
|
|
uSaturation: WebGLUniformLocation;
|
|
uLightness: WebGLUniformLocation;
|
|
};
|
|
|
|
function initGl(canvas: HTMLCanvasElement): GlState | null {
|
|
const gl = canvas.getContext("webgl", {
|
|
premultipliedAlpha: false,
|
|
preserveDrawingBuffer: true,
|
|
}) as WebGLRenderingContext | null;
|
|
if (!gl) return null;
|
|
|
|
const vert = gl.createShader(gl.VERTEX_SHADER)!;
|
|
gl.shaderSource(vert, VERT);
|
|
gl.compileShader(vert);
|
|
|
|
const frag = gl.createShader(gl.FRAGMENT_SHADER)!;
|
|
gl.shaderSource(frag, FRAG);
|
|
gl.compileShader(frag);
|
|
|
|
const prog = gl.createProgram()!;
|
|
gl.attachShader(prog, vert);
|
|
gl.attachShader(prog, frag);
|
|
gl.linkProgram(prog);
|
|
gl.useProgram(prog);
|
|
|
|
const buf = gl.createBuffer()!;
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
gl.bufferData(
|
|
gl.ARRAY_BUFFER,
|
|
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
|
|
gl.STATIC_DRAW,
|
|
);
|
|
const aPos = gl.getAttribLocation(prog, "aPos");
|
|
gl.enableVertexAttribArray(aPos);
|
|
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
|
|
|
const tex = gl.createTexture()!;
|
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
|
|
return {
|
|
gl,
|
|
tex,
|
|
uHueShift: gl.getUniformLocation(prog, "uHueShift")!,
|
|
uSaturation: gl.getUniformLocation(prog, "uSaturation")!,
|
|
uLightness: gl.getUniformLocation(prog, "uLightness")!,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Slider
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function Slider({
|
|
label, value, min, max, unit, onChange, gradient,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
unit: string;
|
|
onChange: (v: number) => void;
|
|
gradient?: string;
|
|
}) {
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-baseline">
|
|
<p className="text-xs font-medium text-text-secondary">{label}</p>
|
|
<span className="text-xs font-mono text-text-tertiary tabular-nums w-16 text-right">
|
|
{`${value > 0 ? "+" : ""}${value}${unit}`}
|
|
</span>
|
|
</div>
|
|
<div className="relative h-5 flex items-center">
|
|
{gradient && (
|
|
<div
|
|
className="absolute inset-x-0 h-2 rounded-full pointer-events-none"
|
|
style={{ background: gradient }}
|
|
/>
|
|
)}
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
value={value}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
className={cn(
|
|
"relative z-10 w-full appearance-none bg-transparent cursor-pointer",
|
|
"[&::-webkit-slider-thumb]:appearance-none",
|
|
"[&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
|
|
"[&::-webkit-slider-thumb]:-mt-1",
|
|
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white",
|
|
"[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-border",
|
|
"[&::-webkit-slider-thumb]:shadow-sm",
|
|
gradient
|
|
? "[&::-webkit-slider-runnable-track]:h-2 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-transparent"
|
|
: "accent-primary",
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HueShifter
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function HueShifter() {
|
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
|
const [imageFile, setImageFile] = useState<File | null>(null);
|
|
const [imageReady, setImageReady] = useState(false);
|
|
const [dragOver, setDragOver] = useState(false);
|
|
|
|
const [hueShift, setHueShift] = useState(0);
|
|
const [saturation, setSaturation] = useState(0);
|
|
const [lightness, setLightness] = useState(0);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
// glCanvasRef — WebGL result canvas (also the preview)
|
|
const glCanvasRef = useRef<HTMLCanvasElement>(null);
|
|
const glRef = useRef<GlState | null>(null);
|
|
|
|
// ── Load image → upload texture once ──────────────────────────────────────
|
|
useEffect(() => {
|
|
if (!imageSrc) return;
|
|
setImageReady(false);
|
|
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
const glCanvas = glCanvasRef.current;
|
|
if (!glCanvas) return;
|
|
glCanvas.width = img.naturalWidth;
|
|
glCanvas.height = img.naturalHeight;
|
|
|
|
if (!glRef.current) {
|
|
glRef.current = initGl(glCanvas);
|
|
if (!glRef.current) {
|
|
console.error("HueShifter: WebGL not supported");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const { gl, tex } = glRef.current;
|
|
gl.bindTexture(gl.TEXTURE_2D, tex);
|
|
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
|
|
gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
|
|
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
|
|
gl.viewport(0, 0, img.naturalWidth, img.naturalHeight);
|
|
|
|
setImageReady(true);
|
|
};
|
|
img.src = imageSrc;
|
|
}, [imageSrc]);
|
|
|
|
// ── Re-render on GPU whenever sliders change ───────────────────────────────
|
|
useEffect(() => {
|
|
const state = glRef.current;
|
|
if (!state || !imageReady) return;
|
|
|
|
const { gl, uHueShift, uSaturation, uLightness } = state;
|
|
gl.uniform1f(uHueShift, hueShift / 360);
|
|
gl.uniform1f(uSaturation, saturation / 100);
|
|
gl.uniform1f(uLightness, lightness / 100);
|
|
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
}, [imageReady, hueShift, saturation, lightness]);
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
const loadFile = useCallback((file: File) => {
|
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
|
setImageSrc(URL.createObjectURL(file));
|
|
setImageFile(file);
|
|
setHueShift(0);
|
|
setSaturation(0);
|
|
setLightness(0);
|
|
}, [imageSrc]);
|
|
|
|
const clearAll = useCallback(() => {
|
|
if (imageSrc) URL.revokeObjectURL(imageSrc);
|
|
setImageSrc(null);
|
|
setImageFile(null);
|
|
setImageReady(false);
|
|
glRef.current = null;
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}, [imageSrc]);
|
|
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
setDragOver(false);
|
|
const file = e.dataTransfer.files[0];
|
|
if (file?.type.startsWith("image/")) loadFile(file);
|
|
};
|
|
|
|
const handleDownload = () => {
|
|
const glCanvas = glCanvasRef.current;
|
|
if (!glCanvas || !imageFile || !imageReady) return;
|
|
glCanvas.toBlob((blob) => {
|
|
if (!blob) return;
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.download = imageFile.name.replace(/\.[^.]+$/, "") + "_recolored.png";
|
|
a.href = url;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}, "image/png");
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setHueShift(0);
|
|
setSaturation(0);
|
|
setLightness(0);
|
|
};
|
|
|
|
const isDefault = hueShift === 0 && saturation === 0 && lightness === 0;
|
|
|
|
// ── Upload screen ──────────────────────────────────────────────────────────
|
|
if (!imageSrc) {
|
|
return (
|
|
<div className="max-w-2xl mx-auto py-8 space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-foreground mb-1">Hue Shifter</h2>
|
|
<p className="text-sm text-text-secondary">
|
|
Upload an image and shift its hue, saturation, and lightness to create colour
|
|
variants. Fully transparent pixels are preserved.
|
|
</p>
|
|
</div>
|
|
|
|
<div
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className={cn(
|
|
"border-2 border-dashed rounded-xl p-16 flex flex-col items-center gap-4 cursor-pointer transition-all select-none",
|
|
dragOver
|
|
? "border-primary bg-primary/5"
|
|
: "border-border hover:border-primary/50 hover:bg-primary/3",
|
|
)}
|
|
>
|
|
<Upload
|
|
className={cn(
|
|
"w-10 h-10 transition-colors",
|
|
dragOver ? "text-primary" : "text-text-tertiary",
|
|
)}
|
|
/>
|
|
<div className="text-center">
|
|
<p className="text-sm font-medium text-text-secondary">
|
|
{dragOver ? "Drop to upload" : "Drop an image here"}
|
|
</p>
|
|
<p className="text-xs text-text-tertiary mt-1">
|
|
or click to browse · PNG, JPEG, WebP · max 15 MB
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) loadFile(f); }}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Editor screen ──────────────────────────────────────────────────────────
|
|
return (
|
|
<div className="space-y-4 pb-8">
|
|
{/* Toolbar */}
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<h2 className="text-lg font-semibold text-foreground flex-1">Hue Shifter</h2>
|
|
<span className="text-xs text-text-tertiary font-mono truncate max-w-[200px]">
|
|
{imageFile?.name}
|
|
</span>
|
|
<button
|
|
onClick={handleReset}
|
|
disabled={isDefault}
|
|
className={cn(
|
|
"px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary transition-colors",
|
|
isDefault
|
|
? "opacity-40 cursor-not-allowed"
|
|
: "hover:text-foreground hover:border-primary/40",
|
|
)}
|
|
>
|
|
Reset
|
|
</button>
|
|
<button
|
|
onClick={clearAll}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-border text-xs text-text-tertiary",
|
|
"hover:text-destructive hover:border-destructive transition-colors",
|
|
)}
|
|
>
|
|
<X className="w-3.5 h-3.5" /> Clear
|
|
</button>
|
|
<button
|
|
onClick={handleDownload}
|
|
disabled={!imageReady || isDefault}
|
|
className={cn(
|
|
"flex items-center gap-1.5 px-4 py-1.5 rounded-md text-xs font-semibold transition-colors",
|
|
imageReady && !isDefault
|
|
? "bg-primary text-white hover:bg-primary/90"
|
|
: "bg-raised border border-border text-text-tertiary cursor-not-allowed",
|
|
)}
|
|
>
|
|
<Download className="w-3.5 h-3.5" /> Download PNG
|
|
</button>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="bg-card border border-border rounded-xl p-5 space-y-5">
|
|
<Slider
|
|
label="Hue Shift"
|
|
value={hueShift}
|
|
min={-180}
|
|
max={180}
|
|
unit="°"
|
|
onChange={setHueShift}
|
|
gradient="linear-gradient(to right, hsl(0,80%,55%), hsl(60,80%,55%), hsl(120,80%,55%), hsl(180,80%,55%), hsl(240,80%,55%), hsl(300,80%,55%), hsl(360,80%,55%))"
|
|
/>
|
|
<Slider
|
|
label="Saturation"
|
|
value={saturation}
|
|
min={-100}
|
|
max={100}
|
|
unit="%"
|
|
onChange={setSaturation}
|
|
gradient="linear-gradient(to right, hsl(210,0%,50%), hsl(210,0%,55%) 50%, hsl(210,90%,55%))"
|
|
/>
|
|
<Slider
|
|
label="Lightness"
|
|
value={lightness}
|
|
min={-100}
|
|
max={100}
|
|
unit="%"
|
|
onChange={setLightness}
|
|
gradient="linear-gradient(to right, hsl(0,0%,10%), hsl(0,0%,50%) 50%, hsl(0,0%,92%))"
|
|
/>
|
|
</div>
|
|
|
|
{/* Side-by-side */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
|
Original
|
|
</p>
|
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
|
<div style={CHECKERBOARD}>
|
|
<img src={imageSrc} alt="Original" className="w-full block" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
|
Result
|
|
</p>
|
|
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
|
{/* WebGL canvas always in DOM; hidden until image is ready */}
|
|
<div style={CHECKERBOARD}>
|
|
<canvas
|
|
ref={glCanvasRef}
|
|
className={cn("w-full block", !imageReady && "hidden")}
|
|
/>
|
|
{!imageReady && (
|
|
<div className="aspect-square flex items-center justify-center">
|
|
<ImageIcon className="w-10 h-10 opacity-20 text-white" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|