6f8b67b583
The WebM carries real alpha (alpha_mode=1) but rendered opaque because the VideoTexture was forced to SRGBColorSpace and the material assumed premultiplied alpha, crushing transparent regions to black. Removing the colorspace override and setting premultipliedAlpha:false keys the black out.
319 lines
13 KiB
HTML
319 lines
13 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 };
|
|
|
|
// Same VP9-alpha capability check as the hunt.
|
|
const SUPPORTS_WEBM_ALPHA = (() => {
|
|
try {
|
|
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);
|
|
// VP9-alpha WebMs carry straight (non-premultiplied) alpha. Leave the
|
|
// texture in the default (linear/no-color-conversion) space — forcing
|
|
// SRGBColorSpace here makes three.js crush the alpha to black. The
|
|
// material below blends on the video's own alpha with premultipliedAlpha
|
|
// off so transparent regions stay see-through.
|
|
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) {
|
|
const img = document.createElement('img');
|
|
img.crossOrigin = 'anonymous'; img.src = image;
|
|
const tex = new THREE.Texture(img);
|
|
img.onload = () => { tex.needsUpdate = true; };
|
|
const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, side: THREE.DoubleSide });
|
|
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), mat);
|
|
group.userData.gifTex = tex; group.userData.gifImg = 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
|
|
? ' · WebM' + (SUPPORTS_WEBM_ALPHA ? '' : ' (no VP9-alpha 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 {} }
|
|
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);
|
|
if (current.userData.gifTex && current.userData.gifImg?.complete) current.userData.gifTex.needsUpdate = true;
|
|
}
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s ?? '').replace(/[&<>"']/g, (c) =>
|
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|