{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "depth-photo",
  "type": "registry:block",
  "title": "Depth Photo",
  "description": "A photo and its depth map rendered as a 2.5D scene: the camera follows the pointer, orbits or dollies, near surfaces properly cover far ones, and revealed gaps fill with stretched background. One depth stays sharp with real depth-of-field blur around it; click anywhere to refocus.",
  "docs": "Depth Photo was added to public/vividsites/depth-photo.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/depth-photo",
  "categories": [
    "3d",
    "3d-effects"
  ],
  "files": [
    {
      "path": "components/vividsites/depth-photo.html",
      "type": "registry:file",
      "target": "public/vividsites/depth-photo.html",
      "content": "<!--\n  vividsites · depth-photo\n  A photograph and its depth map rendered as a 2.5D scene you can look around.\n  The camera moves with the pointer (or orbits, or dollies in and out on its own);\n  near surfaces slide over far ones and properly cover them, because every pixel\n  ray-marches the depth field instead of being shifted flat. The depth map is\n  dilated on load, so when the camera reveals what was behind an edge, that gap\n  fills with the background stretched in, not with a smear of the object.\n  One depth is held still and sharp (the focus); everything nearer or further\n  drifts around it and softens with a real depth-of-field blur that never bleeds\n  sharp edges into the blur. Click or tap anywhere to refocus on that depth.\n\n  Markup:\n    data-src / data-depth                    the photo and its depth map (white = near)\n    data-src-portrait / data-depth-portrait  optional art-directed pair for tall frames\n    data-motion                              pointer | orbit | dolly\n  Customise:\n    --vs-depth-photo-strength   how far the camera travels (fraction of the width, .01–.06)\n    --vs-depth-photo-focus      depth held still and sharp, 0 = far … 1 = near\n    --vs-depth-photo-dof        depth-of-field blur away from the focus (0 = none … 1 = strong)\n    --vs-depth-photo-light      colour of the light that follows the pointer (and the dust motes)\n    --vs-depth-photo-glow       strength of that light (0 = off)\n    --vs-depth-photo-motes      number of dust motes (0 = none)\n    --vs-depth-photo-height     component height\n    --vs-depth-photo-radius     corner radius\n  JS:\n    el.vsDepthPhoto.set({ motion, strength, focus, dof })        any subset; eases to it\n    el.vsDepthPhoto.focusAt(x, y)                                  refocus on what is at x, y (0–1 of the frame)\n    el.vsDepthPhoto.load({ src, depth, srcPortrait, depthPortrait, alt })   crossfades through depth to a new photo\n  Keyboard: arrow keys look around, Enter racks focus between near and far.\n  Depth maps: any monocular depth model (Depth Anything, MiDaS) exports a greyscale map;\n  half the photo's resolution is plenty. Without WebGL 2 the photo shows as a still image.\n-->\n<div class=\"vs-depth-photo\" data-motion=\"pointer\" data-src=\"hero.webp\" data-depth=\"hero-depth.webp\" data-src-portrait=\"hero-m.webp\" data-depth-portrait=\"hero-m-depth.webp\">\n  <img class=\"vs-depth-photo-img\" src=\"hero.webp\" alt=\"An amber perfume bottle on a travertine block, lit by a shaft of light through velvet curtains\">\n  <canvas class=\"vs-depth-photo-canvas\" aria-hidden=\"true\"></canvas>\n  <i class=\"vs-depth-photo-af\" aria-hidden=\"true\"></i>\n</div>\n\n<style>\n  .vs-depth-photo {\n    --vs-depth-photo-strength: .036;\n    --vs-depth-photo-focus: .62;\n    --vs-depth-photo-dof: .55;\n    --vs-depth-photo-light: #ffc987;\n    --vs-depth-photo-glow: .4;\n    --vs-depth-photo-motes: 60;\n    --vs-depth-photo-height: min(80svh, 760px);\n    --vs-depth-photo-radius: 20px;\n    position: relative; width: 100%; height: var(--vs-depth-photo-height); min-height: 320px;\n    overflow: hidden; border-radius: var(--vs-depth-photo-radius); background: #0b0908;\n    isolation: isolate; touch-action: pan-y; -webkit-tap-highlight-color: transparent; cursor: crosshair; outline: none;\n  }\n  .vs-depth-photo::after { content: \"\"; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; z-index: 2;\n    box-shadow: inset 0 0 0 .5px rgba(255,255,255,.1), inset 0 1px 0 rgba(255,255,255,.06); }\n  .vs-depth-photo:focus-visible::after { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--vs-depth-photo-light) 80%, #fff); }\n  .vs-depth-photo-img, .vs-depth-photo-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n  .vs-depth-photo-img { object-fit: cover; transform: scale(1.08); }\n  .vs-depth-photo-canvas { opacity: 0; transition: opacity 900ms cubic-bezier(.16,1,.3,1); }\n  .vs-depth-photo[data-vs-state=\"ready\"] .vs-depth-photo-canvas { opacity: 1; }\n  /* the focus confirmation: camera AF brackets that snap in where you clicked */\n  .vs-depth-photo-af { position: absolute; z-index: 3; left: 50%; top: 50%; width: 54px; height: 54px; margin: -27px 0 0 -27px; pointer-events: none; opacity: 0;\n    --c: color-mix(in srgb, var(--vs-depth-photo-light) 70%, #fff);\n    background:\n      linear-gradient(var(--c) 0 0) 0 0 / 12px 1.5px, linear-gradient(var(--c) 0 0) 0 0 / 1.5px 12px,\n      linear-gradient(var(--c) 0 0) 100% 0 / 12px 1.5px, linear-gradient(var(--c) 0 0) 100% 0 / 1.5px 12px,\n      linear-gradient(var(--c) 0 0) 0 100% / 12px 1.5px, linear-gradient(var(--c) 0 0) 0 100% / 1.5px 12px,\n      linear-gradient(var(--c) 0 0) 100% 100% / 12px 1.5px, linear-gradient(var(--c) 0 0) 100% 100% / 1.5px 12px;\n    background-repeat: no-repeat; filter: drop-shadow(0 0 6px rgba(0,0,0,.5)); }\n  .vs-depth-photo-af.is-on { animation: vs-depth-photo-af 1.1s cubic-bezier(.16,1,.3,1) both; }\n  @keyframes vs-depth-photo-af { 0% { opacity: 0; transform: scale(1.7); } 18% { opacity: 1; transform: scale(.94); } 30% { transform: scale(1); } 70% { opacity: 1; } 100% { opacity: 0; transform: scale(1); } }\n  @media (prefers-reduced-motion: reduce) { .vs-depth-photo-af.is-on { animation-duration: .01s; } }\n</style>\n\n<script>\n(() => {\n  const VS = `#version 300 es\n  in vec2 aP; out vec2 vUv;\n  void main(){ vUv = aP*.5+.5; gl_Position = vec4(aP,0.,1.); }`;\n\n  /* For each screen pixel, walk candidate depths from near to far and take the first\n     surface that lands on this pixel: near surfaces win, so they occlude. A linear\n     refine between the last two steps removes banding. Then a 12-tap depth-aware\n     disc blur whose radius grows with distance from the focus depth. */\n  const FS = `#version 300 es\n  precision highp float;\n  uniform sampler2D uImgA, uDepA, uImgB, uDepB;\n  uniform vec2 uRes, uSizeA, uSizeB, uOff, uPtr;\n  uniform float uFocus, uZoom, uDolly, uMix, uGlow, uDof, uAspect;\n  uniform vec3 uLight;\n  in vec2 vUv; out vec4 o;\n\n  vec2 cover(vec2 uv, vec2 size){\n    float ra = uRes.x/uRes.y, ia = size.x/size.y;\n    vec2 s = ra > ia ? vec2(1., ia/ra) : vec2(ra/ia, 1.);\n    return (uv-.5)*s/uZoom + .5;\n  }\n  vec4 shade(sampler2D img, sampler2D dep, vec2 size){\n    vec2 uv = cover(vUv, size);\n    /* the camera: a sideways move (uOff) plus a push along the view axis (uDolly) */\n    vec2 off = uOff * vec2(1., uAspect) + (uv - .5) * uDolly;\n    const int N = 40;\n    float prevH = 1., prevDiff = -1e-3, h = 1.;\n    vec2 p = uv - off*(1.-uFocus);\n    for (int i = 0; i <= N; i++) {\n      h = 1. - float(i)/float(N);\n      p = uv - off*(h - uFocus);\n      float diff = textureLod(dep, p, 0.).r - h;\n      if (diff >= 0.) {\n        float t = prevDiff/(prevDiff - diff);\n        h = mix(prevH, h, clamp(t,0.,1.));\n        p = uv - off*(h - uFocus);\n        break;\n      }\n      prevH = h; prevDiff = diff;\n    }\n    p = clamp(p, vec2(0.), vec2(1.));\n    /* depth of field: taps on a golden-angle disc; nearer, sharper taps are down-weighted\n       so an in-focus edge never bleeds into the blur behind it */\n    float coc = clamp(abs(h - uFocus) * 1.6, 0., 1.) * uDof;\n    float rpx = coc * 16.;                                   /* blur radius in photo pixels */\n    vec3 c;\n    if (rpx < .35) c = textureGrad(img, p, dFdx(uv), dFdy(uv)).rgb;\n    else {\n      float lod = log2(max(1., rpx / 2.2));\n      vec3 acc = vec3(0.); float ws = 0.;\n      float rot = 6.2832 * fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(.06711056, .00583715))));   /* per-pixel spin: no ghost copies */\n      for (int k = 0; k < 12; k++) {\n        float fk = float(k);\n        float a = fk * 2.39996 + rot, r = sqrt((fk + .5) / 12.);\n        vec2 q = clamp(p + vec2(cos(a), sin(a)) * r * rpx / size, vec2(0.), vec2(1.));\n        float dq = textureLod(dep, q, 1.).r;\n        float w = mix(1., .08, smoothstep(.04, .14, dq - h) * (1. - clamp(abs(dq - uFocus) * 1.6, 0., 1.)));\n        acc += textureLod(img, q, lod).rgb * w; ws += w;\n      }\n      c = acc / ws;\n    }\n    /* a soft light that follows the pointer, shaped by a normal from the depth gradient */\n    vec2 e = 1.5/vec2(textureSize(dep,0));\n    float dx = textureLod(dep, p+vec2(e.x,0.), 1.).r - textureLod(dep, p-vec2(e.x,0.), 1.).r;\n    float dy = textureLod(dep, p+vec2(0.,e.y), 1.).r - textureLod(dep, p-vec2(0.,e.y), 1.).r;\n    vec3 n = normalize(vec3(-dx*9., -dy*9., 1.));\n    vec3 L = normalize(vec3(uPtr, .9));\n    float lam = max(dot(n, L), 0.);\n    float d = textureLod(dep, p, 1.).r;\n    float lum = dot(c, vec3(.2126,.7152,.0722));\n    vec2 q = vUv - (uPtr*.5+.5); q.x *= uRes.x/uRes.y;\n    float pool = exp(-dot(q,q)*3.2);\n    vec3 lit = c * (1. + uGlow*(.5*(lam-.72) + .55*pool*d)) + uLight*uGlow*.16*pool*d*(.35+lum);\n    return vec4(max(lit, 0.), h);\n  }\n  void main(){\n    vec4 a = shade(uImgA, uDepA, uSizeA);\n    vec3 c = a.rgb;\n    /* crossfade through depth: the new scene's near surfaces arrive first */\n    if (uMix < .999) c = mix(shade(uImgB, uDepB, uSizeB).rgb, a.rgb, smoothstep(0., 1., uMix*1.7 - (1.-a.a)*.7));\n    o = vec4(c, 1.);\n  }`;\n\n  /* dust: points with their own depth, moved by the same camera, hidden behind\n     anything nearer, and visible only where the photo itself is bright */\n  const MVS = `#version 300 es\n  in vec4 aM; /* x, y, depth, seed */\n  uniform sampler2D uImg, uDep;\n  uniform vec2 uRes, uSize, uOff;\n  uniform float uFocus, uZoom, uDolly, uTime, uDpr, uAspect, uDof;\n  out float vA; out float vBlur;\n  vec2 cover(vec2 uv){ float ra=uRes.x/uRes.y, ia=uSize.x/uSize.y;\n    vec2 s = ra > ia ? vec2(1., ia/ra) : vec2(ra/ia, 1.); return (uv-.5)*s/uZoom+.5; }\n  void main(){\n    float t = uTime*.018 + aM.w*40.;\n    vec2 s = fract(aM.xy + vec2(sin(t*1.7+aM.w*6.)*.012, -t*.35*(.4+aM.z)));\n    vec2 uv = cover(s);\n    vec2 off = uOff*vec2(1.,uAspect) + (s - .5)*uDolly;\n    vec2 scr = s + off*(aM.z - uFocus)*1.2;\n    float sceneD = textureLod(uDep, uv, 1.).r;\n    float lum = dot(textureLod(uImg, uv, 4.).rgb, vec3(.2126,.7152,.0722));\n    float vis = smoothstep(.02,-.04, sceneD - aM.z);\n    float tw = .55+.45*sin(uTime*1.3 + aM.w*31.);\n    vBlur = clamp(abs(aM.z - uFocus) * 1.6, 0., 1.) * uDof;\n    vA = vis * smoothstep(.16,.62,lum) * tw / (1. + vBlur * 3.);\n    gl_Position = vec4(scr*2.-1., 0., 1.);\n    gl_PointSize = (1.2 + aM.z*aM.z*4.2) * (1. + vBlur * 2.5) * uDpr;\n  }`;\n  const MFS = `#version 300 es\n  precision highp float; in float vA; in float vBlur; uniform vec3 uLight; out vec4 o;\n  void main(){ vec2 c = gl_PointCoord-.5; float r = max(0., 1.-dot(c,c)*4.); r = mix(r*r, sqrt(r), vBlur);\n    o = vec4(mix(uLight, vec3(1.), .45)*r*vA*.8, 0.); }`;\n\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const MOTIONS = ['pointer', 'orbit', 'dolly'];\n  const loadImg = src => new Promise((res, rej) => { const i = new Image(); i.decoding = 'async'; i.onload = () => res(i); i.onerror = rej; i.src = src; });\n\n  /* dilate the depth map (grow near surfaces a few pixels past their silhouette), so\n     the slope the camera reveals lies over background pixels: the gap fills with the\n     background stretched in rather than the object's edge smeared out */\n  function prepDepth(img) {\n    const w = img.naturalWidth, h = img.naturalHeight;\n    const cv = document.createElement('canvas'); cv.width = w; cv.height = h;\n    const x = cv.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0);\n    const id = x.getImageData(0, 0, w, h), px = id.data;\n    const a = new Uint8Array(w * h), b = new Uint8Array(w * h);\n    for (let i = 0; i < w * h; i++) a[i] = px[i * 4];\n    const R = Math.max(2, Math.round(w * .007));\n    for (let y = 0; y < h; y++) { const o = y * w; for (let i = 0; i < w; i++) { let m = 0; for (let k = Math.max(0, i - R), e = Math.min(w - 1, i + R); k <= e; k++) if (a[o + k] > m) m = a[o + k]; b[o + i] = m; } }\n    for (let i = 0; i < w; i++) for (let y = 0; y < h; y++) { let m = 0; for (let k = Math.max(0, y - R), e = Math.min(h - 1, y + R); k <= e; k++) if (b[k * w + i] > m) m = b[k * w + i]; a[y * w + i] = m; }\n    for (let i = 0; i < w * h; i++) { px[i * 4] = px[i * 4 + 1] = px[i * 4 + 2] = a[i]; px[i * 4 + 3] = 255; }\n    x.putImageData(id, 0, 0);\n    return { canvas: cv, data: a, w, h };\n  }\n\n  function mount(root) {\n    if (root.vsDepthPhoto) return;\n    const canvas = root.querySelector('.vs-depth-photo-canvas');\n    const img = root.querySelector('.vs-depth-photo-img');\n    const af = root.querySelector('.vs-depth-photo-af');\n    const css = n => getComputedStyle(root).getPropertyValue(n).trim();\n    const num = (n, d) => { const v = parseFloat(css(n)); return Number.isFinite(v) ? v : d; };\n    const hex = n => { const h = (css(n) || '#ffc987').replace('#',''); const f = h.length === 3 ? h.split('').map(c => c+c).join('') : h;\n      return [0,2,4].map(i => parseInt(f.slice(i,i+2),16)/255); };\n\n    let gl, prog, mprog, vao, mvao, W = 1, H = 1, dpr = 1, raf = 0, visible = true, lost = false;\n    let A = null, B = null, mix = 1, mixT = 0;\n    const t0 = performance.now();\n    const ptr = { tx: 0, ty: 0, x: 0, y: 0, vx: 0, vy: 0 };\n    const cam = { x: 0, y: 0, z: 0, vx: 0, vy: 0, vz: 0 };\n    const opt = { motion: MOTIONS.includes(root.dataset.motion) ? root.dataset.motion : 'pointer', strength: num('--vs-depth-photo-strength', .036), dof: num('--vs-depth-photo-dof', .55) };\n    const foc = { x: num('--vs-depth-photo-focus', .62), v: 0, t: num('--vs-depth-photo-focus', .62) };\n    let zoom = 1, lastMove = -1e9;\n    const want = () => {\n      const portrait = root.clientWidth / Math.max(1, root.clientHeight) < .82 && root.dataset.srcPortrait;\n      return portrait ? [root.dataset.srcPortrait, root.dataset.depthPortrait || root.dataset.depth] : [root.dataset.src, root.dataset.depth];\n    };\n    let current = want().join('|');\n\n    gl = canvas.getContext('webgl2', { antialias: false, alpha: false, premultipliedAlpha: false, powerPreference: 'high-performance' });\n    if (!gl) { root.dataset.vsState = 'nogl'; return; }\n    root.tabIndex = root.tabIndex >= 0 ? root.tabIndex : 0;\n    root.setAttribute('role', 'img');\n    const label = () => root.setAttribute('aria-label', (img.alt || 'Photo') + '. A 3D depth view: move the pointer or use the arrow keys to look around; click or press Enter to refocus.');\n    label();\n\n    function sh(type, src) { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);\n      if (!gl.getShaderParameter(s, gl.COMPILE_STATUS) && !gl.isContextLost()) throw new Error(gl.getShaderInfoLog(s)); return s; }\n    function program(vs, fs) { const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs));\n      gl.linkProgram(p); const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);\n      for (let i = 0; i < n; i++) { const k = gl.getActiveUniform(p, i).name; u[k] = gl.getUniformLocation(p, k); } return { p, u }; }\n    function tex(source) {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);\n      gl.generateMipmap(gl.TEXTURE_2D);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_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 t;\n    }\n    let motesN = 0;\n    function init() {\n      prog = program(VS, FS); mprog = program(MVS, MFS);\n      vao = gl.createVertexArray(); gl.bindVertexArray(vao);\n      const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);\n      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);\n      gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n      motesN = Math.max(0, Math.min(400, Math.round(num('--vs-depth-photo-motes', 60))));\n      mvao = gl.createVertexArray(); gl.bindVertexArray(mvao);\n      const m = new Float32Array(Math.max(1, motesN) * 4);\n      for (let i = 0; i < motesN; i++) { m[i*4] = Math.random(); m[i*4+1] = Math.random(); m[i*4+2] = .35 + Math.random()*.65; m[i*4+3] = Math.random(); }\n      const mb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, mb); gl.bufferData(gl.ARRAY_BUFFER, m, gl.STATIC_DRAW);\n      const loc = gl.getAttribLocation(mprog.p, 'aM'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, 0, 0);\n      gl.bindVertexArray(null);\n    }\n    async function makeSet(src, depth) {\n      const [i, d] = await Promise.all([loadImg(src), loadImg(depth)]);\n      const dd = prepDepth(d);\n      return { i, dd, ti: tex(i), td: tex(dd.canvas), size: [i.naturalWidth, i.naturalHeight] };\n    }\n    function resize() {\n      dpr = Math.min(2, devicePixelRatio || 1);\n      W = Math.max(1, Math.round(root.clientWidth * dpr)); H = Math.max(1, Math.round(root.clientHeight * dpr));\n      if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; }\n    }\n\n    /* the depth under a point of the frame (0–1, top-left origin), averaged over a few pixels */\n    function depthAt(fx, fy) {\n      if (!A) return foc.t;\n      const ra = W / H, ia = A.size[0] / A.size[1];\n      const s = ra > ia ? [1, ia / ra] : [ra / ia, 1];\n      const u = (fx - .5) * s[0] / zoom + .5, v = (fy - .5) * s[1] / zoom + .5;\n      const { data, w, h } = A.dd; let sum = 0, n = 0;\n      for (let j = -2; j <= 2; j++) for (let i = -2; i <= 2; i++) {\n        const x = Math.min(w - 1, Math.max(0, Math.round(u * w) + i * 2)), y = Math.min(h - 1, Math.max(0, Math.round(v * h) + j * 2));\n        sum += data[y * w + x]; n++;\n      }\n      return sum / n / 255;\n    }\n    function focusAt(fx, fy, flash = true) {\n      foc.t = Math.min(.98, Math.max(.02, depthAt(fx, fy)));\n      if (reduce.matches) foc.x = foc.t;\n      root.style.setProperty('--vs-depth-photo-focus', foc.t.toFixed(3));\n      if (flash && af) { af.style.left = fx * 100 + '%'; af.style.top = fy * 100 + '%'; af.classList.remove('is-on'); void af.offsetWidth; af.classList.add('is-on'); }\n      kick();\n    }\n\n    function frame(now) {\n      raf = 0;\n      if (lost || !A) return;\n      const dt = Math.min(.05, (now - (frame.last || now)) / 1000); frame.last = now;\n      const still = reduce.matches, tt = (now - t0) / 1000;\n      /* pointer, smoothed */\n      ptr.vx += ((ptr.tx - ptr.x) * 70 - ptr.vx * 17) * dt; ptr.vy += ((ptr.ty - ptr.y) * 70 - ptr.vy * 17) * dt;\n      ptr.x += ptr.vx * dt; ptr.y += ptr.vy * dt;\n      /* where the camera wants to be, per motion mode */\n      let gx = 0, gy = 0, gz = 0;\n      const idle = now - lastMove > 2600;\n      if (!still) {\n        if (opt.motion === 'orbit') { const a = tt * .85; gx = Math.cos(a) * .85 + ptr.x * .2; gy = Math.sin(a) * .5 + ptr.y * .15; }\n        else if (opt.motion === 'dolly') { gz = .5 - .5 * Math.cos(tt * .75); gx = ptr.x * .4 + Math.sin(tt * .37) * .12; gy = ptr.y * .25; }\n        else if (idle) { gx = Math.sin(tt * .31) * .45; gy = Math.sin(tt * .23 + 1) * .25; }\n        else { gx = ptr.x; gy = ptr.y * .8; }\n      }\n      /* the camera rides a spring (k 42, c 13): interruptible and a little weighty */\n      cam.vx += ((gx - cam.x) * 42 - cam.vx * 13) * dt; cam.x += cam.vx * dt;\n      cam.vy += ((gy - cam.y) * 42 - cam.vy * 13) * dt; cam.y += cam.vy * dt;\n      cam.vz += ((gz - cam.z) * 30 - cam.vz * 11) * dt; cam.z += cam.vz * dt;\n      if (still) { cam.x = cam.y = cam.z = 0; }\n      /* focus racks on a spring (k 60, c 15) */\n      foc.v += ((foc.t - foc.x) * 60 - foc.v * 15) * dt; foc.x += foc.v * dt;\n      if (still) foc.x = foc.t;\n      if (mix < 1) mix = Math.min(1, (now - mixT) / 1100);\n      const e = mix < 1 ? 1 - Math.pow(1 - mix, 3) : 1;\n\n      const strength = opt.strength, dollyMax = opt.motion === 'dolly' ? strength * 2.4 : 0;\n      /* crop just enough that the moving camera never sees past the photo's edges */\n      const rel = Math.max(foc.t, 1 - foc.t) * 1.08;            /* the most any depth can drift from the focus */\n      const margin = strength * rel * Math.max(1, .8 * W / H) + dollyMax * .5 * rel;\n      const zt = 1 / Math.max(.6, 1 - 2 * margin);\n      zoom += (zt - zoom) * (1 - Math.exp(-dt * 6));\n      const dof = opt.dof;\n\n      gl.viewport(0, 0, W, H);\n      gl.useProgram(prog.p); gl.bindVertexArray(vao);\n      const u = prog.u; const light = hex('--vs-depth-photo-light');\n      const bind = (unit, t, name) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t); gl.uniform1i(u[name], unit); };\n      bind(0, A.ti, 'uImgA'); bind(1, A.td, 'uDepA');\n      const b = B || A; bind(2, b.ti, 'uImgB'); bind(3, b.td, 'uDepB');\n      gl.uniform2f(u.uRes, W, H); gl.uniform2f(u.uSizeA, ...A.size); gl.uniform2f(u.uSizeB, ...b.size);\n      const off = [cam.x * strength, cam.y * strength];\n      gl.uniform2f(u.uOff, off[0], off[1]); gl.uniform2f(u.uPtr, ptr.x, ptr.y);\n      gl.uniform1f(u.uAspect, W / H); gl.uniform1f(u.uFocus, foc.x); gl.uniform1f(u.uZoom, zoom); gl.uniform1f(u.uDolly, cam.z * dollyMax);\n      gl.uniform1f(u.uMix, e); gl.uniform1f(u.uGlow, num('--vs-depth-photo-glow', .4)); gl.uniform1f(u.uDof, dof); gl.uniform3f(u.uLight, ...light);\n      gl.disable(gl.BLEND); gl.drawArrays(gl.TRIANGLES, 0, 3);\n\n      if (motesN) {\n        gl.useProgram(mprog.p); gl.bindVertexArray(mvao); const m = mprog.u;\n        gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, A.ti); gl.uniform1i(m.uImg, 0);\n        gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, A.td); gl.uniform1i(m.uDep, 1);\n        gl.uniform2f(m.uRes, W, H); gl.uniform2f(m.uSize, ...A.size); gl.uniform2f(m.uOff, off[0], off[1]);\n        gl.uniform1f(m.uFocus, foc.x); gl.uniform1f(m.uZoom, zoom); gl.uniform1f(m.uDolly, cam.z * dollyMax); gl.uniform1f(m.uTime, still ? 0 : tt);\n        gl.uniform1f(m.uDpr, dpr); gl.uniform1f(m.uAspect, W / H); gl.uniform1f(m.uDof, dof); gl.uniform3f(m.uLight, ...light);\n        gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE); gl.drawArrays(gl.POINTS, 0, motesN);\n      }\n      if (root.dataset.vsState !== 'ready') root.dataset.vsState = 'ready';\n      const busy = Math.abs(foc.t - foc.x) + Math.abs(foc.v) > 1e-4 || mix < 1 || Math.abs(zt - zoom) > 1e-4;\n      if (visible && (!still || busy)) raf = requestAnimationFrame(frame);\n    }\n    const kick = () => { if (!raf && visible && !lost) { frame.last = 0; raf = requestAnimationFrame(frame); } };\n\n    async function load(src, depth) {\n      const set = await makeSet(src, depth);\n      if (!A) { A = set; mix = 1; }\n      else { B = A; A = set; mix = 0; mixT = performance.now(); }\n      img.src = src; kick();\n    }\n\n    const at = e => { const r = root.getBoundingClientRect(); return [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height]; };\n    let down = null;\n    function onMove(e) {\n      const [fx, fy] = at(e);\n      ptr.tx = Math.max(-1, Math.min(1, fx * 2 - 1)); ptr.ty = Math.max(-1, Math.min(1, -(fy * 2 - 1)));\n      lastMove = performance.now(); kick();\n    }\n    root.addEventListener('pointermove', onMove, { passive: true });\n    root.addEventListener('pointerdown', e => { onMove(e); down = { x: e.clientX, y: e.clientY, t: performance.now() }; }, { passive: true });\n    root.addEventListener('pointerup', e => {\n      if (down && Math.hypot(e.clientX - down.x, e.clientY - down.y) < 8 && performance.now() - down.t < 500) { const [fx, fy] = at(e); focusAt(fx, fy); }\n      down = null;\n    });\n    root.addEventListener('pointerleave', () => { ptr.tx = 0; ptr.ty = 0; down = null; }, { passive: true });\n    root.addEventListener('keydown', e => {\n      const k = { ArrowLeft: [-.7, 0], ArrowRight: [.7, 0], ArrowUp: [0, .7], ArrowDown: [0, -.7] }[e.key];\n      if (k) { e.preventDefault(); ptr.tx = Math.max(-1, Math.min(1, ptr.tx + k[0])); ptr.ty = Math.max(-1, Math.min(1, ptr.ty + k[1])); lastMove = performance.now(); kick(); }\n      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); root.vsDepthPhoto.set({ focus: foc.t > .45 ? .12 : .8 }); }\n    });\n\n    canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; cancelAnimationFrame(raf); raf = 0; root.dataset.vsState = 'lost'; });\n    canvas.addEventListener('webglcontextrestored', () => {\n      lost = false; init();\n      for (const s of [A, B]) if (s) { s.ti = tex(s.i); s.td = tex(s.dd.canvas); }\n      kick();\n    });\n    new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) kick(); }).observe(root);\n    document.addEventListener('visibilitychange', () => { visible = !document.hidden; if (visible) kick(); });\n    new ResizeObserver(() => {\n      resize(); const w = want(); if (w.join('|') !== current) { current = w.join('|'); load(w[0], w[1]).catch(() => {}); }\n      kick();\n    }).observe(root);\n    reduce.addEventListener?.('change', kick);\n\n    try { init(); } catch (err) { root.dataset.vsState = 'nogl'; console.warn('[depth-photo]', err.message); return; }\n    resize();\n    const [s0, d0] = want();\n    load(s0, d0).catch(() => { root.dataset.vsState = 'nogl'; });\n\n    root.vsDepthPhoto = {\n      set(o = {}) {\n        if (o.motion && MOTIONS.includes(o.motion)) { opt.motion = o.motion; root.dataset.motion = o.motion; }\n        if (o.strength != null) { opt.strength = Math.max(0, +o.strength); root.style.setProperty('--vs-depth-photo-strength', opt.strength); }\n        if (o.dof != null) { opt.dof = Math.max(0, +o.dof); root.style.setProperty('--vs-depth-photo-dof', opt.dof); }\n        if (o.focus != null) { foc.t = Math.min(1, Math.max(0, +o.focus)); if (reduce.matches) foc.x = foc.t; root.style.setProperty('--vs-depth-photo-focus', foc.t); }\n        kick(); return { motion: opt.motion, strength: opt.strength, dof: opt.dof, focus: foc.t };\n      },\n      focusAt: (x, y) => focusAt(x, y),\n      load(set) {\n        root.dataset.src = set.src; root.dataset.depth = set.depth;\n        if (set.srcPortrait) { root.dataset.srcPortrait = set.srcPortrait; root.dataset.depthPortrait = set.depthPortrait || set.depth; }\n        else { delete root.dataset.srcPortrait; delete root.dataset.depthPortrait; }\n        if (set.alt) { img.alt = set.alt; label(); }\n        const w = want(); current = w.join('|'); return load(w[0], w[1]).catch(() => {});\n      },\n    };\n  }\n  const boot = () => document.querySelectorAll('.vs-depth-photo').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D effects",
    "tags": [
      "depth map",
      "parallax",
      "2.5d",
      "photo",
      "depth of field",
      "focus",
      "pointer",
      "orbit",
      "dolly"
    ],
    "tech": [
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [],
    "source": "sillage",
    "free": true,
    "accent": "#ffc987",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/depth-photo/index.html",
    "thumbnail": "https://vividsites.app/api/component/depth-photo/thumb.jpg",
    "video": "https://vividsites.app/api/component/depth-photo/thumb.mp4",
    "page": "https://vividsites.app/c/depth-photo"
  }
}