Files
discord-rpg-concept/panel/src/pages/HueShifter.tsx
2026-02-19 14:40:22 +01:00

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>
);
}