RGB channel splitting with depth-of-field blur in a single WebGL pass. The image (a bundled poster or any dropped file) is sampled three times per fragment -- red shifted forward along the split direction, blue mirrored, green unshifted -- and each sample is optionally blurred by a 16-tap golden-angle Poisson disc whose radius grows with distance from a draggable focus point (circle-of-confusion model with adjustable range and aperture falloff). A per-frame hash rotation on the disc prevents banding, and luminosity film grain finishes the look. Linear mode fixes the split angle; radial mode derives it from the fragment's direction to the focus point, scaling intensity by distance.
Chromatic
WebGL · drag to split · drop an image
Source
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import ScrambleLink from "../ScrambleLink";
import DraggablePanel from "../DraggablePanel";
import { PanelSelect, PanelToggle } from "../_kansei/controls";
// Chromatic aberration playground (after the RGB-split lens tools): the image
// is decomposed into R/G/B layers offset along a direction, run through a
// focus-point lens blur (poisson disc, radius grows away from focus), and
// finished with luminosity film grain. Drag to aim the split, move the cursor
// to pull focus, drop in your own image. Default plate is a bundled walking
// silhouette (Fredrik Öhlander on Unsplash, unsplash.com/photos/ETpNY3AoDMo);
// a painted GIRAGIRA poster covers the frame until it resolves.
const INK = "#17121a";
const PRESET_SRC = "/lab/chromatic-walk.jpg";
// FontFace-registered copy of KT Flux so the 2D texture canvas can use it.
const TEX_FONT = "KT Flux Chromatic";
const VERT = `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);
gl_Position = vec4(a_pos, 0.0, 1.0);
}`;
const FRAG = `
precision highp float;
uniform sampler2D u_tex;
uniform vec2 u_resolution;
uniform vec2 u_focus;
uniform float u_maxDist;
uniform float u_range;
uniform float u_falloff;
uniform float u_maxBlur;
uniform float u_amount;
uniform float u_angle;
uniform float u_mode;
uniform float u_grain;
uniform float u_time;
varying vec2 v_uv;
float hash21(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main() {
vec2 uv = v_uv;
float aspect = u_resolution.x / u_resolution.y;
vec2 p = uv - u_focus;
p.x *= aspect;
// Normalised against the plate half-diagonal so Focus range spans the
// composition, not the letterboxed canvas.
float d = clamp(length(p) / u_maxDist, 0.0, 1.0);
// Circle of confusion: sharp inside u_range, ramps to u_maxBlur px at the
// frame corner; u_falloff is the aperture curve.
float coc = u_maxBlur * pow(smoothstep(u_range, 1.0, d), u_falloff);
vec2 cocUv = vec2(coc) / u_resolution;
// Per-channel offsets in pixels: R forward, B mirrored, G holds the plate.
vec2 dir;
float amt = u_amount;
if (u_mode < 0.5) {
dir = vec2(cos(u_angle), sin(u_angle));
} else {
dir = (length(p) > 0.001) ? normalize(p) : vec2(0.0);
amt *= d * 1.6;
}
vec2 off = dir * amt / u_resolution;
vec2 uvR = uv + off;
vec2 uvB = uv - off;
// In-focus pixels skip the disc entirely — one read per channel.
vec3 color;
if (coc < 0.75) {
color = vec3(texture2D(u_tex, uvR).r, texture2D(u_tex, uv).g, texture2D(u_tex, uvB).b);
} else {
// Golden-angle spiral disc, rotated per pixel so undersampling reads as
// noise instead of banding (the grain hides the rest).
float rot = hash21(gl_FragCoord.xy) * 6.2831853;
vec3 acc = vec3(0.0);
for (int i = 0; i < 16; i++) {
float fi = float(i);
float r = sqrt((fi + 0.5) / 16.0);
float th = fi * 2.39996323 + rot;
vec2 disc = vec2(cos(th), sin(th)) * r * cocUv;
acc.r += texture2D(u_tex, uvR + disc).r;
acc.g += texture2D(u_tex, uv + disc).g;
acc.b += texture2D(u_tex, uvB + disc).b;
}
color = acc / 16.0;
}
// Film grain, luminosity mode: one monochrome value nudges all channels.
float n = hash21(gl_FragCoord.xy + vec2(fract(u_time * 0.73) * 917.0, fract(u_time * 1.31) * 533.0));
color += (n - 0.5) * u_grain;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}`;
const DEFAULTS = { amount: 0.16, angle: -25, maxBlur: 48, range: 0.18, falloff: 1.6, grain: 0.08 };
const DRIFT_SPEED = 0.21; // rad/s, slow orbit of the split angle
// The effect is soft by nature — rendering at 1x instead of retina quarters
// the fill cost and the blur/grain hide the difference.
const RENDER_DPR = 1;
type SplitMode = "linear" | "radial";
function roundedClip(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
ctx.clip();
}
interface PlateRect {
x: number;
y: number;
w: number;
h: number;
}
// Default plate: vivid gradient poster with the wordmark in silhouette ink —
// bright field on black so the split channels glow like gels. Returns the
// painted rect so the focus pad can map onto the plate, not the letterbox.
function paintPoster(ctx: CanvasRenderingContext2D, w: number, h: number, dpr: number): PlateRect {
ctx.fillStyle = "#050505";
ctx.fillRect(0, 0, w, h);
const ph = h * 0.72;
const pw = Math.min(w * 0.62, ph * 0.82);
const px = (w - pw) / 2;
const py = (h - ph) / 2;
ctx.save();
roundedClip(ctx, px, py, pw, ph, 18 * dpr);
const base = ctx.createLinearGradient(px, 0, px + pw, 0);
base.addColorStop(0, "#ff9a1f");
base.addColorStop(0.45, "#ff4468");
base.addColorStop(0.78, "#a43bff");
base.addColorStop(1, "#5d2bff");
ctx.fillStyle = base;
ctx.fillRect(px, py, pw, ph);
const gx = px + pw * 0.5;
const gy = py + ph * 0.42;
const glow = ctx.createRadialGradient(gx, gy, 0, gx, gy, pw * 0.58);
glow.addColorStop(0, "rgba(255,240,235,0.95)");
glow.addColorStop(0.45, "rgba(255,170,190,0.5)");
glow.addColorStop(1, "rgba(255,170,190,0)");
ctx.fillStyle = glow;
ctx.fillRect(px, py, pw, ph);
const floor = ctx.createLinearGradient(0, py + ph, 0, py + ph * 0.6);
floor.addColorStop(0, "rgba(70,0,45,0.55)");
floor.addColorStop(1, "rgba(70,0,45,0)");
ctx.fillStyle = floor;
ctx.fillRect(px, py, pw, ph);
// Registration crosses give the blur and split something fine to bite on.
const step = 56 * dpr;
const arm = 4 * dpr;
ctx.strokeStyle = INK;
ctx.lineWidth = Math.max(1, dpr * 0.9);
ctx.globalAlpha = 0.16;
for (let y = py + step * 0.5; y < py + ph; y += step) {
for (let x = px + step * 0.5; x < px + pw; x += step) {
ctx.beginPath();
ctx.moveTo(x - arm, y);
ctx.lineTo(x + arm, y);
ctx.moveTo(x, y - arm);
ctx.lineTo(x, y + arm);
ctx.stroke();
}
}
ctx.globalAlpha = 1;
const word = "GIRAGIRA";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = `600 100px "${TEX_FONT}", sans-serif`;
const at100 = ctx.measureText(word).width;
let size = (pw * 0.86 * 100) / Math.max(1, at100);
ctx.font = `600 ${size}px "${TEX_FONT}", sans-serif`;
// Re-measure at the final size: if the face resolved between the two
// measures the first fit is off — rescale so the word never overflows.
const atSize = ctx.measureText(word).width;
if (atSize > pw * 0.86) {
size *= (pw * 0.86) / atSize;
ctx.font = `600 ${size}px "${TEX_FONT}", sans-serif`;
}
ctx.fillStyle = INK;
ctx.fillText(word, px + pw / 2, py + ph * 0.5 - size * 0.06);
const spaced = ctx as CanvasRenderingContext2D & { letterSpacing: string };
if ("letterSpacing" in ctx) spaced.letterSpacing = `${3 * dpr}px`;
ctx.font = `500 ${10 * dpr}px "${TEX_FONT}", sans-serif`;
ctx.fillText("RGB SPLIT — TEST SHEET 01", px + pw / 2, py + ph * 0.5 + size * 0.52);
if ("letterSpacing" in ctx) spaced.letterSpacing = "0px";
ctx.restore();
return { x: px, y: py, w: pw, h: ph };
}
function paintImage(ctx: CanvasRenderingContext2D, w: number, h: number, img: HTMLImageElement): PlateRect {
ctx.fillStyle = "#050505";
ctx.fillRect(0, 0, w, h);
const fit = Math.min((w * 0.74) / img.naturalWidth, (h * 0.74) / img.naturalHeight);
const dw = img.naturalWidth * fit;
const dh = img.naturalHeight * fit;
const dx = (w - dw) / 2;
const dy = (h - dh) / 2;
ctx.save();
roundedClip(ctx, dx, dy, dw, dh, Math.min(dw, dh) * 0.03);
ctx.drawImage(img, dx, dy, dw, dh);
ctx.restore();
return { x: dx, y: dy, w: dw, h: dh };
}
// Focus-point picker after the reference tool: a dot-grid pad with a
// draggable white dot; position maps straight to the blur focus in UV space.
function PanelFocusPad({ x, y, set }: { x: number; y: number; set: (x: number, y: number) => void }) {
const padRef = useRef<HTMLDivElement>(null);
const apply = (e: React.PointerEvent) => {
const r = padRef.current!.getBoundingClientRect();
set(
Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)),
Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
);
};
return (
<div style={{ marginBottom: 12 }}>
<div style={{
fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase",
color: "rgba(255,255,255,0.5)", fontVariationSettings: "'wght' 300, 'SRIF' 100",
marginBottom: 4,
}}>
Focus point
</div>
<div
ref={padRef}
onPointerDown={(e) => {
try {
(e.target as HTMLElement).setPointerCapture(e.pointerId);
} catch {}
apply(e);
}}
onPointerMove={(e) => {
if (e.buttons & 1) apply(e);
}}
style={{
position: "relative", height: 90, borderRadius: 8, cursor: "crosshair",
border: "1px solid rgba(255,255,255,0.15)", touchAction: "none",
backgroundImage: "radial-gradient(rgba(255,255,255,0.28) 1px, transparent 1.4px)",
backgroundSize: "13px 13px", backgroundPosition: "center",
}}
>
<div style={{
position: "absolute", left: `${x * 100}%`, top: `${y * 100}%`,
width: 12, height: 12, borderRadius: 999, background: "#fff",
transform: "translate(-50%, -50%)", boxShadow: "0 0 6px rgba(255,255,255,0.6)",
pointerEvents: "none",
}} />
</div>
</div>
);
}
// Full-width action row matching the PanelToggle look (scene is always dark).
function PanelButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{
width: "100%", marginBottom: 12, padding: "6px 0", borderRadius: 6, cursor: "pointer",
border: "1px solid rgba(255,255,255,0.2)", background: "transparent", color: "#fff",
fontSize: 9, letterSpacing: "0.06em", textTransform: "uppercase",
fontFamily: "var(--font-flux), sans-serif",
}}
>
{label}
</button>
);
}
// No-WebGL fallback: static three-layer wordmark, screen-blended.
function CSSFallback() {
const layers: { color: string; dx: number; dy: number }[] = [
{ color: "#f00", dx: -14, dy: 6 },
{ color: "#0f0", dx: 0, dy: -8 },
{ color: "#00f", dx: 14, dy: 6 },
];
return (
<div className="absolute inset-0 flex items-center justify-center bg-black">
<div className="relative">
{layers.map(({ color, dx, dy }) => (
<span
key={color}
className="font-[family-name:var(--font-flux)] text-[11vw] leading-none"
style={{
fontVariationSettings: "'wght' 600, 'SRIF' 200",
color,
mixBlendMode: "screen",
position: color === "#f00" ? "relative" : "absolute",
inset: color === "#f00" ? undefined : 0,
transform: `translate(${dx}px, ${dy}px)`,
filter: "blur(1.5px)",
}}
>
GIRAGIRA
</span>
))}
</div>
</div>
);
}
export default function Chromatic() {
const [amount, setAmount] = useState(DEFAULTS.amount);
const [angle, setAngle] = useState(DEFAULTS.angle);
const [maxBlur, setMaxBlur] = useState(DEFAULTS.maxBlur);
const [range, setRange] = useState(DEFAULTS.range);
const [falloff, setFalloff] = useState(DEFAULTS.falloff);
const [grain, setGrain] = useState(DEFAULTS.grain);
const [mode, setMode] = useState<SplitMode>("linear");
const [drift, setDrift] = useState(true);
const [focus, setFocus] = useState({ x: 0.5, y: 0.45 });
const [renderMode, setRenderMode] = useState<"gl" | "css" | null>(null);
const [glError, setGlError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const canvasRef = useRef<HTMLCanvasElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const drawRef = useRef<(() => void) | null>(null);
const retexRef = useRef<(() => void) | null>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
// Pointer/drift state lives outside React — the rAF loop mutates it. The
// focus dot eases toward tx/ty in plate-relative coords, which the pad sets
// directly; draw() maps them onto the letterboxed plate via plateRef.
const animRef = useRef({ fx: 0.5, fy: 0.45, tx: 0.5, ty: 0.45, driftAngle: 0, dragging: false });
const plateRef = useRef({ x: 0, y: 0, w: 1, h: 1 });
const dirtyRef = useRef(true);
const paramsRef = useRef({ amount, angle, maxBlur, range, falloff, grain, mode, drift });
// Keep the imperative draw's snapshot of the controls current (synced after each render).
useEffect(() => {
paramsRef.current = { amount, angle, maxBlur, range, falloff, grain, mode, drift };
dirtyRef.current = true;
});
const loadFile = useCallback((file: File) => {
if (!file.type.startsWith("image/")) return;
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(url);
imageRef.current = img;
retexRef.current?.();
};
img.src = url;
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
let disposed = false;
let raf = 0;
let cleanup: (() => void) | null = null;
(async () => {
// preserveDrawingBuffer so Save PNG can read the frame back.
const gl = canvas.getContext("webgl", { antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: true }) as WebGLRenderingContext | null;
if (!gl) {
setGlError("No WebGL context available");
setRenderMode("css");
return;
}
const compile = (type: number, src: string, label: string) => {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(s) || "(empty)";
gl.deleteShader(s);
throw new Error(`${label}: ${log}`);
}
return s;
};
let vs: WebGLShader, fs: WebGLShader, prog: WebGLProgram;
try {
vs = compile(gl.VERTEX_SHADER, VERT, "VERT");
fs = compile(gl.FRAGMENT_SHADER, FRAG, "FRAG");
prog = gl.createProgram()!;
gl.attachShader(prog, vs);
gl.attachShader(prog, fs);
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error(`LINK: ${gl.getProgramInfoLog(prog) || "(empty)"}`);
}
} catch (err) {
setGlError(err instanceof Error ? err.message : String(err));
setRenderMode("css");
return;
}
gl.useProgram(prog);
const vbo = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const posLoc = gl.getAttribLocation(prog, "a_pos");
gl.enableVertexAttribArray(posLoc);
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
gl.uniform1i(gl.getUniformLocation(prog, "u_tex"), 0);
const locs = {
res: gl.getUniformLocation(prog, "u_resolution"),
focus: gl.getUniformLocation(prog, "u_focus"),
maxDist: gl.getUniformLocation(prog, "u_maxDist"),
range: gl.getUniformLocation(prog, "u_range"),
falloff: gl.getUniformLocation(prog, "u_falloff"),
maxBlur: gl.getUniformLocation(prog, "u_maxBlur"),
amount: gl.getUniformLocation(prog, "u_amount"),
angle: gl.getUniformLocation(prog, "u_angle"),
mode: gl.getUniformLocation(prog, "u_mode"),
grain: gl.getUniformLocation(prog, "u_grain"),
time: gl.getUniformLocation(prog, "u_time"),
};
// KT Flux for the poster plate — paints in a fallback sans if the file
// is unreachable.
try {
const face = new FontFace(TEX_FONT, "url(/fonts/KT-Flux-2_Variable.ttf)");
await face.load();
document.fonts.add(face);
// Detached canvases resolve newly added faces lazily — force resolution
// at the spec we paint with, or measureText falls back mid-paint.
await document.fonts.load(`600 100px "${TEX_FONT}"`);
} catch {}
if (disposed) return;
const tex = gl.createTexture()!;
const src = document.createElement("canvas");
const srcCtx = src.getContext("2d")!;
const retex = () => {
const img = imageRef.current;
const r = img
? paintImage(srcCtx, src.width, src.height, img)
: paintPoster(srcCtx, src.width, src.height, RENDER_DPR);
plateRef.current = { x: r.x / src.width, y: r.y / src.height, w: r.w / src.width, h: r.h / src.height };
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
dirtyRef.current = true;
};
const draw = () => {
const p = paramsRef.current;
const a = animRef.current;
const dpr = RENDER_DPR;
const minDim = Math.min(canvas.width, canvas.height);
const plate = plateRef.current;
const aspect = canvas.width / canvas.height;
gl.uniform2f(locs.focus, plate.x + a.fx * plate.w, plate.y + a.fy * plate.h);
gl.uniform1f(locs.maxDist, Math.hypot((plate.w / 2) * aspect, plate.h / 2));
gl.uniform1f(locs.range, p.range);
gl.uniform1f(locs.falloff, p.falloff);
gl.uniform1f(locs.maxBlur, p.maxBlur * dpr);
gl.uniform1f(locs.amount, p.amount * minDim * 0.18);
gl.uniform1f(locs.angle, ((p.angle * Math.PI) / 180) + a.driftAngle);
gl.uniform1f(locs.mode, p.mode === "linear" ? 0 : 1);
gl.uniform1f(locs.grain, p.grain);
gl.uniform1f(locs.time, performance.now() / 1000);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
};
const resize = () => {
const w = Math.max(1, Math.round(canvas.clientWidth * RENDER_DPR));
const h = Math.max(1, Math.round(canvas.clientHeight * RENDER_DPR));
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
}
src.width = w;
src.height = h;
gl.viewport(0, 0, w, h);
gl.uniform2f(locs.res, w, h);
retex();
};
drawRef.current = draw;
retexRef.current = retex;
setRenderMode("gl");
resize();
// Swap in the bundled plate once it resolves — unless an upload beat it.
const preset = new Image();
preset.onload = () => {
if (!disposed && !imageRef.current) {
imageRef.current = preset;
retex();
}
};
preset.src = PRESET_SRC;
// Demand-driven: a frame renders only when a control changed, focus is
// still easing, or drift is animating — otherwise the GPU stays idle.
let last = performance.now();
const loop = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
const p = paramsRef.current;
const a = animRef.current;
const focusMoving = Math.abs(a.tx - a.fx) > 0.0005 || Math.abs(a.ty - a.fy) > 0.0005;
if (focusMoving) {
a.fx += (a.tx - a.fx) * 0.08;
a.fy += (a.ty - a.fy) * 0.08;
}
const drifting = p.drift && !a.dragging;
if (drifting) a.driftAngle += dt * DRIFT_SPEED;
if (dirtyRef.current || focusMoving || drifting || a.dragging) {
dirtyRef.current = false;
draw();
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
const ro = new ResizeObserver(resize);
ro.observe(canvas);
cleanup = () => {
ro.disconnect();
drawRef.current = null;
retexRef.current = null;
gl.deleteTexture(tex);
gl.deleteBuffer(vbo);
gl.deleteProgram(prog);
gl.deleteShader(vs);
gl.deleteShader(fs);
};
})();
return () => {
disposed = true;
cancelAnimationFrame(raf);
cleanup?.();
};
}, []);
const applyDrag = useCallback((e: React.PointerEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const r = canvas.getBoundingClientRect();
const dx = e.clientX - r.left - r.width / 2;
const dy = e.clientY - r.top - r.height / 2;
setAngle(Math.round((Math.atan2(dy, dx) * 180) / Math.PI));
setAmount(Math.min(1, Math.hypot(dx, dy) / (Math.min(r.width, r.height) * 0.42)));
}, []);
const onPointerDown = (e: React.PointerEvent) => {
if (e.button !== 0 || renderMode !== "gl") return;
if ((e.target as HTMLElement).closest("a, button, input, .lab-panel")) return;
animRef.current.dragging = true;
animRef.current.driftAngle = 0; // drag takes over the effective angle
(e.target as HTMLElement).setPointerCapture(e.pointerId);
applyDrag(e);
};
const onPointerMove = (e: React.PointerEvent) => {
if (animRef.current.dragging) applyDrag(e);
};
const onFocusPad = useCallback((x: number, y: number) => {
setFocus({ x, y });
animRef.current.tx = x;
animRef.current.ty = y;
}, []);
const onPointerUp = () => {
animRef.current.dragging = false;
};
const savePng = () => {
const canvas = canvasRef.current;
if (!canvas) return;
drawRef.current?.();
canvas.toBlob((blob) => {
if (!blob) return;
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "chromatic.png";
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});
};
return (
<div
className="fixed inset-0 overflow-hidden select-none bg-black"
style={{ touchAction: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) loadFile(file);
}}
>
{renderMode !== "css" && <canvas ref={canvasRef} className="absolute inset-0 h-full w-full cursor-crosshair" />}
{renderMode === "css" && <CSSFallback />}
{dragOver && (
<div className="absolute inset-4 z-10 pointer-events-none rounded-2xl border border-dashed border-white/40 flex items-center justify-center">
<span className="text-[11px] uppercase tracking-[0.2em] text-white/70 font-[family-name:var(--font-flux)]">
Drop image
</span>
</div>
)}
{glError && (
<div className="absolute left-3 top-3 max-w-sm rounded border border-red-500/30 bg-black/80 px-3 py-2 z-10 font-[family-name:var(--font-geist-mono)]">
<p className="text-[9px] uppercase tracking-widest text-red-400">WebGL shader failed (CSS fallback)</p>
<p className="mt-1 text-[10px] leading-tight text-red-300/70">{glError}</p>
</div>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) loadFile(file);
e.target.value = "";
}}
/>
<div
className="fixed bottom-8 left-8 pointer-events-none z-10 font-[family-name:var(--font-flux)]"
style={{ fontVariationSettings: "'wght' 300, 'SRIF' 100" }}
>
<p className="text-[12px] tracking-[0.08em] uppercase text-white/80">Chromatic</p>
<p className="text-[12px] tracking-[0.08em] mt-1 text-white/50">WebGL · drag to split · drop an image</p>
</div>
<DraggablePanel
isLight
controls={[
{ label: "Amount", value: amount, set: setAmount, min: 0, max: 1, step: 0.01 },
{ label: "Angle", value: angle, set: (v) => setAngle(Math.round(v)), min: -180, max: 180, step: 1 },
{ label: "Max blur", value: maxBlur, set: (v) => setMaxBlur(Math.round(v)), min: 0, max: 80, step: 1 },
{ label: "Focus range", value: range, set: setRange, min: 0, max: 1, step: 0.01 },
{ label: "Aperture", value: falloff, set: setFalloff, min: 0.5, max: 4, step: 0.05 },
{ label: "Grain", value: grain, set: setGrain, min: 0, max: 0.4, step: 0.01 },
]}
>
<PanelFocusPad x={focus.x} y={focus.y} set={onFocusPad} />
<PanelSelect label="Mode" value={mode} options={["linear", "radial"] as const} set={setMode} isLight />
<PanelToggle label="Drift" value={drift} set={setDrift} isLight />
<PanelButton label="Upload image" onClick={() => fileRef.current?.click()} />
<PanelButton label="Save PNG" onClick={savePng} />
</DraggablePanel>
<div className="fixed top-8 left-8 z-10">
<ScrambleLink
from="NEMAWASHI LAB"
to="← BACK"
href="/lab"
className="text-[14px] tracking-[0.08em] uppercase pointer-events-auto font-[family-name:var(--font-flux)] text-white/70"
style={{ fontVariationSettings: "'wght' 600, 'SRIF' 400" }}
/>
</div>
</div>
);
}