Disc picker

A channel picker where each channel is a spinning CD: slideshow label art under a groove-and-glint shader, held still on hover, all on one WebGL context.

Show code
disc-picker.tsx
"use client";

import { useEffect, useId, useRef, type ReactNode } from "react";
import { prefetchPrioritized } from "@/components/wordminders/disc-picker/disc-art";
import { registerDisc, type DiscHandle } from "@/components/wordminders/disc-picker/disc-gl";
import { cn } from "@/lib/utils";

// A channel picker where every channel is a spinning CD. Each disc's label is
// a slideshow of the channel's own images, cross-fading every 3.8s, under a
// groove shader with a travelling glint. Hover or focus a disc and it stops,
// the way a finger stops a record, so the art you're reading holds still.

export type DiscChannel = {
  id: string;
  title: string;
  /** Image URLs for the label slideshow, in order. Cross-origin art needs CORS. */
  slides: string[];
  /** ISO date, shown as "Updated Mar. 4, 2026". */
  updatedAt?: string;
};

const MONTHS = ["Jan.", "Feb.", "Mar.", "Apr.", "May", "Jun.", "Jul.", "Aug.", "Sep.", "Oct.", "Nov.", "Dec."];

function formatUpdated(iso?: string) {
  if (!iso) return "";
  const d = new Date(iso);
  if (Number.isNaN(d.getTime())) return "";
  return `Updated ${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}

function prefersReducedMotion() {
  return typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

function Disc({
  channel,
  index,
  active,
  paused,
  onSelect,
  renderMeta,
}: {
  channel: DiscChannel;
  index: number;
  active: boolean;
  paused: boolean;
  onSelect?: (id: string) => void;
  renderMeta?: (channel: DiscChannel, active: boolean) => ReactNode;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const handleRef = useRef<DiscHandle | null>(null);
  const pausedRef = useRef(paused);
  const updated = formatUpdated(channel.updatedAt);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const handle: DiscHandle = {
      canvas,
      title: channel.title,
      slides: channel.slides,
      index,
      held: false,
      still: pausedRef.current || prefersReducedMotion(),
      visible: false,
    };
    handleRef.current = handle;
    const unregister = registerDisc(handle);

    // Offscreen discs cost nothing: the shared loop skips them.
    const io = new IntersectionObserver(([entry]) => {
      handle.visible = entry?.isIntersecting ?? false;
    });
    io.observe(canvas);
    const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onMotion = () => {
      handle.still = pausedRef.current || motion.matches;
    };
    motion.addEventListener("change", onMotion);

    return () => {
      io.disconnect();
      motion.removeEventListener("change", onMotion);
      unregister();
      handleRef.current = null;
    };
  }, [channel.title, channel.slides, index]);

  // Pausing freezes the disc where it is; it doesn't re-register or reset.
  useEffect(() => {
    pausedRef.current = paused;
    if (handleRef.current) handleRef.current.still = paused || prefersReducedMotion();
  }, [paused]);

  const hold = (held: boolean) => {
    if (handleRef.current) handleRef.current.held = held;
  };

  return (
    <div className="group flex min-w-0 flex-col">
      <button
        type="button"
        aria-label={`Open ${channel.title}`}
        aria-current={active || undefined}
        onPointerEnter={() => hold(true)}
        onPointerLeave={() => hold(false)}
        onFocus={() => hold(true)}
        onBlur={() => hold(false)}
        onClick={() => onSelect?.(channel.id)}
        className="relative aspect-square w-full cursor-pointer touch-manipulation rounded-xl outline-none transition-transform duration-150 ease-out active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:active:scale-100"
      >
        <canvas ref={canvasRef} aria-hidden className="block size-full" />
      </button>
      <div className="flex flex-col px-2 pt-2 pb-3">
        {renderMeta ? (
          renderMeta(channel, active)
        ) : (
          <>
            <p className="flex min-w-0 items-center gap-1 text-sm text-foreground">
              <span className="line-clamp-1 min-w-0 break-words">{channel.title}</span>
              <svg
                viewBox="0 0 24 24"
                fill="none"
                aria-hidden
                className="size-3.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
              >
                <path d="M7 17 17 7M17 7H9M17 7v8" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </p>
            {active ? (
              <p className="text-sm text-muted-foreground">Now playing</p>
            ) : updated ? (
              <p className="text-sm text-muted-foreground">{updated}</p>
            ) : null}
          </>
        )}
      </div>
    </div>
  );
}

export function DiscPicker({
  channels,
  activeId,
  onSelect,
  title = "Select a disk",
  description,
  paused = false,
  hideTitle = false,
  renderMeta,
  className,
}: {
  channels: DiscChannel[];
  /** The playing channel; marked with aria-current and "Now playing". */
  activeId?: string;
  onSelect?: (id: string) => void;
  title?: string;
  description?: string;
  /** Freeze every disc on its current frame (e.g. while the panel is hidden). */
  paused?: boolean;
  /** Keep the title for screen readers only (it still names the section). */
  hideTitle?: boolean;
  /** Replace the title / updated line under each disc. */
  renderMeta?: (channel: DiscChannel, active: boolean) => ReactNode;
  className?: string;
}) {
  const titleId = useId();

  // Warm every disc's first slide before any second slide.
  useEffect(() => {
    void prefetchPrioritized(channels.map((c) => c.slides));
  }, [channels]);

  return (
    <section
      aria-labelledby={titleId}
      className={cn(
        "flex w-full flex-col rounded-3xl bg-background p-6 motion-safe:animate-[disc-picker-in_280ms_cubic-bezier(0.16,1,0.3,1)_both]",
        className,
      )}
    >
      <header className={hideTitle ? "sr-only" : "pb-10"}>
        <h2 id={titleId} className="m-0 flex flex-col text-lg leading-snug font-semibold sm:text-xl">
          <span className="text-foreground">{title}</span>
          {description ? <span className="text-muted-foreground">{description}</span> : null}
        </h2>
      </header>
      <div className="grid grid-cols-2 gap-6">
        {channels.map((channel, i) => (
          <Disc
            key={channel.id}
            channel={channel}
            index={i}
            active={channel.id === activeId}
            paused={paused}
            onSelect={onSelect}
            renderMeta={renderMeta}
          />
        ))}
      </div>
    </section>
  );
}
disc-gl.ts
// One WebGL context for every disc on the page. Each disc owns a shader
// material and an art texture; a shared renderer draws them one at a time
// into a scissored corner of its buffer, and each disc copies that corner
// into its own 2D canvas. Ten pickers still cost one GPU context.

import * as THREE from "three";
import { artKey, createArtTexture, createScratch, paintArt, uploadScratch } from "./disc-art";

export const DISC_DEFAULTS = {
  /** Resting tilt, degrees. */
  rotation: 13,
  /** Radians per second while not held. */
  spinSpeed: 1,
  glistenSpeed: 1.55,
  glistenIntensity: 1.5,
  rainbowAmount: 0,
  highlightSharpness: 96,
};

const vertex = /* glsl */ `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

// Ported from the production CD shader; logo decal and dim/fade passes removed.
const fragment = /* glsl */ `
  precision highp float;
  varying vec2 vUv;
  #define TWO_PI 6.28318530718

  uniform sampler2D uArt;
  uniform float uTime;
  uniform float uGlistenIntensity;
  uniform float uRainbowAmount;
  uniform float uSharpness;
  uniform float uDiskPx;

  vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0/3.0, 1.0/3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
  }

  float hash12(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
  }

  void main() {
    vec2 centered = vUv - 0.5;
    float r = length(centered) * 2.0;
    float angle = atan(centered.y, centered.x);
    if (r > 1.0) discard;

    vec3 art = texture2D(uArt, vUv).rgb;
    // Groove ripple on the printed art, strongest across the data area.
    float pr = 378.0 * r * r + 99.0 * r + 12.0 * sin(angle * 2.62 + uTime * 0.056) + uTime * 0.36;
    float beatp = sin(pr) * sin(pr * 1.049 + 0.9);
    float irisE = smoothstep(0.33, 0.48, r) * smoothstep(0.76, 0.58, r);
    float rimE = smoothstep(0.74, 0.985, r);
    float ge = irisE * (1.0 - rimE * 0.9);
    art *= 1.0 + (0.5 * sin(pr)) * 0.072 * ge + beatp * 0.035 * ge;

    float lum = dot(art, vec3(0.299, 0.587, 0.114));
    art = mix(vec3(lum), art, 0.81);
    art = pow(max(art, vec3(0.001)), vec3(0.93));
    vec2 gCell = floor(vUv * vec2(1600.0));
    float gn = hash12(gCell) * 0.65 + hash12(gCell + vec2(31.0, 17.0)) * 0.35;
    vec3 color = clamp(art + (gn - 0.5) * 0.062, 0.0, 1.0);

    // Aluminium lift at the rim and hub.
    vec3 aluminum = vec3(0.86, 0.88, 0.92);
    float irisMid = irisE;
    float rimW = rimE;
    vec3 radialMetal = mix(aluminum, vec3(0.78, 0.80, 0.86), rimW * 0.95);
    color = mix(color, radialMetal, clamp(rimW * 0.34 + (1.0 - irisMid) * 0.24, 0.0, 1.0) * 0.74);

    // Concentric groove grating with a slight chromatic split.
    float angNorm = fract(angle / TWO_PI + 0.5);
    float kR = 385.0 + 18.0 * sin(angNorm * TWO_PI * 2.0 + uTime * 0.07);
    float ph = kR * r * r + 102.0 * r + 14.0 * sin(angle * 2.65 + uTime * 0.056) + uTime * 0.38;
    float s1 = sin(ph);
    float s2 = sin(ph * 1.051 + 2.15 + 0.35 * sin(angle * 1.9));
    float h2 = 0.5 + 0.5 * sin(ph * 2.04 + 0.55);
    float beat = 0.5 + 0.5 * (s1 * s2);
    float visBeat = pow(max(beat, 0.0), 0.78);
    float grateRegion = irisMid * (1.0 - rimW * 0.88);
    float rib = s1;
    float ribFine = (h2 - 0.5) * 2.0;
    float gStr = grateRegion * (0.72 + 0.28 * visBeat);
    color *= 1.0 + rib * 0.1 * gStr + ribFine * 0.038 * gStr;
    vec3 cChrom = color;
    float chrom = rib * 0.062 * gStr;
    cChrom.r = clamp(cChrom.r + chrom, 0.0, 1.0);
    cChrom.g = clamp(cChrom.g + chrom * 0.28, 0.0, 1.0);
    cChrom.b = clamp(cChrom.b - chrom * 1.02, 0.0, 1.0);
    color = mix(color, cChrom, 0.44);
    color *= 0.982 + 0.055 * mix(beat, visBeat, 0.5) * grateRegion;

    // Optional iridescent zones (off by default).
    float cellCoord = angNorm * 6.0 + uTime * 0.0072;
    float cellF = fract(cellCoord);
    float dwell = pow(max(smoothstep(0.0, 0.32, cellF) * smoothstep(1.0, 0.68, cellF), 1e-4), 0.82);
    float arcMask = smoothstep(0.35, 0.95, 0.55 + 0.45 * cos((angNorm - 0.5) * TWO_PI * 1.15 + uTime * 0.055));
    float zi = mod(floor(cellCoord), 3.0);
    float hueAnchor = mix(0.17, mix(0.48, 0.76, step(2.0, zi)), step(1.0, zi));
    float phHue = fract(0.09 + ph * 0.0098 + angNorm * 0.2 + sin(ph * 0.031) * 0.11 + uTime * 0.004);
    float hue = fract(mix(hueAnchor, phHue, 0.62) + 0.04 * rib * grateRegion + 0.018 * sin(cellF * TWO_PI));
    float speckle = clamp(0.42 * pow(max(abs(s1), 0.08), 0.65) + 0.28 * abs(h2 - 0.5) * 2.0 + 0.35 * visBeat, 0.0, 1.1);
    float zoneSat = 0.62 * speckle * dwell * arcMask * grateRegion;
    float iridMix = min(1.0, zoneSat * uRainbowAmount * (0.16 + 0.84 * smoothstep(0.08, 0.55, dwell)) * uGlistenIntensity);
    color = mix(color, mix(radialMetal, hsv2rgb(vec3(hue, 0.76, 1.0)), 0.58), iridMix);

    // Soft bloom that drifts around the data area.
    float spread = mix(0.42, 0.78, clamp(uSharpness / 128.0, 0.0, 1.0));
    float bloom = (cos((angle * spread + uTime * 0.62) * 1.15) * 0.5 + 0.5) * 0.52
      + (sin(angle * 0.65 + uTime * 0.28) * 0.5 + 0.5) * 0.48;
    bloom = smoothstep(0.18, 0.96, pow(max(bloom, 0.0), 1.35));
    float bloomMask = (irisMid * 0.92 + 0.05 * (1.0 - rimW) * irisMid)
      * smoothstep(0.1, 0.26, r) * (1.0 - smoothstep(0.65, 0.94, r));
    color = clamp(color + vec3(1.0, 0.994, 0.99) * bloom * bloomMask * 0.11 * uGlistenIntensity, 0.0, 1.0);

    // The specular sweep: the glint that reads as "a CD catching the light".
    float specN = clamp(uSharpness * 0.5 + 40.0, 52.0, 120.0);
    float ht = angle * 1.72 + uTime * 0.98;
    float cs = cos(ht);
    float spec = pow(max(cs, 0.0), specN) + pow(max(-cs, 0.0), specN * 0.8) * 0.42
      + pow(max(cos(ht + 0.67), 0.0), specN * 1.02) * 0.36;
    float specZone = irisMid * (0.58 + 0.32 * (1.0 - rimW * 0.95)) * (1.0 - smoothstep(0.76, 0.96, r));
    vec3 specTint = mix(vec3(1.0, 0.996, 0.992), vec3(0.78, 0.84, 0.96), 0.22);
    color = clamp(color + specTint * spec * specZone * 0.62 * uGlistenIntensity, 0.0, 1.0);

    // A 1px rim stroke, measured in screen pixels.
    float dr = 2.0 / max(uDiskPx, 1.0);
    float aa = max(dr * 0.55, 0.00035);
    float strokeMask = smoothstep(1.0 - dr - aa, 1.0 - aa * 0.3, r) * step(r, 1.001);
    vec3 strokeRgb = mix(vec3(0.0), vec3(0.17, 0.17, 0.18), clamp(vUv.x * 0.68 + vUv.y * 0.32, 0.0, 1.0));
    color = mix(color, strokeRgb, strokeMask);

    gl_FragColor = vec4(color, smoothstep(1.008, 0.988, r));
  }
`;

export type DiscHandle = {
  canvas: HTMLCanvasElement;
  title: string;
  slides: string[];
  index: number;
  /** True while hovered or focused: the disc stops, like a finger on a record. */
  held: boolean;
  /** Frozen: no spin, no slideshow, no glisten drift (reduced motion, paused). */
  still: boolean;
  visible: boolean;
};

type Entry = {
  handle: DiscHandle;
  material: THREE.ShaderMaterial;
  art: THREE.DataTexture;
  scratch: HTMLCanvasElement;
  rotation: number;
  time: number;
  lastKey: string | null;
  lastDrawKey: string | null;
};

type Shared = {
  renderer: THREE.WebGLRenderer;
  scene: THREE.Scene;
  camera: THREE.OrthographicCamera;
  mesh: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>;
  entries: Set<Entry>;
  raf: number;
  last: number;
  bufW: number;
  bufH: number;
};

/** Seconds of slideshow offset between neighbouring discs. */
const SLIDE_STAGGER_SEC = 0.12;
const MAX_DPR = 2;

let shared: Shared | null = null;

function createShared(): Shared {
  const renderer = new THREE.WebGLRenderer({
    alpha: true,
    antialias: false,
    premultipliedAlpha: false,
    powerPreference: "high-performance",
  });
  renderer.setClearColor(0x000000, 0);
  renderer.setScissorTest(true);
  const scene = new THREE.Scene();
  const camera = new THREE.OrthographicCamera(0, 1, 1, 0, -10, 10);
  camera.position.z = 1;
  const placeholder = new THREE.ShaderMaterial();
  const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), placeholder);
  scene.add(mesh);
  return { renderer, scene, camera, mesh, entries: new Set(), raf: 0, last: 0, bufW: 0, bufH: 0 };
}

