/* exhibit.js — main viewer (v3). * Pipeline: camera video -> ArUco detect (maxHamming 0) -> TRACKING ENGINE -> Three.js * camera placed in world -> server-driven ghosts + building occlusion meshes. * * v3 ships two runtime-switchable tracking engines (HUD overlay, tap top third): * opencv (default) — joint multi-marker board solve (solve-cv.js). All detected * corners -> one solvePnP -> camera pose. A non-coplanar * crest removes planar ambiguity outright. * posit — the v2 per-marker POS-IT + fuseWorld path, kept intact for * on-table A/B until the new engine is confirmed. Delete later. */ import * as THREE from 'three'; import { createDetector, detectMarkers, quadArea } from './ar/detect.js'; import { PoseEstimator, ROT_MODES, setRotMode, getRotMode } from './ar/pose.js'; import { WorldFuser } from './ar/fuse.js'; import { CvBoardSolver, CV_AXIS_MODES } from './ar/solve-cv.js'; import { buildGhost } from './ghosts/loader.js'; import { ghostTransform } from './ghosts/behavior.js'; import { ExhibitNet, installErrorReporter } from './net.js'; import { renderMarkdown } from './md.js'; const $ = (s) => document.querySelector(s); /* ---------------- tracking engines ---------------- */ const ENGINES = ['opencv', 'posit']; let engine = localStorage.getItem('nbx.engine') || 'opencv'; if (!ENGINES.includes(engine)) engine = 'opencv'; let cvState = 'idle'; // idle | loading | ready | failed function setEngine(e) { engine = e; localStorage.setItem('nbx.engine', e); } /* Camera-frame correction for the POSIT path (unchanged from v2; the opencv path * needs none — its conversion was validated synthetically and has its own * axis-mode toggle in solve-cv.js for marker-frame insurance). */ const CAM_CORR = { none: new THREE.Quaternion(), y180: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), Math.PI), x180: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI), z180: new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI), }; const CAM_MODES = ['none', 'y180', 'x180', 'z180']; let camMode = 'none'; /* Diagnostic: freeze all ghost motion (bob/sway/wander/path) so ghosts sit at their * static spawn position — tells tracking jitter apart from the float animation. * Defaults ON in dev; toggle with the "Motion" button (or the tracking chip). */ let freezeGhosts = false; function setFreeze(v) { freezeGhosts = v; const b = $('#motionBtn'); if (b) { b.textContent = `Motion: ${v ? 'frozen' : 'on'}`; b.classList.toggle('frozen', v); } } let info = { mode: 'dev' }; let scene3, camera3, renderer, video, canvas2d, ctx2d; let detector, poseEst, fuser, cvSolver; let gradients = {}, modelManifest = null, characters = { defaults: {}, byId: {} }; let ghostHeight = 4; // cm, from scene.ghostHeightCm const activeGhosts = new Map(); // uid -> { rec, group } let tracking = { markers: 0, raw: 0, rawIds: [], dupes: 0, lastSeen: 0 }; let netRef = null; let sceneLoaded = false; let anchorCount = 0; const rxLog = []; // last few WS message types, newest last function rxTrace(t) { rxLog.push(t); if (rxLog.length > 6) rxLog.shift(); } const clockOffsetSamples = []; let clockOffset = 0; // serverNow - clientNow /* Field-of-view assumption behind every focal estimate; override per device via * localStorage 'nbx.hfovDeg' (shared by both engines). * * IT APPLIES TO THE LONG AXIS OF THE IMAGE, NOT TO videoWidth. iOS can hand back * a PORTRAIT stream (720x1280), and a phone lens has its wide field across the * sensor's long side either way. Assuming the angle spanned videoWidth made the * focal length 1280/720 = 1.78x too short on portrait streams — the exact factor * measured on device, and the reason ghosts still slid around after the camera * model was "fixed". */ const HFOV_DEG = parseFloat(localStorage.getItem('nbx.hfovDeg')) || 60; const focalPxFor = (w, h) => (Math.max(w, h) / 2) / Math.tan(THREE.MathUtils.degToRad(HFOV_DEG) / 2); /* Detection cadence. Marker detection (getImageData + ArUco decode over a 960px * frame) is by far the most expensive thing per frame, and running it on every * rAF tick pins the CPU for no benefit — the crests aren't moving, the phone is. * ~20Hz tracks hand movement fine and the 1e filter smooths between updates, * while rendering stays at full frame rate. Override: localStorage 'nbx.detectHz'. */ const DETECT_HZ = parseFloat(localStorage.getItem('nbx.detectHz')) || 20; /* Long-edge resolution of the detection canvas. At 960 a 60mm crest filling the * usual framing already gets ~23px per marker cell, well above ArUco's needs — * so raising this is NOT an obvious cure for crests that fail to decode, and it * costs CPU roughly linearly (960 -> 1280 is +78% pixels). Exposed as a knob to * test that empirically rather than assume: localStorage 'nbx.detectLong'. */ const DETECT_LONG = parseFloat(localStorage.getItem('nbx.detectLong')) || 960; const DETECT_INTERVAL_MS = 1000 / DETECT_HZ; let lastDetectAt = 0; let fps = 0, fpsFrames = 0, fpsAt = 0; // Debug pose readout (dev mode) const _dbgEuler = new THREE.Euler(); const _dbgV = new THREE.Vector3(); let dbgEl = null; let dbgPose = ''; function dbgStatus() { const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none'; return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` + `scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n` + `${fps} fps detect ${DETECT_HZ}Hz@${DETECT_LONG} fov ${camera3 ? camera3.fov.toFixed(0) : '?'}` + `${cvSolver && cvSolver.lastReject ? ' REJ:' + cvSolver.lastReject : ''}\n`; } function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; } /* Aim check: the pose is only self-consistent if the marker we can SEE in the * camera frame sits where the solved pose projects it. Compares the marker's * measured pixel centre against its projected position, and reports the ghost's * on-screen NDC. |ndc| < 1 means the ghost is inside the frustum. * px = where the crest actually is on screen (-1..1, 0 = centre) * proj= where the pose says it should be * Those two disagreeing is a camera-model or frame-convention error, not * tracking noise: a sign flip means a mirrored axis, a constant ratio means the * projection does not match the lens. */ let dbgAim = ''; const _aimV = new THREE.Vector3(); function updateAim(markers, W, H) { if (!cvSolver) return; let s = ''; const m0 = markers && markers[0]; if (m0) { const a = cvSolver.anchors.get(m0.id); let cxp = 0, cyp = 0; for (const c of m0.corners) { cxp += c.x; cyp += c.y; } // full-frame NDC, then rescaled into viewport NDC so it is directly // comparable with proj (the cropped sides are not on screen) cxp = ((cxp / 4) / W * 2 - 1) / _crop.x; cyp = -((cyp / 4) / H * 2 - 1) / _crop.y; s += `m${m0.id} px(${cxp.toFixed(2)},${cyp.toFixed(2)})`; if (a) { _aimV.setFromMatrixPosition(a.mat).project(camera3); s += ` proj(${_aimV.x.toFixed(2)},${_aimV.y.toFixed(2)})`; } s += '\n'; } const first = activeGhosts.values().next().value; if (first) { _aimV.copy(first.group.position).project(camera3); const onScreen = Math.abs(_aimV.x) < 1 && Math.abs(_aimV.y) < 1 && _aimV.z < 1; s += `ghost ndc(${_aimV.x.toFixed(2)},${_aimV.y.toFixed(2)}) ${onScreen ? 'ON-SCREEN' : 'off-screen'}`; } dbgAim = s; } function updateDbg(fusedQuat, markerCount, extra) { if (!dbgEl) return; const deg = r => (r * 180 / Math.PI).toFixed(0).padStart(4); _dbgEuler.setFromQuaternion(fusedQuat, 'YXZ'); const rawLine = `raw P${deg(_dbgEuler.x)} Y${deg(_dbgEuler.y)} R${deg(_dbgEuler.z)}`; const view = _dbgV.set(0, 0, -1).applyQuaternion(camera3.quaternion).clone(); const up = _dbgV.set(0, 1, 0).applyQuaternion(camera3.quaternion).clone(); const v3 = v => `(${v.x.toFixed(2)},${v.y.toFixed(2)},${v.z.toFixed(2)})`; const modeLine = engine === 'opencv' ? `axis ${cvSolver ? cvSolver.getAxisMode() : 'std'} cv:${cvState}` : `rot ${getRotMode()} cam ${camMode}`; dbgPose = `eng ${engine} ${freezeGhosts ? 'FROZEN' : 'moving'}\n` + `${modeLine}\n` + `markers ${markerCount}/${tracking.raw} seen [${tracking.rawIds.join(',')}]${tracking.dupes ? ' +' + tracking.dupes + 'dup' : ''}${extra}\n${rawLine}\nview ${v3(view)}\nup ${v3(up)}\n` + `pos ${v3(camera3.position)}`; paintDbg(); } async function boot() { info = await (await fetch('/api/info')).json(); reportError = installErrorReporter(info.mode === 'dev'); await applyLanding(); const g = await (await fetch('/api/ghosts')).json(); gradients = g.gradients.gradients || g.gradients; modelManifest = await (await fetch('/api/models')).json(); characters = await (await fetch('/api/characters')).json(); $('#start').addEventListener('click', start, { once: true }); $('#detailsBtn').addEventListener('click', () => $('#details').classList.remove('hidden')); $('#backBtn').addEventListener('click', () => $('#details').classList.add('hidden')); } /* Paint the operator-authored landing page + details page. */ async function applyLanding() { let L; try { L = await (await fetch('/api/landing')).json(); } catch { return; } const set = (sel, txt) => { const el = $(sel); if (el) el.textContent = txt || ''; }; document.documentElement.style.setProperty('--accent', L.accent || '#51eaf1'); document.documentElement.style.setProperty('--text', L.textColor || '#e8ecff'); document.documentElement.style.setProperty('--ov', L.overlay != null ? L.overlay : 0.55); if (L.backgroundUrl) $('#startScreen').style.backgroundImage = `url("${L.backgroundUrl}")`; if (L.logoUrl) { const img = $('#logo'); img.src = L.logoUrl; img.alt = L.title || 'logo'; img.classList.remove('hidden'); $('#title').classList.add('hidden'); // logo art replaces the text title } else { set('#title', L.title); } set('#subtitle', L.subtitle); set('#start', L.startButton || 'Start'); set('#detailsBtn', L.detailsButton || 'About'); set('#detailsTitle', L.detailsTitle); $('#detailsBody').innerHTML = renderMarkdown(L.detailsMarkdown); set('#disc1', L.disclaimer); set('#disc2', L.disclaimer); if (!L.detailsMarkdown) $('#detailsBtn').classList.add('hidden'); document.title = L.title || 'Newbury Nights'; } /* Boot the OpenCV worker. All 13MB of parse + WASM compile happens off the main * thread, so the render loop keeps running while it loads; the posit engine * covers tracking meanwhile and permanently if the worker fails. */ function ensureOpenCV() { if (cvState === 'ready' || cvState === 'loading') return; // 'failed' falls through: tapping back to opencv retries the load cvState = 'loading'; cvSolver.load().then(() => { cvState = 'ready'; if (engine === 'opencv') toast('Tracking engine ready'); }).catch((e) => { cvState = 'failed'; console.warn('opencv worker unavailable, falling back to posit', e); if (engine === 'opencv') { setEngine('posit'); toast('Using classic tracking'); } }); } async function start() { $('#startScreen').classList.add('hidden'); $('#hud').classList.remove('hidden'); $('#shutter').classList.remove('hidden'); $('#shutter').addEventListener('click', capturePhoto); // camera video = $('#cam'); const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false, }); video.srcObject = stream; await video.play(); // dimensions can land after play() resolves on iOS; recompute when they do video.addEventListener('loadedmetadata', updateCameraIntrinsics); video.addEventListener('resize', updateCameraIntrinsics); // detection canvas canvas2d = document.createElement('canvas'); ctx2d = canvas2d.getContext('2d', { willReadFrequently: true }); // three.js renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); scene3 = new THREE.Scene(); camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 2, 5000); scene3.add(new THREE.AmbientLight(0xffffff, 1.2)); onResize(); addEventListener('resize', onResize); detector = createDetector(); fuser = new WorldFuser(); cvSolver = new CvBoardSolver(); ensureOpenCV(); // scene over HTTP so tracking works even if the WebSocket is down try { applyScene(await (await fetch('/api/scene')).json()); } catch (e) { reportError({ kind: 'scene', msg: `scene fetch failed: ${(e && e.message) || e}` }); } // dev-mode pose readout + tap-to-cycle modes if (info.mode === 'dev') { // start static so tracking jitter can't be mistaken for the ghost's own float const mb = $('#motionBtn'); if (mb) { mb.classList.remove('hidden'); mb.addEventListener('click', () => setFreeze(!freezeGhosts)); } setFreeze(true); dbgEl = $('#dbg'); if (dbgEl) { dbgEl.style.display = 'block'; dbgEl.style.pointerEvents = 'auto'; /* tap zones (thirds): * top — cycle engine (opencv <-> posit) * middle — cycle engine mode (opencv: axis std/ymirror; posit: rot mode) * bottom — cycle posit camera correction (no-op for opencv) */ dbgEl.addEventListener('click', (ev) => { const r = dbgEl.getBoundingClientRect(); const frac = (ev.clientY - r.top) / r.height; if (frac < 0.34) { const next = ENGINES[(ENGINES.indexOf(engine) + 1) % ENGINES.length]; setEngine(next); if (next === 'opencv') ensureOpenCV(); } else if (frac < 0.67) { if (engine === 'opencv') { const i = CV_AXIS_MODES.indexOf(cvSolver.getAxisMode()); cvSolver.setAxisMode(CV_AXIS_MODES[(i + 1) % CV_AXIS_MODES.length]); } else { const i = ROT_MODES.indexOf(getRotMode()); setRotMode(ROT_MODES[(i + 1) % ROT_MODES.length]); } } else if (engine === 'posit') { const i = CAM_MODES.indexOf(camMode); camMode = CAM_MODES[(i + 1) % CAM_MODES.length]; } }); } // tap the tracking HUD chip to toggle ghost motion freeze const trk = $('#trk'); if (trk) { trk.style.pointerEvents = 'auto'; trk.addEventListener('click', () => setFreeze(!freezeGhosts)); } } // network const net = new ExhibitNet(); netRef = net; const on = (t, fn) => net.on(t, (m) => { rxTrace(t); return fn(m); }); on('scene', (m) => applyScene(m.scene)); on('characters', async (m) => { characters = m.characters || characters; const recs = [...activeGhosts.values()].map(e => e.rec); for (const uid of [...activeGhosts.keys()]) removeGhost(uid); for (const rec of recs) await addGhost(rec); // rebuild with new visuals }); on('active', async (m) => { for (const uid of [...activeGhosts.keys()]) removeGhost(uid); for (const rec of m.ghosts) await addGhost(rec); }); on('spawn', (m) => addGhost(m.ghost)); on('despawn', (m) => scheduleRemove(m.uid)); on('pong', (m) => { const rtt = performance.now() - m.t; clockOffsetSamples.push(m.server + rtt / 2 - Date.now()); if (clockOffsetSamples.length > 5) clockOffsetSamples.shift(); clockOffset = clockOffsetSamples.reduce((a, b) => a + b, 0) / clockOffsetSamples.length; }); net.connect(); setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000); requestAnimationFrame(loop); } function onResize() { renderer.setSize(innerWidth, innerHeight); updateCameraIntrinsics(); } /* Match the virtual camera to the video the user can actually SEE. * * Two things were wrong before: the camera was hardcoded to a 60 degree * VERTICAL fov, while a 1280x720 feed at ~60 degrees HORIZONTAL is only ~36 * degrees vertically; and #cam is `object-fit: cover`, so on a portrait screen * the sides of the frame are cropped away and never seen. Both make world * geometry project too far from centre — measured at 1.7x on device, which * reads as ghosts sliding the wrong way when you tilt. * * So: take the focal length implied by HFOV_DEG over the image's LONG axis (see * focalPxFor — the stream may be portrait), work out how much of the frame * survives the cover-crop, and set the fov from that visible height. Cover crops * symmetrically, so the principal point stays centred and no lens shift is * needed. */ const _crop = { x: 1, y: 1 }; // fraction of the video width/height still visible function updateCameraIntrinsics() { if (!camera3) return; camera3.aspect = innerWidth / innerHeight; if (!video || !video.videoWidth) { camera3.updateProjectionMatrix(); return; } const vw = video.videoWidth, vh = video.videoHeight; const focal = focalPxFor(vw, vh); const scale = Math.max(innerWidth / vw, innerHeight / vh); // object-fit: cover const visW = Math.min(vw, innerWidth / scale); const visH = Math.min(vh, innerHeight / scale); _crop.x = visW / vw; _crop.y = visH / vh; camera3.fov = 2 * Math.atan((visH / 2) / focal) * 180 / Math.PI; camera3.updateProjectionMatrix(); } /* Scene application. Tracking must NOT depend on the WebSocket: detection filters * against the scene's marker IDs, so if the socket is slow or flapping the crest * is detected and then silently discarded ("Point at a Newbury Crest" forever). * We therefore load the scene over plain HTTP at start; the WS message just * refreshes it when an operator edits the layout. */ function applyScene(scene) { if (!scene) return; sceneLoaded = true; anchorCount = (scene.anchors || []).filter(a => a.enabled !== false).length; fuser.setScene(scene); cvSolver.setScene(scene); buildOcclusion(scene); ghostHeight = scene.ghostHeightCm || 4; for (const e of activeGhosts.values()) e.group.userData.setHeight(ghostHeight); } // ---------- occlusion: invisible depth-only building volumes ---------- const occlusionGroup = new THREE.Group(); function buildOcclusion(scene) { occlusionGroup.clear(); const mat = new THREE.MeshBasicMaterial({ colorWrite: false }); // depth-only for (const b of scene.buildings || []) { /* Two occluder shapes. Boxes cover LEGO buildings; cylinders exist for round * props (the test rig's 85mm post), where a box silhouette would wrongly * hide ghosts at the corners. Positions are floor-level, so each mesh is * lifted by half its height. */ let geo, height; if (b.shape === 'cylinder') { const r = b.radiusCm ?? (b.size ? b.size[0] / 2 : 5); height = b.heightCm ?? (b.size ? b.size[1] : 10); geo = new THREE.CylinderGeometry(r, r, height, 24); } else { height = b.size[1]; geo = new THREE.BoxGeometry(b.size[0], b.size[1], b.size[2]); } const mesh = new THREE.Mesh(geo, mat); mesh.position.set(b.position[0], b.position[1] + height / 2, b.position[2]); mesh.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0); mesh.renderOrder = -1; occlusionGroup.add(mesh); } if (!occlusionGroup.parent) scene3.add(occlusionGroup); } // ---------- ghosts ---------- async function addGhost(rec) { if (activeGhosts.has(rec.uid)) return; let group; try { group = await buildGhost(rec, gradients, modelManifest, characters); } catch (e) { reportError({ kind: 'ghost', msg: `buildGhost ${rec.name}: ${(e && e.message) || e}` }); return; } group.userData.setHeight(ghostHeight); if (info.mode === 'dev') { // triage beacon: always-on-top magenta dot at the ghost's origin — if the // record exists client-side, this shows regardless of model/shader/opacity const dot = new THREE.Mesh( new THREE.SphereGeometry(1, 12, 8), new THREE.MeshBasicMaterial({ color: 0xff33cc, depthTest: false, transparent: true, opacity: 0.9 })); dot.renderOrder = 999; group.add(dot); } scene3.add(group); activeGhosts.set(rec.uid, { rec, group }); toast(`${rec.name} appeared`); } function removeGhost(uid) { const e = activeGhosts.get(uid); if (!e) return; scene3.remove(e.group); activeGhosts.delete(uid); } function scheduleRemove(uid) { const e = activeGhosts.get(uid); if (!e) return removeGhost(uid); const until = e.rec.until ?? (Date.now() + clockOffset); const wait = Math.max(0, until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000; setTimeout(() => removeGhost(uid), Math.min(wait, 8000)); } // ---------- main loop ---------- const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 }; let frameCount = 0; function loop(t) { requestAnimationFrame(loop); // rolling fps so the HUD shows what the render loop is actually managing fpsFrames++; if (t - fpsAt >= 1000) { fps = Math.round(fpsFrames * 1000 / (t - fpsAt)); fpsFrames = 0; fpsAt = t; } if (video && video.readyState >= 2 && (t - lastDetectAt) >= DETECT_INTERVAL_MS) { lastDetectAt = t; /* Downscale for detection, capping the LONG side. Fixing the WIDTH quietly * tripled the work on a portrait stream (960x1707 vs 960x540), which is most * of the cost of a frame. */ const vw = video.videoWidth, vh = video.videoHeight; const LONG = DETECT_LONG; const W = vw >= vh ? LONG : Math.round(LONG * vw / vh); const H = vw >= vh ? Math.round(LONG * vh / vw) : LONG; if (canvas2d.width !== W || canvas2d.height !== H) { canvas2d.width = W; canvas2d.height = H; } ctx2d.drawImage(video, 0, 0, W, H); const img = ctx2d.getImageData(0, 0, W, H); const focal = focalPxFor(W, H); if (!poseEst) poseEst = new PoseEstimator(focal); const markers = detectMarkers(detector, img, fuser.knownIds()); tracking.markers = markers.length; tracking.raw = markers.rawCount || 0; tracking.rawIds = markers.rawIds || []; tracking.dupes = markers.dupes || 0; if (dbgEl) updateAim(markers, W, H); if (markers.length) { tracking.lastSeen = performance.now(); if (engine === 'opencv' && cvState === 'ready') { // v3 path: one joint solve over every visible crest const fused = cvSolver.solve(markers, W, H, focal); if (fused) { camera3.position.copy(fused.position); camera3.quaternion.copy(fused.quaternion); updateDbg(fused.quaternion, fused.markerCount, ` reproj ${fused.reprojPx.toFixed(1)}px`); } } else { // v2 path: per-marker POS-IT -> confidence-weighted fusion const estimates = markers.map(m => { const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id)); return e && { markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) }; }).filter(Boolean); const fused = fuser.fuse(estimates); if (fused) { camera3.position.copy(fused.position); camera3.quaternion.copy(fused.quaternion).multiply(CAM_CORR[camMode]); updateDbg(fused.quaternion, fused.markerCount, fused.markerCount > 1 ? ` spread ${fused.spread.toFixed(1)}cm` : ''); } } } } // ghosts: deterministic motion on synced clock const now = Date.now() + clockOffset; for (const { rec, group } of activeGhosts.values()) { ghostTransform(rec, now, _tmp); if (freezeGhosts) { group.position.set(rec.pos[0], rec.pos[1], rec.pos[2]); group.rotation.y = 0; } else { group.position.copy(_tmp.position); group.rotation.y = _tmp.rotationY; } group.userData.setOpacity(_tmp.opacity * 0.92); group.userData.tick(freezeGhosts ? 0 : t / 1000); } // HUD if ((frameCount++ & 15) === 0) { paintDbg(); const stale = performance.now() - tracking.lastSeen > 1500; $('#trk').textContent = !stale ? `Tracking ${tracking.markers} crest${tracking.markers === 1 ? '' : 's'}` : (tracking.raw > 0 ? `Crest ${tracking.rawIds.join(',')} not in this layout` // detected, but no anchor for it : 'Point at a Newbury Crest'); $('#trk').classList.toggle('warn', stale || tracking.markers < 2); $('#cnt').textContent = `${activeGhosts.size} ghost${activeGhosts.size === 1 ? '' : 's'} nearby`; } renderer.render(scene3, camera3); } /* Shutter: composite the live camera frame with the rendered ghosts and save a PNG. */ async function capturePhoto() { try { const flash = $('#flash'); flash.classList.add('on'); setTimeout(() => flash.classList.remove('on'), 60); const vw = video.videoWidth, vh = video.videoHeight; const out = document.createElement('canvas'); out.width = vw; out.height = vh; const c = out.getContext('2d'); c.drawImage(video, 0, 0, vw, vh); const gl = $('#gl'); const scale = Math.max(vw / gl.width, vh / gl.height); const dw = gl.width * scale, dh = gl.height * scale; renderer.render(scene3, camera3); // ensure the buffer is current c.drawImage(gl, (vw - dw) / 2, (vh - dh) / 2, dw, dh); const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const blob = await new Promise(r => out.toBlob(r, 'image/png')); const file = new File([blob], `newbury-${stamp}.png`, { type: 'image/png' }); if (navigator.canShare && navigator.canShare({ files: [file] })) { await navigator.share({ files: [file], title: 'Newbury Nights' }); toast('Photo ready to share'); } else { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000); toast('Photo saved'); } } catch (e) { if (e && e.name === 'AbortError') return; // user dismissed the share sheet toast('Could not save photo'); console.warn('capture failed', e); } } let reportError = () => {}; let toastTimer = null; function toast(msg) { const el = $('#toast'); el.textContent = msg; el.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => el.classList.remove('show'), 2500); } boot().catch(e => { console.error(e); alert('Failed to start: ' + e.message); });