forked from syntaxbullet/aurorabot
feat: add item creation tools
This commit is contained in:
470
panel/src/pages/HueShifter.tsx
Normal file
470
panel/src/pages/HueShifter.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user