function frame(now: number) {
  const s = shared;
  if (!s) return;
  const dt = s.last ? Math.min((now - s.last) / 1000, 0.05) : 0;
  s.last = now;
  const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);

  for (const e of s.entries) {
    const h = e.handle;
    if (!h.visible) continue;
    const w = Math.max(1, h.canvas.clientWidth);
    const hh = Math.max(1, h.canvas.clientHeight);

    if (!h.still) {
      e.time += dt;
      if (!h.held) e.rotation += dt * DISC_DEFAULTS.spinSpeed;
    }

    const key = artKey(h.title, h.slides, e.time + h.index * SLIDE_STAGGER_SEC);
    if (key !== e.lastKey) {
      e.lastKey = key;
      paintArt(e.scratch, h.title, h.slides, e.time + h.index * SLIDE_STAGGER_SEC);
      uploadScratch(e.scratch, e.art);
    }

    // A still disc only redraws when its art or size changes.
    const drawKey = h.still ? `${key}|${w}x${hh}|${dpr}` : null;
    if (drawKey && drawKey === e.lastDrawKey) continue;
    e.lastDrawKey = drawKey;

    // Grow-only shared buffer, sized to the largest disc on the page.
    if (w > s.bufW || hh > s.bufH || s.renderer.getPixelRatio() !== dpr) {
      s.bufW = Math.max(s.bufW, w);
      s.bufH = Math.max(s.bufH, hh);
      s.renderer.setPixelRatio(dpr);
      s.renderer.setSize(s.bufW, s.bufH, false);
    }

    const size = Math.min(w, hh) * 0.92;
    const u = e.material.uniforms;
    u.uTime.value = e.time * DISC_DEFAULTS.glistenSpeed + h.index * 1.37;
    u.uDiskPx.value = size * dpr;
    s.mesh.material = e.material;
    s.mesh.position.set(w / 2, hh / 2, 0);
    s.mesh.scale.set(size, size, 1);
    s.mesh.rotation.z = (DISC_DEFAULTS.rotation * Math.PI) / 180 + e.rotation;
    s.camera.right = w;
    s.camera.top = hh;
    s.camera.updateProjectionMatrix();
    s.renderer.setViewport(0, 0, w, hh);
    s.renderer.setScissor(0, 0, w, hh);
    s.renderer.clear();
    s.renderer.render(s.scene, s.camera);

    // Copy the bottom-left corner (GL origin) into the disc's own canvas.
    const pw = Math.round(w * dpr);
    const ph = Math.round(hh * dpr);
    if (h.canvas.width !== pw || h.canvas.height !== ph) {
      h.canvas.width = pw;
      h.canvas.height = ph;
    }
    const ctx = h.canvas.getContext("2d");
    if (!ctx) continue;
    const src = s.renderer.domElement;
    ctx.clearRect(0, 0, pw, ph);
    ctx.drawImage(src, 0, src.height - ph, pw, ph, 0, 0, pw, ph);
  }

  s.raf = requestAnimationFrame(frame);
}

