// 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 = `