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 (

{label}

{`${value > 0 ? "+" : ""}${value}${unit}`}
{gradient && (
)} 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", )} />
); } // --------------------------------------------------------------------------- // HueShifter // --------------------------------------------------------------------------- export function HueShifter() { const [imageSrc, setImageSrc] = useState(null); const [imageFile, setImageFile] = useState(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(null); // glCanvasRef — WebGL result canvas (also the preview) const glCanvasRef = useRef(null); const glRef = useRef(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 (

Hue Shifter

Upload an image and shift its hue, saturation, and lightness to create colour variants. Fully transparent pixels are preserved.

{ 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", )} >

{dragOver ? "Drop to upload" : "Drop an image here"}

or click to browse · PNG, JPEG, WebP · max 15 MB

{ const f = e.target.files?.[0]; if (f) loadFile(f); }} className="hidden" />
); } // ── Editor screen ────────────────────────────────────────────────────────── return (
{/* Toolbar */}

Hue Shifter

{imageFile?.name}
{/* Controls */}
{/* Side-by-side */}

Original

Original

Result

{/* WebGL canvas always in DOM; hidden until image is ready */}
{!imageReady && (
)}
); }