/** Register a disc with the shared renderer; returns an unregister function. */
export function registerDisc(handle: DiscHandle): () => void {
  shared ??= createShared();
  const s = shared;
  const art = createArtTexture();
  const material = new THREE.ShaderMaterial({
    vertexShader: vertex,
    fragmentShader: fragment,
    transparent: true,
    depthTest: false,
    depthWrite: false,
    uniforms: {
      uArt: { value: art },
      uTime: { value: 0 },
      uGlistenIntensity: { value: DISC_DEFAULTS.glistenIntensity },
      uRainbowAmount: { value: DISC_DEFAULTS.rainbowAmount },
      uSharpness: { value: DISC_DEFAULTS.highlightSharpness },
      uDiskPx: { value: 256 },
    },
  });
  const entry: Entry = {
    handle,
    material,
    art,
    scratch: createScratch(),
    rotation: 0,
    time: 0,
    lastKey: null,
    lastDrawKey: null,
  };
  s.entries.add(entry);
  if (!s.raf) {
    s.last = 0;
    s.raf = requestAnimationFrame(frame);
  }

  return () => {
    s.entries.delete(entry);
    material.dispose();
    art.dispose();
    if (s.entries.size === 0) {
      cancelAnimationFrame(s.raf);
      s.mesh.geometry.dispose();
      s.renderer.dispose();
      shared = null;
    }
  };
}
disc-art.ts
// Slideshow artwork for one disc: hold on a frame, cross-fade to the next,
// painted into a 512² scratch canvas and copied into a DataTexture. Bitmaps
// load into one shared cache, first frame of every disc before the rest, so
// every disc shows art before any disc gets its second slide.

