Files
newbury-nights/public/preview.html
T
jessikitty 25832dd58f Keep WebP animating on iOS by attaching the source img to the DOM
iOS pauses animation on a detached <img>, so the CanvasTexture sampled a
frozen frame. Attach the img hidden off-screen (opacity 0.01) so WebKit
keeps advancing the WebP; remove it on clear.
2026-06-22 09:53:36 +10:00

355 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" />
<title>Newbury Nights — Ghost Preview</title>
<link rel="stylesheet" href="/css/app.css" />
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js"
}
}
</script>
<style>
#pv-stage { position: fixed; inset: 0; overflow: hidden; background: #07090f; }
#pv-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
#pv-canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.pv-bar {
position: fixed; left: 0; right: 0; top: 0; z-index: 10;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 10px 14px; background: rgba(7,9,15,.82); backdrop-filter: blur(8px);
border-bottom: 1px solid rgba(255,255,255,.08);
}
.pv-bar .brand { font-weight: 700; letter-spacing: .04em; text-transform: uppercase; font-size: 14px; }
.pv-bar .brand span { color: var(--blue, #3bb6ff); }
.pv-bar select, .pv-bar button { font-size: 13px; }
.pv-bar .grow { flex: 1; }
.pv-hint { position: fixed; left: 0; right: 0; bottom: 0; z-index: 10;
text-align: center; padding: 8px; font-family: var(--mono, monospace); font-size: 11px;
color: rgba(255,255,255,.7); background: linear-gradient(transparent, rgba(7,9,15,.75)); }
.pv-controls { position: fixed; right: 14px; bottom: 36px; z-index: 10;
display: flex; flex-direction: column; gap: 8px; align-items: stretch; }
.pv-controls label { font-family: var(--mono, monospace); font-size: 10px; letter-spacing: .1em;
text-transform: uppercase; color: rgba(255,255,255,.65); }
.pv-controls input[type=range] { width: 150px; }
/* Login gate */
#pv-login { position: fixed; inset: 0; z-index: 20; display: grid; place-items: center;
padding: 24px; background: #07090f; }
.pv-login-card { background: var(--panel, #11151f); border: 1px solid rgba(255,255,255,.1);
border-radius: 14px; padding: 30px; width: 100%; max-width: 360px; }
.pv-login-card h1 { font-size: 30px; line-height: .9; margin: 0 0 4px; }
.pv-login-card h1 span { color: var(--blue, #3bb6ff); }
.pv-login-card .sub { font-size: 11px; letter-spacing: .3em; color: rgba(255,255,255,.55);
margin: 6px 0 22px; font-family: var(--mono, monospace); }
.pv-login-card label { display: block; margin-top: 12px; font-size: 13px; }
.pv-login-card input { width: 100%; box-sizing: border-box; }
.pv-err { color: var(--danger, #ff3b5c); font-size: 13px; margin-top: 12px; }
.hidden { display: none !important; }
</style>
</head>
<body>
<!-- Login gate (same client-side flow as /admin) -->
<div id="pv-login">
<div class="pv-login-card">
<h1>Newbury<span>Nights</span></h1>
<div class="sub">GHOST PREVIEW</div>
<label for="pv-user">Username</label>
<input id="pv-user" autocomplete="username" />
<label for="pv-pass">Password</label>
<input id="pv-pass" type="password" autocomplete="current-password" />
<button class="primary" id="pv-go" style="width:100%;margin-top:14px">Sign in</button>
<div id="pv-login-err" class="pv-err hidden"></div>
</div>
</div>
<!-- Preview stage -->
<div id="pv-stage" class="hidden">
<video id="pv-video" playsinline muted></video>
<canvas id="pv-canvas"></canvas>
</div>
<div id="pv-app" class="hidden">
<div class="pv-bar">
<div class="brand">Newbury<span>Nights</span> · PREVIEW</div>
<select id="pv-select"><option value="">Loading ghosts…</option></select>
<button id="pv-camera-toggle" class="small">Camera: on</button>
<span class="grow"></span>
<a href="/admin" class="small" style="text-decoration:none"><button class="small">← Admin</button></a>
</div>
<div class="pv-controls">
<label>Distance <span id="pv-dist-val">3.0</span></label>
<input id="pv-dist" type="range" min="1.5" max="8" step="0.1" value="3" />
<label>Size <span id="pv-size-val">1.2</span></label>
<input id="pv-size" type="range" min="0.4" max="3" step="0.1" value="1.2" />
</div>
<div class="pv-hint" id="pv-hint">Select a ghost to preview it over the camera feed.</div>
</div>
<script type="module">
import * as THREE from 'three';
const $ = (s) => document.querySelector(s);
const TYPE_COLORS = { red: 0xff3b5c, yellow: 0xffc23b, blue: 0x3bb6ff };
// VP9-alpha WebM transparency works on desktop Chromium/Gecko but NOT on
// WebKit: all iOS browsers (Safari, Chrome, Firefox) and desktop Safari play
// the WebM but render its alpha as opaque black. Those devices must fall
// through to the animated WebP (which IS transparent), so exclude WebKit here.
const SUPPORTS_WEBM_ALPHA = (() => {
try {
const ua = navigator.userAgent;
const isIOS = /iPad|iPhone|iPod/.test(ua) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS 13+ poses as Mac
const isSafari = /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg|OPR/.test(ua);
if (isIOS || isSafari) return false;
const v = document.createElement('video');
return !!v.canPlayType && v.canPlayType('video/webm; codecs="vp9"') !== '';
} catch { return false; }
})();
let TOKEN = null;
let GHOSTS = [];
/* ---------- login ---------- */
$('#pv-go').addEventListener('click', login);
$('#pv-pass').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
async function login() {
const username = $('#pv-user').value.trim();
const password = $('#pv-pass').value;
const err = $('#pv-login-err');
err.classList.add('hidden');
try {
const res = await fetch('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error('bad login');
TOKEN = (await res.json()).token;
} catch {
err.textContent = 'Invalid username or password.';
err.classList.remove('hidden');
return;
}
$('#pv-login').classList.add('hidden');
$('#pv-stage').classList.remove('hidden');
$('#pv-app').classList.remove('hidden');
await loadGhosts();
initScene();
await initCamera();
loop();
}
async function loadGhosts() {
const res = await fetch('/api/admin/ghosts', { headers: { Authorization: `Bearer ${TOKEN}` } });
GHOSTS = (await res.json()).ghosts;
const sel = $('#pv-select');
sel.innerHTML = '<option value="">— pick a ghost —</option>' +
GHOSTS.map((g) => `<option value="${g.id}">${esc(g.name)}${g.is_boss ? ' (boss)' : ''} ${'★'.repeat(g.rarity)}</option>`).join('');
sel.addEventListener('change', () => {
const g = GHOSTS.find((x) => x.id === +sel.value);
if (g) showGhost(g);
else clearGhost();
});
}
/* ---------- camera ---------- */
let stream = null;
let cameraOn = true;
async function initCamera() {
const video = $('#pv-video');
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
video.srcObject = stream;
await video.play();
} catch {
$('#pv-hint').textContent = 'Camera unavailable — ghost shown over a dark backdrop.';
}
}
$('#pv-camera-toggle').addEventListener('click', () => {
cameraOn = !cameraOn;
$('#pv-video').style.visibility = cameraOn ? 'visible' : 'hidden';
$('#pv-camera-toggle').textContent = `Camera: ${cameraOn ? 'on' : 'off'}`;
});
/* ---------- three.js scene ---------- */
let scene, camera, renderer, current = null, raf = null;
let distance = 3, size = 1.2;
function initScene() {
const canvas = $('#pv-canvas');
renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 0, 0);
scene.add(new THREE.AmbientLight(0xffffff, 0.85));
const p = new THREE.PointLight(0x88aaff, 1.2, 50);
p.position.set(0, 2, 2);
scene.add(p);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
}
// Build a ghost group using the SAME render path as the hunt:
// WebM (VP9+alpha) VideoTexture -> GIF/image Texture -> procedural wisp.
function buildGhost(data) {
const group = new THREE.Group();
const color = TYPE_COLORS[data.type] ?? 0xffffff;
let mesh;
// The admin endpoint (/api/admin/ghosts) returns raw DB rows with
// snake_case *_path fields holding bare filenames. Map them to /uploads
// URLs. (Also tolerate the public API shape: webm / webp / image.)
const up = (f) => (f ? (f.startsWith('/') || f.startsWith('http') ? f : `/uploads/${f}`) : null);
const webm = up(data.webm_path || data.webm);
const image = up(data.image_path || data.image || data.webp_path || data.webp);
if (webm && SUPPORTS_WEBM_ALPHA) {
const vid = document.createElement('video');
vid.crossOrigin = 'anonymous';
vid.muted = true; vid.loop = true; vid.playsInline = true; vid.autoplay = true; vid.preload = 'auto';
vid.src = webm;
const tex = new THREE.VideoTexture(vid);
// Desktop Chromium/Gecko only (WebKit excluded above). RGBAFormat keeps
// the alpha channel on frame upload; no SRGB override (it can crush the
// alpha); premultipliedAlpha off so transparent regions stay see-through.
tex.format = THREE.RGBAFormat;
tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
const mat = new THREE.MeshBasicMaterial({
map: tex, transparent: true, side: THREE.DoubleSide,
depthWrite: false, premultipliedAlpha: false,
});
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
vid.onerror = () => {
if (group.userData.fell || !image) return;
group.userData.fell = true;
const img = document.createElement('img');
img.crossOrigin = 'anonymous'; img.src = image;
const t2 = new THREE.Texture(img);
img.onload = () => { t2.needsUpdate = true; };
mesh.material.map = t2; mesh.material.needsUpdate = true;
group.userData.gifTex = t2; group.userData.gifImg = img; group.userData.vidEl = null;
};
const pr = vid.play(); if (pr && pr.catch) pr.catch(() => {});
group.userData.vidEl = vid;
} else if (image) {
// Animated WebP (the iOS/WebKit fallback). Three.js won't advance an
// animated-image Texture on iOS, so instead we draw the <img> into an
// offscreen canvas every frame and use a CanvasTexture. The browser keeps
// animating the WebP internally; drawImage() samples the current frame, so
// the texture animates and transparency is preserved.
const img = document.createElement('img');
img.crossOrigin = 'anonymous'; img.src = image;
// iOS pauses animation on a detached <img>, so attach it hidden off-screen
// to keep the WebP advancing. We sample its current frame into the canvas.
img.style.cssText = 'position:fixed;left:-99999px;top:0;width:64px;height:64px;pointer-events:none;opacity:0.01;';
document.body.appendChild(img);
const cnv = document.createElement('canvas');
cnv.width = 256; cnv.height = 256;
const ctx = cnv.getContext('2d');
const tex = new THREE.CanvasTexture(cnv);
tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
img.onload = () => {
// size the canvas to the image's aspect once known
cnv.width = img.naturalWidth || 256;
cnv.height = img.naturalHeight || 256;
};
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide });
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
group.userData.canvasTex = tex; group.userData.canvasCtx = ctx; group.userData.canvasEl = cnv;
group.userData.animImg = img;
} else {
const geo = new THREE.SphereGeometry(0.4, 24, 24);
const mat = new THREE.MeshStandardMaterial({
color, emissive: color, emissiveIntensity: 1.4, roughness: 0.4, transparent: true, opacity: 0.92,
});
mesh = new THREE.Mesh(geo, mat);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(0.55, 24, 24),
new THREE.MeshBasicMaterial({ color, transparent: true, opacity: 0.16, side: THREE.BackSide })
);
group.add(halo);
}
group.add(mesh);
group.userData.mesh = mesh;
group.userData.bobPhase = Math.random() * Math.PI * 2;
return group;
}
function showGhost(data) {
clearGhost();
current = buildGhost(data);
applyTransform();
scene.add(current);
const hasWebm = !!(data.webm_path || data.webm);
const hasImg = !!(data.image_path || data.image || data.webp_path || data.webp);
const kind = hasWebm
? (SUPPORTS_WEBM_ALPHA ? ' · WebM' : ' · WebP (WebKit fallback)')
: hasImg ? ' · image' : ' · procedural wisp';
$('#pv-hint').textContent = `${data.name}${kind}`;
}
function clearGhost() {
if (!current) return;
if (current.userData.vidEl) { try { current.userData.vidEl.pause(); current.userData.vidEl.src = ''; } catch {} }
if (current.userData.animImg) { try { current.userData.animImg.remove(); } catch {} }
scene.remove(current);
current = null;
}
function applyTransform() {
if (!current) return;
current.position.set(0, 0, -distance);
const m = current.userData.mesh;
if (m) m.scale.set(size, size, size);
}
$('#pv-dist').addEventListener('input', (e) => {
distance = +e.target.value; $('#pv-dist-val').textContent = distance.toFixed(1); applyTransform();
});
$('#pv-size').addEventListener('input', (e) => {
size = +e.target.value; $('#pv-size-val').textContent = size.toFixed(1); applyTransform();
});
function loop() {
raf = requestAnimationFrame(loop);
const t = performance.now() / 1000;
if (current) {
current.position.y = Math.sin(t * 1.5 + current.userData.bobPhase) * 0.06;
current.lookAt(camera.position);
// Desktop WebM-onerror image fallback (rare): refresh its texture.
if (current.userData.gifTex && current.userData.gifImg?.complete) current.userData.gifTex.needsUpdate = true;
// Animated WebP path: redraw the current frame into the canvas texture
// so the animation plays (works on iOS, where image textures don't tick).
const ud = current.userData;
if (ud.canvasTex && ud.animImg?.complete && ud.animImg.naturalWidth) {
const c = ud.canvasEl;
ud.canvasCtx.clearRect(0, 0, c.width, c.height);
ud.canvasCtx.drawImage(ud.animImg, 0, 0, c.width, c.height);
ud.canvasTex.needsUpdate = true;
}
}
renderer.render(scene, camera);
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
</script>
</body>
</html>