{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "depth-image",
  "title": "Depth Image",
  "description": "A photo relit by a light that follows the pointer or drifts on a slow orbit, with depth, surface detail and soft self-shadows estimated from the image. Raw WebGL 2, no dependencies.",
  "files": [
    {
      "path": "registry/mewo/ui/depth-image.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface DepthImageProps extends Omit<React.ComponentProps<\"div\">, \"children\" | \"onLoad\" | \"onError\"> {\n  /** Photo to relight. Must be same-origin, a data URL, or served with CORS. */\n  image?: string\n  /** Optional real depth map, white near and black far. Estimated from the photo when omitted. */\n  depthMap?: string\n  /** Accessible description of the photo. Without it the image is decorative. */\n  alt?: string\n  /** How the photo fills the box: cropped to cover it, or shown whole inside it. */\n  fit?: \"cover\" | \"contain\"\n  /** The lit photo, or debug views of the estimated depth or the normals. */\n  view?: \"lit\" | \"depth\" | \"normal\"\n  /** Estimated depth: 0 reads depth from vertical position only, 1 from brightness only. */\n  depthFromLight?: number\n  /** Blur of the height field, in steps of five texels of a 512-wide map. Applies to supplied depth maps too. */\n  depthSmoothing?: number\n  /** Contrast applied to the estimated depth. */\n  depthContrast?: number\n  /** Swap near and far. */\n  invertDepth?: boolean\n  /** Height of the relief in scene units; the plane is 2 tall. */\n  displacement?: number\n  /** Large-scale surface tilt from depth. */\n  normalStrength?: number\n  /** Fine surface detail read from the photo. */\n  detail?: number\n  /** Darkness of self-cast shadows; 0 disables them. */\n  shadowIntensity?: number\n  /** Penumbra width of the shadows. */\n  shadowSoftness?: number\n  /** Colour of the moving light. Any CSS colour. */\n  lightColor?: string\n  /** Brightness of the moving light. */\n  lightIntensity?: number\n  /** How quickly the light fades with distance: 0 is even, 4 is a tight pool. */\n  falloff?: number\n  /** Height of the light above the surface. */\n  elevation?: number\n  /** Flat fill light applied everywhere. */\n  ambient?: number\n  /** Colour of the fill light. Any CSS colour. */\n  ambientColor?: string\n  /** How much of the photo's own tone survives in unlit areas, 0 to 1. */\n  colorPreserve?: number\n  /** How quickly the light follows the pointer, 0 to 1 (per 60 fps frame). */\n  follow?: number\n  /** Move the light on a slow ellipse when the pointer is away. */\n  autoOrbit?: boolean\n  /** Size of the idle orbit, 0 to 1. */\n  orbitRadius?: number\n  /** Seconds per idle orbit. */\n  orbitDuration?: number\n  /** Floor colour of the unlit surface, so shadows settle on the page colour. */\n  backgroundColor?: string\n  /** Shown while the photo loads or if it fails. */\n  fallbackColor?: string\n  /** Freeze the light. */\n  paused?: boolean\n  /** Render scale cap. The device pixel ratio is also capped at 2 and by a pixel budget. */\n  dpr?: number\n  /** Content layered above the image. */\n  children?: React.ReactNode\n  /** The photo (and depth map) loaded and can be relit. */\n  onLoad?: () => void\n  /** The photo could not be used: it failed to load, or its server doesn't allow cross-origin reads (CORS). */\n  onError?: (error: Error) => void\n}\n\ntype Settings = {\n  image: string\n  depthMap?: string\n  fit: \"cover\" | \"contain\"\n  view: \"lit\" | \"depth\" | \"normal\"\n  depthFromLight: number\n  depthSmoothing: number\n  depthContrast: number\n  invertDepth: boolean\n  displacement: number\n  normalStrength: number\n  detail: number\n  shadowIntensity: number\n  shadowSoftness: number\n  lightColor: string\n  lightIntensity: number\n  falloff: number\n  elevation: number\n  ambient: number\n  ambientColor: string\n  colorPreserve: number\n  follow: number\n  autoOrbit: boolean\n  orbitRadius: number\n  orbitDuration: number\n  backgroundColor: string\n  paused: boolean\n  dpr: number\n  onLoad?: () => void\n  onError?: (error: Error) => void\n}\n\nexport interface DepthFieldOptions {\n  depthFromLight: number\n  depthSmoothing: number\n  depthContrast: number\n  invertDepth: boolean\n}\n\nexport interface DepthField {\n  w: number\n  h: number\n  /** 0 far to 1 near, blurred. */\n  depth: Float32Array\n  /** Depth and detail luminance, interleaved, as the GPU reads them. */\n  rg: Float32Array\n}\n\nconst DEFAULT_IMAGE = \"https://cataas.com/cat/Vw1PCxvyAshg1Cz7\"\n\n// Resolution of the depth and detail map, in texels across. Its height follows the photo's aspect.\nconst MAP_WIDTH = 512\n// Gaussian sigma per step of depthSmoothing, in map texels.\nconst SMOOTHING_SIGMA = 2.3\n// Radii of the idle orbit, as a share of half the width and half the height, per unit of orbitRadius.\nconst ORBIT_X = 0.715\nconst ORBIT_Y = 0.82\n// The photo is uploaded no wider than this, and a depth map is read no wider than DEPTH_MAX_WIDTH.\nconst PHOTO_MAX_WIDTH = 2048\nconst DEPTH_MAX_WIDTH = 1024\n// The shader marches 28 shadow steps per pixel, so a large display is held to this many fragments.\nconst PIXEL_BUDGET = 4.5e6\n\n// Calibrated constants of the lighting model, fitted against reference renders and calibration images.\nconst TUNING = Object.freeze({\n  // Slope per unit of luminance gradient per texel, times detail.\n  detailGain: 10.8,\n  // The light falls off as E(r) = peak · lambert · (1 + (r / c)²)^(−p); peak, c and p depend on falloff and light height.\n  peakGain: 8.2,\n  // Penumbra growth per unit of distance, times shadowSoftness.\n  shadowSoftScale: 4,\n  // Furthest the shadow ray searches, in scene units.\n  shadowReach: 1.2,\n  // 0: the falloff uses the depth under the light; 1: each pixel's own depth. 0.5 matched best.\n  heightMix: 0.5,\n  // 0: exact sRGB decode; otherwise albedo = rgb^albedoGamma.\n  albedoGamma: 0,\n  // Multiplies the falloff radius c.\n  widthScale: 1,\n  // Multiplies the falloff exponent p.\n  sharpScale: 1,\n})\n\nconst UNIFORMS = [\n  \"uPhoto\",\n  \"uField\",\n  \"uView\",\n  \"uImgHalf\",\n  \"uTexel\",\n  \"uDisp\",\n  \"uNormal\",\n  \"uDetail\",\n  \"uShadow\",\n  \"uSoft\",\n  \"uAmbient\",\n  \"uPreserve\",\n  \"uLight\",\n  \"uPeak\",\n  \"uC\",\n  \"uP\",\n  \"uDetailGain\",\n  \"uSoftK\",\n  \"uReach\",\n  \"uHeightMix\",\n  \"uAlbedoGamma\",\n  \"uElev\",\n  \"uDUnder\",\n  \"uLightColor\",\n  \"uAmbientColor\",\n  \"uBg\",\n  \"uViewMode\",\n  \"uReady\",\n] as const\n\nconst VERTEX = `#version 300 es\nin vec2 aPos;\nout vec2 vUv;\nvoid main() { vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }`\n\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 vUv;\nout vec4 outColor;\nuniform sampler2D uPhoto, uField;\nuniform vec2 uView;        // half extents of the view in scene units (aspect, 1)\nuniform vec2 uImgHalf;     // half extents of the displayed image in scene units\nuniform vec2 uTexel;       // 1 / field size\nuniform float uDisp, uNormal, uDetail, uShadow, uSoft, uAmbient, uPreserve;\nuniform vec3 uLight;       // light position (scene units, y up), z = displacement + elevation\nuniform float uPeak, uC, uP, uDetailGain, uSoftK, uReach, uHeightMix, uAlbedoGamma, uElev, uDUnder, uFall;\nuniform vec3 uLightColor, uAmbientColor, uBg;\nuniform int uViewMode;     // 0 lit, 1 depth, 2 normal\nuniform int uReady;\n\nvec3 toLinear(vec3 c) { return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(0.04045, c)); }\nvec3 toSrgb(vec3 l) { return mix(l * 12.92, 1.055 * pow(l, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, l)); }\n\nvec2 imageUv(vec2 p) { return vec2(p.x / uImgHalf.x * 0.5 + 0.5, 0.5 - p.y / uImgHalf.y * 0.5); }\nfloat depthAt(vec2 uv) { return texture(uField, uv).r; }\n\nvoid main() {\n  vec2 p = (vUv * 2.0 - 1.0) * uView;   // scene position, y up\n  vec2 uv = imageUv(p);\n  bool inside = all(greaterThanEqual(uv, vec2(0.0))) && all(lessThanEqual(uv, vec2(1.0)));\n  if (uReady == 0 || !inside) { outColor = vec4(toSrgb(uBg), 1.0); return; }\n\n  vec2 f = texture(uField, uv).rg;\n  float d = f.r;\n  // Large-scale tilt: true slope of the displaced surface (scene units) × normal strength.\n  vec2 du = vec2(uTexel.x, 0.0), dv = vec2(0.0, uTexel.y);\n  vec2 sceneTexel = uTexel * uImgHalf * 2.0;\n  float dx = (depthAt(uv + du) - depthAt(uv - du)) / (2.0 * sceneTexel.x);\n  float dyDown = (depthAt(uv + dv) - depthAt(uv - dv)) / (2.0 * sceneTexel.y);\n  // Fine detail from the photo's own brightness, per texel.\n  float lx = (texture(uField, uv + du).g - texture(uField, uv - du).g) * 0.5;\n  float lyDown = (texture(uField, uv + dv).g - texture(uField, uv - dv).g) * 0.5;\n  float sx = uNormal * uDisp * dx + uDetail * uDetailGain * lx;\n  float syUp = -(uNormal * uDisp * dyDown + uDetail * uDetailGain * lyDown);\n  vec3 N = normalize(vec3(-sx, -syUp, 1.0));\n\n  if (uViewMode == 1) { outColor = vec4(vec3(d), 1.0); return; }\n  if (uViewMode == 2) { outColor = vec4(N * 0.5 + 0.5, 1.0); return; }\n\n  vec3 srgb = texture(uPhoto, uv).rgb;\n  vec3 albedo = uAlbedoGamma > 0.0 ? pow(srgb, vec3(uAlbedoGamma)) : toLinear(srgb);\n  vec3 P = vec3(p, d * uDisp);\n  vec3 Lv = uLight - P;\n  vec3 L = normalize(Lv);\n  float r = length(uLight.xy - p);\n  float lambert = max(dot(N, L), 0.0);\n  // Light height above the surface: blend of the depth under the light and this pixel's own depth.\n  float gapPix = max(0.05, uElev + uDisp * (1.0 - mix(uDUnder, d, uHeightMix)));\n  float zr = gapPix / 1.2;\n  float peakPix = uPeak * pow(zr, -4.94);\n  float cPix = uC * zr;\n  float pPix = uP * pow(zr, -0.4);\n  float atten = peakPix * pow(1.0 + (r / cPix) * (r / cPix), -pPix);\n\n  // Soft self-shadow: march the height field toward the light.\n  float vis = 1.0;\n  if (uShadow > 0.0 && uDisp > 0.0) {\n    vec2 dir = uLight.xy - p;\n    float len = length(dir);\n    const int STEPS = 28;\n    for (int i = 1; i <= STEPS; i++) {\n      float t = float(i) / float(STEPS);\n      vec3 q = P + Lv * t * min(1.0, uReach / max(len, 1e-3));\n      if (q.z >= uLight.z) break;\n      vec2 quv = imageUv(q.xy);\n      if (any(lessThan(quv, vec2(0.0))) || any(greaterThan(quv, vec2(1.0)))) break;\n      float hField = depthAt(quv) * uDisp;\n      float dist = length(q.xy - p) + 1e-3;\n      vis = min(vis, clamp((q.z - hField) / (uSoft * dist * uSoftK + 1e-3) + 0.5, 0.0, 1.0));\n    }\n  }\n  float shadow = 1.0 - uShadow * (1.0 - vis);\n\n  vec3 E = uAmbient * uAmbientColor + uLightColor * atten * lambert * shadow;\n  vec3 lit = albedo * E;\n  lit = lit / (1.0 + lit);               // Reinhard\n  lit = mix(lit, max(lit, albedo), uPreserve);\n  outColor = vec4(toSrgb(min(lit + uBg, vec3(1.0))), 1.0);\n}`\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))\n\n/** Rec. 709 luminance, 0 to 1, of every pixel of RGBA bytes. */\nexport function luminance(rgba: ArrayLike<number>) {\n  const count = Math.floor(rgba.length / 4)\n  const lum = new Float32Array(count)\n  for (let i = 0; i < count; i++) lum[i] = (0.2126 * rgba[i * 4] + 0.7152 * rgba[i * 4 + 1] + 0.0722 * rgba[i * 4 + 2]) / 255\n  return lum\n}\n\n/** Separable Gaussian blur of a `w` by `h` map with clamped edges. A sigma of 0.01 or less returns the map itself. */\nexport function blur(src: Float32Array, w: number, h: number, sigma: number) {\n  if (sigma <= 0.01) return src\n  const r = Math.ceil(sigma * 3)\n  const ker = new Float32Array(r * 2 + 1)\n  let sum = 0\n  for (let i = -r; i <= r; i++) sum += ker[i + r] = Math.exp(-(i * i) / (2 * sigma * sigma))\n  for (let i = 0; i < ker.length; i++) ker[i] /= sum\n  const tmp = new Float32Array(w * h)\n  const out = new Float32Array(w * h)\n  for (let y = 0; y < h; y++) {\n    const row = y * w\n    for (let x = 0; x < w; x++) {\n      let s = 0\n      for (let i = -r; i <= r; i++) s += src[row + clamp(x + i, 0, w - 1)] * ker[i + r]\n      tmp[row + x] = s\n    }\n  }\n  for (let y = 0; y < h; y++)\n    for (let x = 0; x < w; x++) {\n      let s = 0\n      for (let i = -r; i <= r; i++) s += tmp[clamp(y + i, 0, h - 1) * w + x] * ker[i + r]\n      out[y * w + x] = s\n    }\n  return out\n}\n\n/** Size of the depth map for a photo: 512 texels across, its height following the photo's aspect. */\nexport function fieldSize(width: number, height: number) {\n  return { w: MAP_WIDTH, h: Math.max(1, Math.round((MAP_WIDTH * height) / width)) }\n}\n\n/**\n * The height field the light reads and the photo's luminance for fine detail, on a `w` by `h` grid. Depth is a\n * supplied map's luminance (`depthLum`) or is estimated from the photo: the bottom reads as near, and\n * `depthFromLight` mixes in brightness. Neither input is changed.\n */\nexport function buildField(lum: Float32Array, depthLum: Float32Array | null, w: number, h: number, o: DepthFieldOptions): DepthField {\n  let depth: Float32Array\n  if (depthLum) depth = Float32Array.from(depthLum)\n  else {\n    depth = new Float32Array(w * h)\n    for (let y = 0; y < h; y++) {\n      // The bottom of the photo reads as near.\n      const v = y / Math.max(1, h - 1)\n      for (let x = 0; x < w; x++) {\n        const i = y * w + x\n        const raw = v * (1 - o.depthFromLight) + lum[i] * o.depthFromLight\n        depth[i] = clamp((raw - 0.5) * o.depthContrast + 0.5, 0, 1)\n      }\n    }\n  }\n  if (o.invertDepth) for (let i = 0; i < depth.length; i++) depth[i] = 1 - depth[i]\n  depth = blur(depth, w, h, o.depthSmoothing * SMOOTHING_SIGMA)\n  const detailLum = blur(lum, w, h, 1)\n  const rg = new Float32Array(w * h * 2)\n  for (let i = 0; i < w * h; i++) {\n    rg[i * 2] = depth[i]\n    rg[i * 2 + 1] = detailLum[i]\n  }\n  return { w, h, depth, rg }\n}\n\n/**\n * Half extents of the displayed photo in scene units, for a view `viewAspect` wide and 2 tall. Written into\n * `out` so the frame loop allocates nothing.\n */\nexport function imageExtents(imageAspect: number, viewAspect: number, fit: \"cover\" | \"contain\", out = { x: 1, y: 1 }) {\n  if ((imageAspect > viewAspect) === (fit === \"cover\")) {\n    out.x = imageAspect\n    out.y = 1\n  } else {\n    out.x = viewAspect\n    out.y = viewAspect / imageAspect\n  }\n  return out\n}\n\n/** Where the idle orbit puts the light at `angle`, in scene units with y up. A growing angle turns it counter-clockwise on screen. */\nexport function orbitPoint(angle: number, radius: number, aspect: number, out = { x: 0, y: 0 }) {\n  out.x = Math.cos(angle) * ORBIT_X * radius * aspect\n  out.y = Math.sin(angle) * ORBIT_Y * radius\n  return out\n}\n\n/** The orbit angle that points at the light at (x, y), so the orbit resumes from wherever the pointer left it. */\nexport function orbitAngle(x: number, y: number, radius: number, aspect: number) {\n  const r = Math.max(0.01, radius)\n  return Math.atan2(y / (ORBIT_Y * r), x / (ORBIT_X * r * aspect))\n}\n\n/** Share of the way to its target the light covers in `dt` seconds: `follow` per 60 fps frame, the same curve at any frame rate. */\nexport function followStep(follow: number, dt: number) {\n  return 1 - Math.pow(1 - clamp(follow, 0.001, 1), dt * 60)\n}\n\nconst srgbToLinear = (value: number) => (value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4))\n\nconst parseColor = (probe: CanvasRenderingContext2D | null, color: string): [number, number, number] => {\n  if (!probe) return [1, 1, 1]\n  probe.fillStyle = \"#000\"\n  probe.fillStyle = color\n  // The browser normalises it to #rrggbb, or rgba() when it is translucent.\n  const value = String(probe.fillStyle)\n  if (value.startsWith(\"#\")) return [1, 3, 5].map((i) => parseInt(value.slice(i, i + 2), 16) / 255) as [number, number, number]\n  const m = value.match(/[\\d.]+/g)?.map(Number) ?? [255, 255, 255]\n  return [m[0] / 255, m[1] / 255, m[2] / 255]\n}\n\nconst loadImage = (src: string) =>\n  new Promise<HTMLImageElement>((resolve, reject) => {\n    const img = new Image()\n    img.crossOrigin = \"anonymous\"\n    img.decoding = \"async\"\n    img.onload = () => resolve(img)\n    img.onerror = reject\n    img.src = src\n  })\n\n/** Rasterises an image (an SVG too) into a canvas no wider than `maxWidth`. */\nconst rasterise = (img: HTMLImageElement, maxWidth: number) => {\n  const scale = Math.min(1, maxWidth / (img.naturalWidth || maxWidth))\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = Math.max(1, Math.round((img.naturalWidth || maxWidth) * scale))\n  canvas.height = Math.max(1, Math.round((img.naturalHeight || maxWidth) * scale))\n  canvas.getContext(\"2d\")?.drawImage(img, 0, 0, canvas.width, canvas.height)\n  return canvas\n}\n\n/** Luminance of `source` resampled to `w` by `h`. Throws when the pixels cannot be read. */\nconst readLuminance = (source: HTMLCanvasElement, w: number, h: number) => {\n  const canvas = document.createElement(\"canvas\")\n  canvas.width = w\n  canvas.height = h\n  const ctx = canvas.getContext(\"2d\", { willReadFrequently: true })\n  if (!ctx) throw new Error(\"No 2D context\")\n  ctx.drawImage(source, 0, 0, w, h)\n  return luminance(ctx.getImageData(0, 0, w, h).data)\n}\n\nconst compile = (gl: WebGL2RenderingContext, type: number, source: string) => {\n  const shader = gl.createShader(type)\n  if (!shader) return null\n  gl.shaderSource(shader, source)\n  gl.compileShader(shader)\n  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader\n  gl.deleteShader(shader)\n  return null\n}\n\nconst link = (gl: WebGL2RenderingContext) => {\n  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX)\n  const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT)\n  const program = gl.createProgram()\n  if (!vertex || !fragment || !program) return null\n  gl.attachShader(program, vertex)\n  gl.attachShader(program, fragment)\n  gl.linkProgram(program)\n  gl.deleteShader(vertex)\n  gl.deleteShader(fragment)\n  if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program\n  gl.deleteProgram(program)\n  return null\n}\n\nconst createTexture = (gl: WebGL2RenderingContext) => {\n  const texture = gl.createTexture()\n  gl.bindTexture(gl.TEXTURE_2D, texture)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)\n  return texture\n}\n\nexport function DepthImage({\n  image = DEFAULT_IMAGE,\n  depthMap,\n  alt,\n  fit = \"cover\",\n  view = \"lit\",\n  depthFromLight = 0.5,\n  depthSmoothing = 7,\n  depthContrast = 1.2,\n  invertDepth = false,\n  displacement = 1.5,\n  normalStrength = 1.5,\n  detail = 0.8,\n  shadowIntensity = 0.7,\n  shadowSoftness = 0.1,\n  lightColor = \"#ffffff\",\n  lightIntensity = 6,\n  falloff = 2.5,\n  elevation = 1.2,\n  ambient = 0.02,\n  ambientColor = \"#ffffff\",\n  colorPreserve = 0,\n  follow = 0.12,\n  autoOrbit = true,\n  orbitRadius = 0.6,\n  orbitDuration = 10,\n  backgroundColor = \"#000000\",\n  fallbackColor = \"#171717\",\n  paused = false,\n  dpr = 1.5,\n  children,\n  onLoad,\n  onError,\n  className,\n  style,\n  ...props\n}: DepthImageProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const settings: Settings = {\n    image,\n    depthMap,\n    fit,\n    view,\n    depthFromLight,\n    depthSmoothing,\n    depthContrast,\n    invertDepth,\n    displacement,\n    normalStrength,\n    detail,\n    shadowIntensity,\n    shadowSoftness,\n    lightColor,\n    lightIntensity,\n    falloff,\n    elevation,\n    ambient,\n    ambientColor,\n    colorPreserve,\n    follow,\n    autoOrbit,\n    orbitRadius,\n    orbitDuration,\n    backgroundColor,\n    paused,\n    dpr,\n    onLoad,\n    onError,\n  }\n  const settingsRef = React.useRef(settings)\n  const wakeRef = React.useRef<(() => void) | null>(null)\n\n  // The WebGL pipeline is built once. A prop change only updates the ref and wakes the loop, and a new photo\n  // replaces the two textures, never the context.\n  React.useEffect(() => {\n    const root = rootRef.current\n    if (!root) return\n\n    // The canvas is made here, not in JSX: React Strict Mode runs this effect twice in development, and the\n    // context the first cleanup loses would come straight back from getContext() on the same element.\n    const canvas = document.createElement(\"canvas\")\n    canvas.className = \"absolute inset-0 block h-full w-full\"\n    canvas.setAttribute(\"aria-hidden\", \"true\")\n    // Alpha stays on so the fallback colour shows through while the photo loads.\n    const gl = canvas.getContext(\"webgl2\", { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false })\n    // jsdom, a GPU-less CI or an old browser: the root keeps its fallback colour and nothing throws.\n    if (!gl) return\n    const program = link(gl)\n    if (!program) {\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext()\n      return\n    }\n    root.appendChild(canvas)\n\n    const loc: Record<string, WebGLUniformLocation | null> = {}\n    for (const name of UNIFORMS) loc[name] = gl.getUniformLocation(program, name)\n    const vao = gl.createVertexArray()\n    const buffer = gl.createBuffer()\n    gl.bindVertexArray(vao)\n    gl.bindBuffer(gl.ARRAY_BUFFER, buffer)\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW)\n    const aPos = gl.getAttribLocation(program, \"aPos\")\n    gl.enableVertexAttribArray(aPos)\n    gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)\n    gl.bindVertexArray(null)\n    const photoTexture = createTexture(gl)\n    const fieldTexture = createTexture(gl)\n    gl.useProgram(program)\n    gl.uniform1i(loc.uPhoto, 0)\n    gl.uniform1i(loc.uField, 1)\n\n    const motionQuery = window.matchMedia?.(\"(prefers-reduced-motion: reduce)\")\n    // One colour probe for the component's life; each colour is parsed only when it changes.\n    const probe = document.createElement(\"canvas\").getContext(\"2d\")\n    const slot = () => ({ key: \"\", rgb: new Float32Array(3) })\n    const colors = { light: slot(), ambient: slot(), background: slot() }\n    const linearColor = (target: { key: string; rgb: Float32Array }, color: string) => {\n      if (target.key !== color) {\n        target.key = color\n        target.rgb.set(parseColor(probe, color).map(srgbToLinear))\n      }\n      return target.rgb\n    }\n\n    // The light in scene units (the plane is 2 tall, y up), easing toward its target.\n    const light = { x: 0, y: 0, tx: 0, ty: 0, angle: 0, pointer: false }\n    const orbit = { x: 0, y: 0 }\n    const extents = { x: 1, y: 1 }\n    const photoSize = { width: 1, height: 1 }\n    // The photo's luminance (and a depth map's) on the field grid, read once per photo.\n    let pixels: { w: number; h: number; lum: Float32Array; depth: Float32Array | null } | null = null\n    let field: DepthField | null = null\n    const built: DepthFieldOptions = { depthFromLight: NaN, depthSmoothing: NaN, depthContrast: NaN, invertDepth: false }\n    let sourceKey: string | null = null\n    let generation = 0\n    let ready = false\n    let failed = false\n    let blank = false\n    let sizedDpr = 0\n    let raf = 0\n    let last = 0\n    let visible = true\n\n    const draw = () => {\n      gl.viewport(0, 0, canvas.width, canvas.height)\n      if (!ready || !field) {\n        // Loading or failed: the root's fallback colour shows through.\n        if (!blank) {\n          gl.clearColor(0, 0, 0, 0)\n          gl.clear(gl.COLOR_BUFFER_BIT)\n          blank = true\n        }\n        return\n      }\n      blank = false\n      const s = settingsRef.current\n      const aspect = canvas.width / canvas.height\n      // Displayed image extents in scene units.\n      imageExtents(photoSize.width / photoSize.height, aspect, s.fit, extents)\n      const hx = extents.x\n      const hy = extents.y\n      // The light's height above the surface under it drives the falloff.\n      const u = clamp((light.x / hx) * 0.5 + 0.5, 0, 1)\n      const v = clamp(0.5 - (light.y / hy) * 0.5, 0, 1)\n      const dUnder = field.depth[Math.round(v * (field.h - 1)) * field.w + Math.round(u * (field.w - 1))]\n      const f = Math.max(0, s.falloff)\n      // Falloff bases at the reference light height 1.2, scaled per pixel in the shader.\n      const peak = s.lightIntensity * TUNING.peakGain * Math.exp(-0.53 * f + 0.012 * f * f)\n      const c = (0.85 + 0.4 * Math.exp(-3 * f)) * TUNING.widthScale\n      const p = (0.9 + 0.41 * f) * TUNING.sharpScale\n      gl.useProgram(program)\n      gl.bindVertexArray(vao)\n      gl.uniform2f(loc.uView, aspect, 1)\n      gl.uniform2f(loc.uImgHalf, hx, hy)\n      gl.uniform2f(loc.uTexel, 1 / field.w, 1 / field.h)\n      gl.uniform1f(loc.uDisp, s.displacement)\n      gl.uniform1f(loc.uNormal, s.normalStrength)\n      gl.uniform1f(loc.uDetail, s.detail)\n      gl.uniform1f(loc.uShadow, s.shadowIntensity)\n      gl.uniform1f(loc.uSoft, Math.max(0.005, s.shadowSoftness))\n      gl.uniform1f(loc.uAmbient, s.ambient)\n      gl.uniform1f(loc.uPreserve, s.colorPreserve)\n      gl.uniform3f(loc.uLight, light.x, light.y, s.displacement + s.elevation)\n      gl.uniform1f(loc.uDetailGain, TUNING.detailGain)\n      gl.uniform1f(loc.uHeightMix, TUNING.heightMix)\n      gl.uniform1f(loc.uAlbedoGamma, TUNING.albedoGamma)\n      gl.uniform1f(loc.uElev, s.elevation)\n      gl.uniform1f(loc.uDUnder, dUnder)\n      gl.uniform1f(loc.uSoftK, TUNING.shadowSoftScale)\n      gl.uniform1f(loc.uReach, TUNING.shadowReach)\n      gl.uniform1f(loc.uPeak, peak)\n      gl.uniform1f(loc.uC, c)\n      gl.uniform1f(loc.uP, p)\n      gl.uniform3fv(loc.uLightColor, linearColor(colors.light, s.lightColor))\n      gl.uniform3fv(loc.uAmbientColor, linearColor(colors.ambient, s.ambientColor))\n      gl.uniform3fv(loc.uBg, linearColor(colors.background, s.backgroundColor))\n      gl.uniform1i(loc.uViewMode, s.view === \"depth\" ? 1 : s.view === \"normal\" ? 2 : 0)\n      gl.uniform1i(loc.uReady, 1)\n      gl.activeTexture(gl.TEXTURE0)\n      gl.bindTexture(gl.TEXTURE_2D, photoTexture)\n      gl.activeTexture(gl.TEXTURE1)\n      gl.bindTexture(gl.TEXTURE_2D, fieldTexture)\n      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)\n    }\n\n    // The height field is rebuilt on the CPU when the photo or a depth setting changes, never per frame.\n    const rebuild = () => {\n      if (!pixels) return\n      const s = settingsRef.current\n      built.depthFromLight = s.depthFromLight\n      built.depthSmoothing = s.depthSmoothing\n      built.depthContrast = s.depthContrast\n      built.invertDepth = s.invertDepth\n      field = buildField(pixels.lum, pixels.depth, pixels.w, pixels.h, built)\n      gl.bindTexture(gl.TEXTURE_2D, fieldTexture)\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG16F, field.w, field.h, 0, gl.RG, gl.FLOAT, field.rg)\n      ready = true\n    }\n\n    const stale = (s: Settings) =>\n      s.depthFromLight !== built.depthFromLight ||\n      s.depthSmoothing !== built.depthSmoothing ||\n      s.depthContrast !== built.depthContrast ||\n      s.invertDepth !== built.invertDepth\n\n    const frame = (now: number) => {\n      raf = 0\n      if (!visible || document.hidden) return\n      const s = settingsRef.current\n      // dt is clamped so a hitch or a tab switch does not jump the light.\n      const dt = last ? clamp((now - last) / 1000, 1 / 240, 0.05) : 1 / 60\n      last = now\n      if (pixels && stale(s)) {\n        try {\n          rebuild()\n        } catch {\n          ready = false\n        }\n      }\n      const reduced = motionQuery?.matches ?? false\n      const orbiting = !light.pointer && s.autoOrbit && !s.paused && !reduced\n      if (!s.paused) {\n        if (orbiting) {\n          light.angle += (dt / Math.max(0.5, s.orbitDuration)) * Math.PI * 2\n          orbitPoint(light.angle, s.orbitRadius, canvas.width / Math.max(1, canvas.height), orbit)\n          light.tx = orbit.x\n          light.ty = orbit.y\n        }\n        const k = followStep(s.follow, dt)\n        light.x += (light.tx - light.x) * k\n        light.y += (light.ty - light.y) * k\n      }\n      draw()\n      const moving = Math.abs(light.tx - light.x) + Math.abs(light.ty - light.y) > 1e-4\n      // The loop runs only while the light orbits or is still easing toward its target. A paused light, a settled\n      // one and a photo that failed to load cost nothing until a prop, the pointer or a new photo wakes it.\n      if (!failed && !s.paused && (orbiting || moving)) raf = requestAnimationFrame(frame)\n      else last = 0\n    }\n\n    // Nothing is scheduled while the image is off screen or the tab is hidden.\n    const wake = () => {\n      if (!raf && visible && !document.hidden) raf = requestAnimationFrame(frame)\n    }\n\n    const resize = () => {\n      const s = settingsRef.current\n      const width = root.clientWidth\n      const height = root.clientHeight\n      // The device pixel ratio, capped by `dpr`, by 2 and by the fragment budget.\n      const ratio = Math.min(window.devicePixelRatio || 1, s.dpr > 0 ? s.dpr : 2, 2, Math.sqrt(PIXEL_BUDGET / Math.max(1, width * height)))\n      canvas.width = Math.max(1, Math.round(width * ratio))\n      canvas.height = Math.max(1, Math.round(height * ratio))\n      sizedDpr = s.dpr\n      // A resized canvas is blank, so it is drawn again at once rather than on the next frame.\n      blank = true\n      draw()\n      wake()\n    }\n\n    // A new photo (or depth map) replaces the textures and the field; the pipeline stays.\n    const load = () => {\n      const s = settingsRef.current\n      const key = `${s.image}\\n${s.depthMap ?? \"\"}`\n      if (key === sourceKey) return\n      sourceKey = key\n      const token = ++generation\n      // The fallback colour shows until the new photo is ready; the light keeps moving meanwhile.\n      ready = false\n      failed = false\n      pixels = null\n      field = null\n      Promise.all([loadImage(s.image), s.depthMap ? loadImage(s.depthMap) : Promise.resolve(null)])\n        .then(([img, depthImg]) => {\n          if (token !== generation) return\n          const photo = rasterise(img, PHOTO_MAX_WIDTH)\n          const depthSource = depthImg ? rasterise(depthImg, DEPTH_MAX_WIDTH) : null\n          gl.bindTexture(gl.TEXTURE_2D, photoTexture)\n          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, photo)\n          photoSize.width = photo.width\n          photoSize.height = photo.height\n          try {\n            // The pixels are read once here: a depth setting changing later only re-runs the maths.\n            const { w, h } = fieldSize(photo.width, photo.height)\n            pixels = { w, h, lum: readLuminance(photo, w, h), depth: depthSource ? readLuminance(depthSource, w, h) : null }\n            rebuild()\n          } catch {\n            ready = false\n          }\n          wake()\n          if (!ready) throw new Error(\"The image pixels could not be read (cross-origin image without CORS).\")\n          settingsRef.current.onLoad?.()\n        })\n        .catch((error: unknown) => {\n          if (token !== generation) return\n          // Stays on the fallback colour.\n          ready = false\n          failed = true\n          pixels = null\n          field = null\n          wake()\n          const reason =\n            error instanceof Error && error.name === \"SecurityError\"\n              ? \"its server does not allow cross-origin use (CORS)\"\n              : error instanceof Error\n                ? error.message\n                : \"it failed to load (check the URL, and that its server allows cross-origin use / CORS)\"\n          settingsRef.current.onError?.(new Error(`Couldn't use the image: ${reason}`))\n        })\n    }\n\n    // The pointer is read on window, so content layered over the image never hides it from the light.\n    const locate = (event: PointerEvent) => {\n      const rect = root.getBoundingClientRect()\n      const x = event.clientX - rect.left\n      const y = event.clientY - rect.top\n      return x >= 0 && y >= 0 && x < rect.width && y < rect.height ? rect : null\n    }\n\n    const leave = () => {\n      if (!light.pointer) return\n      light.pointer = false\n      // The orbit resumes from wherever the pointer left the light.\n      const s = settingsRef.current\n      light.angle = orbitAngle(light.x, light.y, s.orbitRadius, canvas.width / Math.max(1, canvas.height))\n      if (!s.paused) wake()\n    }\n\n    const onPointerMove = (event: PointerEvent) => {\n      if (!visible && !light.pointer) return\n      const rect = locate(event)\n      if (!rect) return leave()\n      const aspect = rect.width / Math.max(1, rect.height)\n      light.pointer = true\n      light.tx = (((event.clientX - rect.left) / rect.width) * 2 - 1) * aspect\n      light.ty = 1 - ((event.clientY - rect.top) / rect.height) * 2\n      if (!settingsRef.current.paused) wake()\n    }\n\n    // A scroll can carry the image out from under a still pointer; the browser reports that as pointerover.\n    const onPointerOver = (event: PointerEvent) => {\n      if (light.pointer && !locate(event)) leave()\n    }\n\n    // The pointer left the window, or a finger or pen lifted.\n    const onPointerOut = (event: PointerEvent) => {\n      if (!event.relatedTarget) leave()\n    }\n\n    const onVisibility = () => {\n      last = 0\n      wake()\n    }\n\n    window.addEventListener(\"pointermove\", onPointerMove, { passive: true })\n    window.addEventListener(\"pointerdown\", onPointerMove, { passive: true })\n    window.addEventListener(\"pointerover\", onPointerOver, { passive: true })\n    window.addEventListener(\"pointerout\", onPointerOut, { passive: true })\n    document.addEventListener(\"visibilitychange\", onVisibility)\n    const resizeObserver = new ResizeObserver(resize)\n    resizeObserver.observe(root)\n    const intersection = new IntersectionObserver((entries) => {\n      visible = entries.some((entry) => entry.isIntersecting)\n      if (visible) {\n        last = 0\n        wake()\n      }\n    })\n    intersection.observe(root)\n    load()\n    resize()\n    wakeRef.current = () => {\n      load()\n      if (settingsRef.current.dpr !== sizedDpr) resize()\n      else wake()\n    }\n\n    // Everything is released, then the context, then the element.\n    return () => {\n      cancelAnimationFrame(raf)\n      // A photo still loading is ignored when it arrives.\n      generation++\n      wakeRef.current = null\n      resizeObserver.disconnect()\n      intersection.disconnect()\n      window.removeEventListener(\"pointermove\", onPointerMove)\n      window.removeEventListener(\"pointerdown\", onPointerMove)\n      window.removeEventListener(\"pointerover\", onPointerOver)\n      window.removeEventListener(\"pointerout\", onPointerOut)\n      document.removeEventListener(\"visibilitychange\", onVisibility)\n      gl.deleteTexture(photoTexture)\n      gl.deleteTexture(fieldTexture)\n      gl.deleteBuffer(buffer)\n      gl.deleteVertexArray(vao)\n      gl.deleteProgram(program)\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext()\n      canvas.remove()\n    }\n  }, [])\n\n  // Props reach the loop through a ref written here, never in render, and a change only wakes the loop.\n  React.useEffect(() => {\n    settingsRef.current = settings\n    wakeRef.current?.()\n  })\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"depth-image\"\n      role={alt ? \"img\" : undefined}\n      aria-label={alt}\n      className={cn(\"relative h-full w-full touch-pan-y overflow-hidden\", className)}\n      style={{ background: fallbackColor, ...style }}\n      {...props}\n    >\n      {children ? <div className=\"relative z-1 h-full\">{children}</div> : null}\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "added": "2026-10-28"
  },
  "categories": [
    "background"
  ],
  "type": "registry:ui"
}