import * as THREE from "three";

export const ART_SIZE = 512;
export const SLIDE_HOLD_SEC = 2.8;
export const SLIDE_CROSS_SEC = 1.0;

export type BitmapCache = Map<string, ImageBitmap | null>;

/** One cache per page, so remounting a picker reuses warmed bitmaps. */
export const bitmapCache: BitmapCache = new Map();

/** Deterministic neutral grey for discs with no art, or while their art loads. */
export function fallbackGrey(str: string, offset = 0): string {
  let h = 0;
  for (let i = 0; i < str.length; i++) h = str.charCodeAt(i) + ((h << 5) - h);
  return `hsl(0 0% ${22 + (((h + offset * 7) % 8) + 8) % 8}%)`;
}

/**
 * Strict CORS: the scratch canvas is read back with getImageData, so a
 * tainted image would throw. Cross-origin art must be served with CORS.
 */
function loadBitmap(url: string): Promise<ImageBitmap | null> {
  return new Promise((resolve) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => {
      createImageBitmap(img).then(resolve, () => resolve(null));
    };
    img.onerror = () => resolve(null);
    img.src = url;
  });
}

export async function prefetchBitmaps(urls: string[], cache = bitmapCache, concurrency = 16) {
  const pending = [...new Set(urls)].filter((u) => !cache.has(u));
  if (pending.length === 0) return;
  let cursor = 0;
  async function worker() {
    while (cursor < pending.length) {
      const url = pending[cursor++]!;
      cache.set(url, await loadBitmap(url));
    }
  }
  await Promise.all(Array.from({ length: Math.min(concurrency, pending.length) }, worker));
}

