// A|B HDR EXR checkpoint comparison viewer. // Two HDRRenderer instances (one per checkpoint) share one grade; a CSS wipe // or side-by-side layout compares them. Pure static, no build step. import { HDRRenderer, DEFAULT_PARAMS, decodeExrRgb, initExrs } from './exr-viewer-lib.js'; const BASE = './clips'; const $ = (s) => document.querySelector(s); let manifest = null; let clips = []; let currentClip = null; let currentFrame = 0; let rendererA, rendererB; let dataA = null, dataB = null; // {rgb, w, h} for pixel probe let params = structuredClone(DEFAULT_PARAMS); let sideMap = { A: 'curated', B: 'contrast' }; // checkpoint shown on each side let wipe = 0.5; let sideBySide = false; let loading = false; let playTimer = null; // checkpoint id -> subdir + pretty label, from manifest.checkpoints function ckptDir(id) { return manifest.checkpoints[id].dir; } function ckptLabel(id) { return manifest.checkpoints[id].label; } // Sources available for a clip (per-clip `sources`, else all checkpoints). function clipSources(clip) { const all = Object.keys(manifest.checkpoints); if (!clip || !Array.isArray(clip.sources)) return all; return clip.sources.filter((id) => all.includes(id)); } // Populate the A/B source dropdowns for the current clip and keep sideMap valid. function populateSelectors() { const srcs = clipSources(currentClip); if (!srcs.includes(sideMap.A)) sideMap.A = srcs[0]; if (!srcs.includes(sideMap.B)) sideMap.B = srcs[Math.min(1, srcs.length - 1)]; for (const [selId, side] of [['#selA', 'A'], ['#selB', 'B']]) { const sel = $(selId); sel.innerHTML = ''; srcs.forEach((id) => { const o = document.createElement('option'); o.value = id; o.textContent = ckptLabel(id); sel.appendChild(o); }); sel.value = sideMap[side]; } } function exrUrl(clipId, ckptId, frame) { return `${BASE}/${clipId}/${ckptDir(ckptId)}/frame_${String(frame).padStart(5, '0')}.exr`; } function thumbUrl(clipId) { return `${BASE}/${clipId}/thumbnail.jpg`; } async function fetchDecode(url) { const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status} ${url}`); const buf = new Uint8Array(await resp.arrayBuffer()); const dec = decodeExrRgb(buf); // { width, height, interleavedRgbPixels } return { rgb: dec.interleavedRgbPixels, w: dec.width, h: dec.height }; } async function loadFrame(clipId, frame) { if (loading) return; loading = true; $('#readout').textContent = 'Loading EXR…'; try { const [a, b] = await Promise.all([ fetchDecode(exrUrl(clipId, sideMap.A, frame)), fetchDecode(exrUrl(clipId, sideMap.B, frame)), ]); dataA = a; dataB = b; rendererA.uploadImage(a.rgb, a.w, a.h); rendererB.uploadImage(b.rgb, b.w, b.h); // Lock the stage aspect ratio to the image so both canvases overlay exactly. $('#stage').style.aspectRatio = `${a.w} / ${a.h}`; $('#resInfo').textContent = `${a.w}×${a.h} · ${clipId} · frame ${frame}`; renderBoth(); $('#readout').textContent = 'Ctrl+click the image to probe linear RGB.'; } catch (e) { $('#readout').textContent = `Error: ${e.message}`; } loading = false; } function renderBoth() { if (dataA) rendererA.render(params); if (dataB) rendererB.render(params); } function applyWipe() { const stage = $('#stage'); const holderB = $('#holderB'); const divider = $('#divider'); if (sideBySide) { stage.classList.add('sidebyside'); holderB.style.clipPath = ''; return; } stage.classList.remove('sidebyside'); // B occupies the right of the wipe; A shows through on the left. holderB.style.clipPath = `inset(0 0 0 ${wipe * 100}%)`; divider.style.left = `${wipe * 100}%`; } function updateLabels() { $('#tagA').textContent = `A ◀ ${ckptLabel(sideMap.A)}`; $('#tagB').textContent = `${ckptLabel(sideMap.B)} ▶ B`; $('#lblA').textContent = `A · ${ckptLabel(sideMap.A)}`; $('#lblB').textContent = `B · ${ckptLabel(sideMap.B)}`; } // ── Pixel probe ────────────────────────────────────────── function probe(ev) { if (!dataA) return; const stage = $('#stage'); const rect = stage.getBoundingClientRect(); const nx = (ev.clientX - rect.left) / rect.width; const ny = (ev.clientY - rect.top) / rect.height; if (nx < 0 || nx > 1 || ny < 0 || ny > 1) return; // In wipe mode, pick the side under the cursor; in side-by-side, left half = A. const onLeft = sideBySide ? nx < 0.5 : nx < wipe; const d = onLeft ? dataA : dataB; const which = onLeft ? `A (${ckptLabel(sideMap.A)})` : `B (${ckptLabel(sideMap.B)})`; let lx = nx; if (sideBySide) lx = onLeft ? nx * 2 : (nx - 0.5) * 2; const px = Math.min(d.w - 1, Math.max(0, Math.floor(lx * d.w))); const py = Math.min(d.h - 1, Math.max(0, Math.floor(ny * d.h))); const i = (py * d.w + px) * 3; const r = d.rgb[i], g = d.rgb[i + 1], bch = d.rgb[i + 2]; const luma = 0.2126 * r + 0.7152 * g + 0.0722 * bch; const evStops = luma > 0 ? Math.log2(luma).toFixed(2) : '-inf'; $('#readout').textContent = `${which} px(${px},${py})\n` + `linear RGB ${r.toFixed(4)} ${g.toFixed(4)} ${bch.toFixed(4)}\n` + `luma ${luma.toFixed(4)} ${evStops} EV max ${Math.max(r, g, bch).toFixed(4)}`; } // ── UI wiring ──────────────────────────────────────────── function buildClipGrid() { const grid = $('#clip-grid'); grid.innerHTML = ''; clips.forEach((c, idx) => { const item = document.createElement('div'); item.className = 'clip-item' + (idx === 0 ? ' active' : ''); item.innerHTML = `
${c.label}
`; item.addEventListener('click', () => selectClip(idx)); grid.appendChild(item); }); } function selectClip(idx) { currentClip = clips[idx]; currentFrame = 0; const fs = $('#frame'); fs.max = String(currentClip.frames - 1); fs.value = '0'; $('#frameVal').textContent = `0 / ${currentClip.frames - 1}`; document.querySelectorAll('.clip-item').forEach((el, i) => el.classList.toggle('active', i === idx)); populateSelectors(); updateLabels(); loadFrame(currentClip.id, 0); } function setFrame(f) { if (!currentClip) return; currentFrame = Math.max(0, Math.min(currentClip.frames - 1, f | 0)); $('#frame').value = String(currentFrame); $('#frameVal').textContent = `${currentFrame} / ${currentClip.frames - 1}`; loadFrame(currentClip.id, currentFrame); } function wireUI() { $('#ev').addEventListener('input', (e) => { params.exposure = parseFloat(e.target.value); $('#evVal').textContent = `${params.exposure.toFixed(1)} EV`; renderBoth(); }); $('#tm').addEventListener('change', (e) => { params.toneMapping = parseInt(e.target.value, 10); renderBoth(); }); $('#fc').addEventListener('change', (e) => { params.falseColor = e.target.checked; renderBoth(); }); $('#resetBtn').addEventListener('click', () => { const expo = params.exposure, tm = params.toneMapping, fc = params.falseColor; params = structuredClone(DEFAULT_PARAMS); params.exposure = expo; params.toneMapping = tm; params.falseColor = fc; renderBoth(); }); $('#selA').addEventListener('change', (e) => { sideMap.A = e.target.value; updateLabels(); if (currentClip) loadFrame(currentClip.id, currentFrame); }); $('#selB').addEventListener('change', (e) => { sideMap.B = e.target.value; updateLabels(); if (currentClip) loadFrame(currentClip.id, currentFrame); }); $('#swapBtn').addEventListener('click', () => { sideMap = { A: sideMap.B, B: sideMap.A }; $('#selA').value = sideMap.A; $('#selB').value = sideMap.B; updateLabels(); if (currentClip) loadFrame(currentClip.id, currentFrame); }); $('#modeBtn').addEventListener('click', (e) => { sideBySide = !sideBySide; e.target.textContent = sideBySide ? '▤ Wipe' : '▥ Side-by-side'; applyWipe(); }); $('#prevBtn').addEventListener('click', () => setFrame(currentFrame - 1)); $('#nextBtn').addEventListener('click', () => setFrame(currentFrame + 1)); $('#frame').addEventListener('input', (e) => setFrame(parseInt(e.target.value, 10))); $('#playBtn').addEventListener('click', (e) => { if (playTimer) { clearInterval(playTimer); playTimer = null; e.target.textContent = '▶ Play'; return; } e.target.textContent = '⏸ Pause'; playTimer = setInterval(() => { if (loading) return; let n = currentFrame + 1; if (n > currentClip.frames - 1) n = 0; setFrame(n); }, 120); }); // Divider drag (wipe) const divider = $('#divider'); const stage = $('#stage'); let dragging = false; const onMove = (clientX) => { const rect = stage.getBoundingClientRect(); wipe = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); applyWipe(); }; divider.addEventListener('mousedown', () => { dragging = true; }); window.addEventListener('mousemove', (e) => { if (dragging) onMove(e.clientX); }); window.addEventListener('mouseup', () => { dragging = false; }); // Ctrl+click probe stage.addEventListener('click', (e) => { if (e.ctrlKey || e.metaKey) probe(e); }); // Keyboard frame nav window.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') setFrame(currentFrame - 1); if (e.key === 'ArrowRight') setFrame(currentFrame + 1); }); } async function main() { await initExrs(); manifest = await (await fetch(`${BASE}/clips.json`)).json(); clips = manifest.clips; rendererA = new HDRRenderer($('#cvA')); rendererB = new HDRRenderer($('#cvB')); buildClipGrid(); wireUI(); updateLabels(); applyWipe(); if (clips.length) selectClip(0); } main().catch((e) => { $('#readout').textContent = `Init error: ${e.message}`; console.error(e); });