import { previewUrl } from "@/config/assets";
const IMG_KEY = "dts/unwrapped/DTS_Unwrapped_by_FRANCO_DUPUY_19.jpg";
hold: [1e3, 300, 3e3, 50],
const VERT = `#version 300 es
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
const FRAG = `#version 300 es
uniform float uDev; // develop 0..1
p = fract(p * vec2(123.34, 456.21));
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
mix(hash(i), hash(i + vec2(1, 0)), u.x),
mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x),
// object-fit: cover crop.
float rc = uRes.x / uRes.y;
float ri = uImgRes.x / uImgRes.y;
if (rc > ri) { float s = ri / rc; uv.y = (uv.y - 0.5) * s + 0.5; }
else { float s = rc / ri; uv.x = (uv.x - 0.5) * s + 0.5; }
float gray = dot(texture(uImg, uv).rgb, vec3(0.299, 0.587, 0.114));
// The print: house 45-degree halftone screen, dots swelling in the darks.
mat2 R45 = mat2(0.7071, 0.7071, -0.7071, 0.7071);
vec2 hp = (R45 * (vUv * uRes)) / uPitch;
float cellD = length(fract(hp) - 0.5);
float dotR = mix(0.62, 0.06, gray);
float ht = smoothstep(dotR + 0.14, dotR - 0.14, cellD);
vec3 printed = mix(uPaper, uInk, ht);
float th = vnoise(vUv * vec2(6.0, 4.0) * (0.5 + uPatch));
// Rising edges: a point develops once uDev PASSES its noise threshold —
// the whole reveal is this one dither bloom, no directional wipe (tried a
// bath-line sweep; the pure blots read better). At uDev 0 every point sits
// below its threshold; at 1 every point has passed it.
// NB "patch" is a GLSL reserved word.
float developed = smoothstep(th - 0.22, th + 0.22, uDev * 1.5 - 0.25);
// Undeveloped paper carries a whisper of tooth so it reads as stock.
vec3 paper = uPaper + (hash(vUv * uRes * 1.013) - 0.5) * 0.02;
outColor = vec4(mix(paper, printed, developed), 1.0);
const s = document.createElement("style");
s.dataset.labStyle = "darkroom";
.dk { position: absolute; inset: 0; cursor: pointer; user-select: none; -webkit-user-drag: none; }
.dk__canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.dk__bar { position: absolute; inset-inline: 0; inset-block-start: 0; height: 3px; overflow: hidden; }
.dk__bar i { position: absolute; inset: 0; background: var(--ink); transform: translate3d(-100.5%, 0, 0); }
.dk__cap { position: absolute; inset-inline-start: var(--s-16); inset-block-end: var(--s-12);
font-family: var(--font-mono); font-size: var(--fs-mono-meta); letter-spacing: 0.06em;
color: var(--ink-muted); text-transform: uppercase; }
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const P = { ...DEFAULTS };
const wrap = document.createElement("div");
<canvas class="dk__canvas" aria-hidden="true"></canvas>
<div class="dk__bar" aria-hidden="true"><i></i></div>
<span class="dk__cap">HOLD::DEVELOP</span>`;
const canvas = wrap.querySelector(".dk__canvas");
const fill = wrap.querySelector(".dk__bar i");
const cap = wrap.querySelector(".dk__cap");
const gl = canvas.getContext("webgl2", { alpha: false, antialias: false });
if (!gl) return () => wrap.remove();
const s = gl.createShader(t);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS))
console.warn("[darkroom]", gl.getShaderInfoLog(s));
const prog = gl.createProgram();
gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG));
for (const n of ["uImg", "uRes", "uImgRes", "uDev", "uPitch", "uPatch", "uPaper", "uInk"])
u[n] = gl.getUniformLocation(prog, n);
const cs = getComputedStyle(document.documentElement);
const m = v.trim().match(/^#([0-9a-f]{6})$/i)?.[1];
if (!m) return [0.5, 0.5, 0.5];
return [0, 2, 4].map((i) => parseInt(m.slice(i, i + 2), 16) / 255);
return { paper: parse(cs.getPropertyValue("--paper")), ink: parse(cs.getPropertyValue("--ink")) };
img.crossOrigin = "anonymous";
img.src = previewUrl(IMG_KEY, 1440);
img.decode().then(() => {
imgH = img.naturalHeight;
const tex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
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);
const dpr = Math.min(devicePixelRatio || 1, 1.5);
const r = wrap.getBoundingClientRect();
canvas.width = Math.max(1, Math.round(r.width * dpr));
canvas.height = Math.max(1, Math.round(r.height * dpr));
gl.viewport(0, 0, canvas.width, canvas.height);
const ro = new ResizeObserver(size);
const { paper, ink } = inks();
gl.uniform2f(u.uRes, canvas.width, canvas.height);
gl.uniform2f(u.uImgRes, imgW, imgH);
gl.uniform1f(u.uDev, state.p);
gl.uniform1f(u.uPitch, P.pitch * Math.min(devicePixelRatio || 1, 1.5));
gl.uniform1f(u.uPatch, P.patch);
gl.uniform3fv(u.uPaper, paper);
gl.uniform3fv(u.uInk, ink);
gl.drawArrays(gl.TRIANGLES, 0, 3);
fill.style.transform = `translate3d(${-100.5 + p * 100.5}%, 0, 0)`;
const now = performance.now();
const dt = Math.min((now - last) / 1e3, 0.05);
state.p = Math.min(1, state.p + dt * 1e3 / P.hold);
const startTicker = () => {
last = performance.now();
const stopTicker = () => gsap.ticker.remove(tick);
cap.textContent = "FIXED::PRINT — CLICK::BLEACH";
gsap.to(fill, { xPercent: 201, duration: 1, ease: "expo.out" });
cap.textContent = "HOLD::DEVELOP";
gsap.set(fill, { xPercent: 0 });
gsap.killTweensOf(state);
wrap.addEventListener("pointerdown", down);
window.addEventListener("pointerup", up);
wrap.addEventListener("keydown", (e) => {
if (e.key === " " && !e.repeat) down(e);
wrap.addEventListener("keyup", (e) => {
gsap.killTweensOf([state, fill]);
window.removeEventListener("pointerup", up);
gl.getExtension("WEBGL_lose_context")?.loseContext();
if (key in P) P[key] = Number(value);
experiment.mount(document.querySelector(".stage"));