/** First frame of every disc, then everything else. */
export async function prefetchPrioritized(slides: string[][], cache = bitmapCache) {
  const primaries = [...new Set(slides.map((s) => s[0]).filter(Boolean) as string[])];
  await prefetchBitmaps(primaries, cache);
  void prefetchBitmaps(slides.flat(), cache);
}

export function createArtTexture(): THREE.DataTexture {
  const tex = new THREE.DataTexture(
    new Uint8Array(ART_SIZE * ART_SIZE * 4),
    ART_SIZE,
    ART_SIZE,
    THREE.RGBAFormat,
    THREE.UnsignedByteType,
  );
  // No colour-space decode: the shader writes straight to an sRGB canvas, so
  // decoding here would show every label darker than its source image.
  tex.colorSpace = THREE.NoColorSpace;
  tex.magFilter = THREE.LinearFilter;
  tex.minFilter = THREE.LinearFilter;
  tex.flipY = true;
  tex.generateMipmaps = false;
  tex.needsUpdate = true;
  return tex;
}

export function createScratch(): HTMLCanvasElement {
  const c = document.createElement("canvas");
  c.width = ART_SIZE;
  c.height = ART_SIZE;
  return c;
}

export function uploadScratch(scratch: HTMLCanvasElement, tex: THREE.DataTexture) {
  const ctx = scratch.getContext("2d", { willReadFrequently: true });
  const dst = tex.image.data as Uint8Array | null;
  if (!ctx || !dst) return;
  dst.set(ctx.getImageData(0, 0, ART_SIZE, ART_SIZE).data);
  tex.needsUpdate = true;
}

