/* Newbury Exhibit — AR marker proof of concept * * Goal of this module: prove the full chain works on a real phone — * camera frame -> ArUco 4x4 detection -> a ghost sprite locked onto the marker. * No server/sync yet; this is the localisation primitive everything else builds on. * * Detector: js-aruco2 (global `AR`), dictionary ARUCO_4X4_1000. */ (function () { 'use strict'; const video = document.getElementById('video'); const overlay = document.getElementById('overlay'); const ctx = overlay.getContext('2d'); const startScreen = document.getElementById('start'); const goBtn = document.getElementById('go'); const errEl = document.getElementById('err'); const hud = document.getElementById('hud'); const seenTag = document.getElementById('seen'); const midEl = document.getElementById('mid'); const fpsEl = document.getElementById('fps'); // Offscreen canvas we actually read pixels from (matches video native size). const grab = document.createElement('canvas'); const gctx = grab.getContext('2d', { willReadFrequently: true }); let detector = null; let running = false; let lastSeen = 0; // timestamp of last successful detection const HOLD_MS = 250; // keep ghost visible briefly through dropped frames // Ghost sprite (animated WebP if present, else procedural wisp fallback). const ghostImg = new Image(); let ghostReady = false; ghostImg.onload = () => { ghostReady = true; }; ghostImg.onerror = () => { ghostReady = false; }; // fall back to procedural // Optional: drop a sprite at this path to use it. Safe if 404 -> procedural wisp. ghostImg.src = 'assets/ghost_blue.webp'; function sizeToVideo() { const vw = video.videoWidth, vh = video.videoHeight; if (!vw || !vh) return; grab.width = vw; grab.height = vh; // Cover-fit the viewport while keeping native pixels for detection. const scale = Math.max(window.innerWidth / vw, window.innerHeight / vh); const dw = vw * scale, dh = vh * scale; for (const el of [video, overlay]) { el.style.width = dw + 'px'; el.style.height = dh + 'px'; } overlay.width = dw; overlay.height = dh; overlay._scale = scale; // px-per-native-pixel for drawing in display space } async function start() { errEl.textContent = ''; try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false, }); video.srcObject = stream; await video.play(); } catch (e) { errEl.textContent = 'Camera unavailable: ' + (e.message || e.name) + '. On iPhone this page must be served over HTTPS and you must allow camera access.'; return; } detector = new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000' }); await new Promise((res) => { if (video.readyState >= 2) return res(); video.onloadeddata = () => res(); }); sizeToVideo(); window.addEventListener('resize', sizeToVideo); startScreen.classList.add('hidden'); hud.classList.remove('hidden'); running = true; requestAnimationFrame(loop); } // ---- detection + render loop ---- let frames = 0, fpsStamp = performance.now(); function loop(now) { if (!running) return; requestAnimationFrame(loop); if (video.readyState < 2 || !grab.width) return; gctx.drawImage(video, 0, 0, grab.width, grab.height); const img = gctx.getImageData(0, 0, grab.width, grab.height); let markers = []; try { markers = detector.detect(img); } catch (_) { markers = []; } const s = overlay._scale || 1; ctx.clearRect(0, 0, overlay.width, overlay.height); if (markers.length) { lastSeen = now; const m = markers[0]; drawMarkerOutline(m, s); drawGhostOnMarker(m, s, now); seenTag.textContent = 'locked'; seenTag.className = 'tag on'; midEl.textContent = m.id; } else if (now - lastSeen < HOLD_MS) { // brief hold-over: keep last ghost roughly; (cheap version: skip) seenTag.textContent = 'holding…'; seenTag.className = 'tag on'; } else { seenTag.textContent = 'searching…'; seenTag.className = 'tag off'; midEl.textContent = '—'; } frames++; if (now - fpsStamp > 500) { fpsEl.textContent = Math.round((frames * 1000) / (now - fpsStamp)); frames = 0; fpsStamp = now; } } function corners(m, s) { // js-aruco corners are in native pixel space (origin top-left). return m.corners.map((c) => ({ x: c.x * s, y: c.y * s })); } function drawMarkerOutline(m, s) { const c = corners(m, s); ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(82,158,255,0.9)'; ctx.beginPath(); ctx.moveTo(c[0].x, c[0].y); for (let i = 1; i < c.length; i++) ctx.lineTo(c[i].x, c[i].y); ctx.closePath(); ctx.stroke(); // corner dot to show orientation lock (first corner) ctx.fillStyle = 'var(--yellow)'; ctx.fillStyle = '#fff35d'; ctx.beginPath(); ctx.arc(c[0].x, c[0].y, 5, 0, Math.PI * 2); ctx.fill(); } function centroid(c) { let x = 0, y = 0; for (const p of c) { x += p.x; y += p.y; } return { x: x / c.length, y: y / c.length }; } function markerSpan(c) { // average side length in display px -> sprite scale reference let sum = 0; for (let i = 0; i < c.length; i++) { const a = c[i], b = c[(i + 1) % c.length]; sum += Math.hypot(b.x - a.x, b.y - a.y); } return sum / c.length; } function drawGhostOnMarker(m, s, now) { const c = corners(m, s); const mid = centroid(c); const span = markerSpan(c); // Ghost hovers above the marker, gently bobbing — a tiny taste of the // motion solver the sync server will eventually drive. const t = now / 1000; const bob = Math.sin(t * 2.2) * span * 0.10; const sway = Math.cos(t * 1.3) * span * 0.06; const cx = mid.x + sway; const cy = mid.y - span * 0.95 + bob; // float above the plaque const size = span * 1.4; if (ghostReady) { ctx.save(); ctx.globalAlpha = 0.92; ctx.drawImage(ghostImg, cx - size / 2, cy - size / 2, size, size); ctx.restore(); } else { drawWisp(cx, cy, size * 0.5, t); } // tether line so it reads as "bound to this marker" ctx.strokeStyle = 'rgba(81,234,241,0.35)'; ctx.lineWidth = 2; ctx.setLineDash([4, 6]); ctx.beginPath(); ctx.moveTo(mid.x, mid.y); ctx.lineTo(cx, cy + size * 0.25); ctx.stroke(); ctx.setLineDash([]); } // Procedural fallback wisp in Hidden Side "Sad/Blue" gradient. function drawWisp(cx, cy, r, t) { const grad = ctx.createRadialGradient(cx, cy - r * 0.3, r * 0.2, cx, cy, r); grad.addColorStop(0, 'rgba(81,234,241,0.95)'); grad.addColorStop(0.5, 'rgba(82,158,255,0.65)'); grad.addColorStop(1, 'rgba(82,158,255,0)'); ctx.fillStyle = grad; ctx.beginPath(); // teardrop-ish body const wob = Math.sin(t * 3) * r * 0.08; ctx.ellipse(cx, cy, r * 0.8 + wob, r, 0, 0, Math.PI * 2); ctx.fill(); // eyes ctx.fillStyle = 'rgba(8,12,30,0.85)'; ctx.beginPath(); ctx.ellipse(cx - r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.ellipse(cx + r * 0.28, cy - r * 0.1, r * 0.10, r * 0.16, 0, 0, Math.PI * 2); ctx.fill(); } goBtn.addEventListener('click', start); })();