function drawCover(ctx: CanvasRenderingContext2D, bmp: ImageBitmap) {
  const s = Math.max(ART_SIZE / bmp.width, ART_SIZE / bmp.height);
  const w = bmp.width * s;
  const h = bmp.height * s;
  ctx.drawImage(bmp, (ART_SIZE - w) / 2, (ART_SIZE - h) / 2, w, h);
}

/** Which slide pair is on screen at time t, and how far the cross-fade is. */
function slideAt(n: number, t: number) {
  const seg = SLIDE_HOLD_SEC + SLIDE_CROSS_SEC;
  const cyc = Math.max(0, t) % (n * seg);
  const idx = Math.floor(cyc / seg);
  const local = cyc - idx * seg;
  const blend = local >= SLIDE_HOLD_SEC ? Math.min(1, (local - SLIDE_HOLD_SEC) / SLIDE_CROSS_SEC) : 0;
  return { from: idx % n, to: (idx + 1) % n, blend };
}

/**
 * Changes exactly when the painted pixels would: slide pair, blend, or a
 * bitmap arriving. Comparing it per frame means a disc on hold re-uploads
 * nothing, and art that loads late appears on the next frame.
 */
export function artKey(title: string, urls: string[], t: number, cache = bitmapCache): string {
  let loaded = "";
  for (const u of urls) loaded += !cache.has(u) ? "m" : cache.get(u) ? "1" : "0";
  if (urls.length === 0) return `q:${title}`;
  if (urls.length === 1) return `1:${loaded}`;
  const { from, to, blend } = slideAt(urls.length, t);
  return `${from}|${to}|${blend}|${loaded}`;
}

export function paintArt(
  scratch: HTMLCanvasElement,
  title: string,
  urls: string[],
  t: number,
  cache = bitmapCache,
) {
  const ctx = scratch.getContext("2d", { willReadFrequently: true });
  if (!ctx) return;
  const half = ART_SIZE / 2;

  if (urls.length === 0) {
    for (let i = 0; i < 4; i++) {
      ctx.fillStyle = fallbackGrey(title, i);
      ctx.fillRect(i % 2 ? half : 0, i < 2 ? 0 : half, half, half);
    }
    return;
  }

  const { from, to, blend } = urls.length === 1 ? { from: 0, to: 0, blend: 0 } : slideAt(urls.length, t);
  const a = cache.get(urls[from]!) ?? null;
  const b = cache.get(urls[to]!) ?? null;
  ctx.globalAlpha = 1;
  ctx.clearRect(0, 0, ART_SIZE, ART_SIZE);
  if (a) drawCover(ctx, a);
  else {
    ctx.fillStyle = fallbackGrey(title, from);
    ctx.fillRect(0, 0, ART_SIZE, ART_SIZE);
  }
  if (blend > 0 && b) {
    ctx.globalAlpha = blend;
    drawCover(ctx, b);
    ctx.globalAlpha = 1;
  }
}