Compare commits

20 Commits

Author SHA1 Message Date
jessikitty efadc85195 Free hunt: only spawn ghosts that have an uploaded video
Add `AND webm_path IS NOT NULL` to the /api/freehunt pool so free-hunt
mode only selects ghosts with real media (and their derived webp/image
fallbacks), never bare procedural-wisp ghosts.
2026-06-22 10:37:52 +10:00
jessikitty 653980a52e iOS WebP: render as tracked DOM img overlay instead of WebGL texture
iOS won't animate a WebP inside a WebGL texture in any form. Render the
iOS/WebKit fallback as a real DOM <img> (animates natively) and project an
invisible 3D anchor's screen position onto it each frame, so it still tracks
distance/size and bobs. Flat 2D rather than in-scene, but animated + transparent.
2026-06-22 09:59:35 +10:00
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
jessikitty 4cf8ec07a4 Animate WebP fallback on iOS via per-frame CanvasTexture
Three.js doesn't advance an animated-image Texture on iOS, so the WebP
showed as a static frame. Draw the animating <img> into an offscreen
canvas each frame and sample it through a CanvasTexture, keeping the ghost
in 3D (bob/scale/lookAt) and animated, with transparency preserved.
2026-06-22 09:48:17 +10:00
jessikitty 793a17dbdf Route iOS/WebKit to transparent WebP instead of opaque VP9 WebM
WebKit (all iOS browsers + desktop Safari) plays VP9 WebM but renders its
alpha as opaque black. SUPPORTS_WEBM_ALPHA now excludes iOS/Safari so those
devices fall through to the animated WebP fallback, which IS transparent.
Desktop Chromium/Gecko keep the WebM path.
2026-06-19 22:38:53 +10:00
jessikitty 6f8b67b583 Fix VP9-alpha transparency: drop sRGB on video texture, premultipliedAlpha off
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.
2026-06-19 22:00:03 +10:00
jessikitty b2a863bd80 Fix preview: map admin raw-row *_path fields to /uploads URLs
The /api/admin/ghosts endpoint returns raw DB rows (webm_path,
webp_path, image_path as bare filenames), not the public API's
camelCase URL shape. buildGhost now reads those and prefixes /uploads/,
so WebM video renders in the preview instead of falling through to the
procedural wisp.
2026-06-19 21:49:58 +10:00
jessikitty 2d66d809d4 Add /preview route serving the ghost preview page 2026-06-19 15:26:14 +10:00
jessikitty e81f779ea5 Fix preview.html encoding (store raw HTML, not base64) 2026-06-19 15:22:33 +10:00
jessikitty a8552592c7 Add admin-gated ghost preview page (/preview)
Camera-background single-ghost preview reusing the hunt's render path
(WebM VP9+alpha VideoTexture -> WebP/GIF -> procedural wisp). Login gate
matches admin; ghost list via JWT-protected /api/admin/ghosts. Dropdown
to pick a ghost, sliders for distance/size, camera toggle.
2026-06-19 15:20:17 +10:00
jessikitty 458c66a2c0 Fix: decode base64-corrupted source files (html/css/js + backend) 2026-06-19 05:06:43 +00:00
jessikitty 327b37babb Fix: decode base64-corrupted admin html/css/js 2026-06-19 01:21:26 +00:00
jessikitty 3fa50c4c2f Admin UI: style video row thumbnails like image thumbnails
Add .thumb-cell video to the existing .thumb-cell img rule so WebM-only
ghost thumbnails render at the same 34px size.
2026-06-19 09:54:25 +10:00
jessikitty 8e8e259b4c Admin UI: video-aware preview + table thumbnails
- openGhost() previews stored media, preferring WebM video over still image
- showPreview/hidePreview swap between <img> and <video> elements
- Live local preview on file pick (handles mp4/webm)
- Ghost-table row thumbnail renders <video> when only a WebM exists
2026-06-19 09:52:24 +10:00
jessikitty 2fe4d67518 Admin UI: allow MP4/WebM in billboard upload + video preview element
- Widen file input accept to include .webm/.mp4
- Add a <video> preview element alongside the <img> preview
- Update label and help text to mention server-side MP4 conversion
2026-06-19 09:46:29 +10:00
jessikitty 0c5123e3a6 Admin: accept mp4/webm uploads and auto-convert mp4 to transparent webm+webp
- Allow .mp4/.webm in addition to image types; raise upload limit to 64MB
- MP4 uploads are luma-keyed to a VP9+alpha WebM plus an animated WebP
  fallback via lib/ghost-media.js; the raw MP4 is discarded
- Pre-made .webm uploads are stored directly
- All prior media (image/webm/webp) is cleaned up on replace and on delete
- WebP doubles as the still thumbnail for converted ghosts
2026-06-18 14:27:50 +10:00
jessikitty dc5e032b3a Emit webm and webp URLs in public ghost objects
rowToGhost now returns webm/webp alongside image, so the client renderer can
select the VP9+alpha video billboard (with WebP/GIF fallback).
2026-06-18 14:22:50 +10:00
jessikitty 22983caa18 Add webm_path and webp_path columns to ghosts (idempotent migration)
ALTER TABLE guarded by a column-existence check so existing production
databases gain the columns without data loss.
2026-06-18 14:20:50 +10:00
jessikitty 9866e44445 Add server-side ghost media converter (mp4 -> transparent webm + webp)
Shells out to system ffmpeg with the same luma-key pipeline as ghostify.sh.
Produces a VP9+alpha WebM (browser-decoded primary) and an animated WebP
fallback. No new npm dependency; fails gracefully if ffmpeg is absent.
2026-06-18 14:18:50 +10:00
jessikitty ec4442d4ce Add WebM (VP9+alpha) VideoTexture ghost billboards with GIF/WebP fallback
- Detect VP9-alpha WebM support once at load; iOS Safari falls back to <img>
- addGhost prefers data.webm via THREE.VideoTexture (browser-decoded, no
  per-frame needsUpdate pump) when supported
- On video error, gracefully swap the billboard to the GIF/image texture
- Pause + release ghost <video> elements on capture and on hunt teardown to
  avoid leaking decoders
2026-06-18 13:15:54 +10:00
11 changed files with 2165 additions and 24 deletions
+14
View File
@@ -70,5 +70,19 @@ CREATE TABLE IF NOT EXISTS set_ghosts (
);
`);
/* ---- Migrations (idempotent) ----
* Add transparent-video sprite columns to the existing ghosts table without
* dropping data. webm_path is the VP9+alpha billboard; webp_path is the
* animated-WebP fallback used where VP9 alpha isn't supported (e.g. iOS).
*/
function addColumnIfMissing(table, column, decl) {
const cols = db.prepare(`PRAGMA table_info(${table})`).all();
if (!cols.some((c) => c.name === column)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
}
}
addColumnIfMissing('ghosts', 'webm_path', 'TEXT'); // VP9+alpha billboard (nullable)
addColumnIfMissing('ghosts', 'webp_path', 'TEXT'); // animated-WebP fallback (nullable)
export default db;
export { DB_PATH };
+111
View File
@@ -0,0 +1,111 @@
import { spawn } from 'node:child_process';
import { existsSync, statSync } from 'node:fs';
import { basename, extname, join } from 'node:path';
/*
* ghost-media.js — server-side ghost sprite conversion.
*
* Turns an uploaded black-background ghost MP4 into a transparent animated
* WebM (VP9 + alpha) plus an animated WebP fallback, using luma keying so the
* black becomes transparent and the glow keeps soft semi-transparent edges.
*
* Shells out to the system `ffmpeg` binary (no npm dependency). If ffmpeg is
* not installed, conversion fails gracefully and the caller keeps the original.
*
* This mirrors the standalone ghostify.sh pipeline so local and server output
* match.
*/
const FFMPEG = process.env.FFMPEG_PATH || 'ffmpeg';
// Tunables (env-overridable) — kept in sync with ghostify.sh defaults.
const THRESH = process.env.GHOST_KEY_THRESHOLD || '0.06';
const TOL = process.env.GHOST_KEY_TOLERANCE || '0.10';
const SOFT = process.env.GHOST_KEY_SOFTNESS || '0.15';
const FPS = process.env.GHOST_FPS || '15';
const MAXW = process.env.GHOST_MAXW || '512';
const WEBP_Q = process.env.GHOST_WEBP_QUALITY || '80';
const VP9_CRF = process.env.GHOST_VP9_CRF || '30';
// The shared luma-key + downscale filter chain.
function keyChain() {
const key = `format=rgba,lumakey=threshold=${THRESH}:tolerance=${TOL}:softness=${SOFT}`;
const scale = `scale='min(iw,${MAXW})':-1:flags=lanczos`;
return `${key},fps=${FPS},${scale}`;
}
function run(args) {
return new Promise((resolve, reject) => {
const proc = spawn(FFMPEG, args, { stdio: ['ignore', 'ignore', 'pipe'] });
let stderr = '';
proc.stderr.on('data', (d) => { stderr += d.toString(); });
proc.on('error', reject); // e.g. ffmpeg not found (ENOENT)
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`));
});
});
}
/** True if a usable ffmpeg binary is on PATH. */
export function ffmpegAvailable() {
return new Promise((resolve) => {
const proc = spawn(FFMPEG, ['-version'], { stdio: 'ignore' });
proc.on('error', () => resolve(false));
proc.on('close', (code) => resolve(code === 0));
});
}
function outName(srcFilename, ext) {
// wraith-1718.mp4 -> wraith-1718.webm / .webp, written alongside the source.
const stem = basename(srcFilename, extname(srcFilename));
return `${stem}.${ext}`;
}
/**
* Convert a source MP4 (already saved in uploadDir) into transparent webm+webp.
*
* @param {string} uploadDir absolute uploads directory
* @param {string} srcFilename the multer filename of the uploaded mp4
* @returns {Promise<{ webm: string|null, webp: string|null }>} new filenames
* (not full paths). Either may be null if that encode failed.
*/
export async function convertGhostMp4(uploadDir, srcFilename) {
const srcPath = join(uploadDir, srcFilename);
if (!existsSync(srcPath)) throw new Error(`source not found: ${srcPath}`);
const vf = keyChain();
const result = { webm: null, webp: null };
// --- WebM (VP9 + alpha): the primary, browser-decoded sprite ---
const webmName = outName(srcFilename, 'webm');
try {
await run([
'-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath,
'-vf', vf, '-an',
'-c:v', 'libvpx-vp9', '-pix_fmt', 'yuva420p',
'-b:v', '0', '-crf', VP9_CRF, '-auto-alt-ref', '0',
join(uploadDir, webmName),
]);
if (statSync(join(uploadDir, webmName)).size > 0) result.webm = webmName;
} catch (e) {
console.error('[ghost-media] webm encode failed:', e.message);
}
// --- WebP fallback: for iOS Safari / no-VP9-alpha browsers ---
const webpName = outName(srcFilename, 'webp');
try {
await run([
'-hide_banner', '-loglevel', 'error', '-y', '-i', srcPath,
'-vf', vf, '-loop', '0', '-an',
'-c:v', 'libwebp_anim', '-lossless', '0', '-q:v', WEBP_Q,
'-preset', 'drawing', '-compression_level', '6',
join(uploadDir, webpName),
]);
if (statSync(join(uploadDir, webpName)).size > 0) result.webp = webpName;
} catch (e) {
console.error('[ghost-media] webp encode failed:', e.message);
}
return result;
}
+1496
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -100,12 +100,13 @@
<label class="chk"><input type="checkbox" id="gm-enabled" checked /> Enabled</label>
</div>
<div class="image-row">
<label>Billboard image (GIF / PNG)</label>
<label>Billboard (GIF / PNG / WebP, or MP4 / WebM video)</label>
<div class="row">
<input id="gm-file" type="file" accept=".gif,.png,.jpg,.jpeg,.webp" />
<input id="gm-file" type="file" accept=".gif,.png,.jpg,.jpeg,.webp,.webm,.mp4" />
<img id="gm-preview" class="gm-preview hidden" alt="" />
<video id="gm-preview-vid" class="gm-preview hidden" muted loop playsinline></video>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">Upload happens after you save the ghost.</div>
<div class="muted" style="font-size:11px;margin-top:4px">Upload happens after you save the ghost. MP4 files are converted to a transparent WebM (with a WebP fallback) on the server.</div>
</div>
<div class="modal-actions">
<button data-close>Cancel</button>
+1 -1
View File
@@ -32,7 +32,7 @@ td .sub { color: var(--text-dim); font-size: 12px; }
.toggle.on { color: var(--gloom); } .toggle.off { color: var(--text-dim); }
.row-actions { display: flex; gap: 6px; }
.row-actions button { padding: 5px 9px; font-size: 11px; }
.thumb-cell img { width: 34px; height: 34px; object-fit: contain; background: #000a; border-radius: 6px; }
.thumb-cell img, .thumb-cell video { width: 34px; height: 34px; object-fit: contain; background: #000a; border-radius: 6px; }
.sets-list { display: grid; gap: 14px; }
.set-card { border: 1px solid var(--line); border-radius: var(--rad); padding: 16px; background: var(--panel); }
+41 -4
View File
@@ -79,7 +79,11 @@ function renderGhosts() {
}
function ghostRow(g) {
const img = g.image_path ? `<img src="/uploads/${g.image_path}" alt="">` : '';
const img = g.image_path
? `<img src="/uploads/${g.image_path}" alt="">`
: (g.webm_path
? `<video src="/uploads/${g.webm_path}" muted loop autoplay playsinline></video>`
: '');
const setRef = g.set_number ? `${g.set_number}` : '—';
return `<tr>
<td class="thumb-cell">${img}</td>
@@ -134,12 +138,45 @@ function openGhost(id) {
$('#gm-boss').checked = !!g?.is_boss;
$('#gm-enabled').checked = g ? !!g.enabled : true;
$('#gm-file').value = '';
const prev = $('#gm-preview');
if (g?.image_path) { prev.src = `/uploads/${g.image_path}`; prev.classList.remove('hidden'); }
else prev.classList.add('hidden');
// Show the existing stored media: prefer the WebM video, else a still image
// (image_path is the GIF/PNG, or the WebP thumbnail for converted ghosts).
if (g?.webm_path) showPreview(`/uploads/${g.webm_path}`, 'video');
else if (g?.image_path) showPreview(`/uploads/${g.image_path}`, 'image');
else if (g?.webp_path) showPreview(`/uploads/${g.webp_path}`, 'image');
else hidePreview();
$('#ghost-modal').classList.remove('hidden');
}
// Swap the modal preview between an <img> and a <video> depending on media kind.
function showPreview(src, kind) {
const img = $('#gm-preview');
const vid = $('#gm-preview-vid');
if (kind === 'video') {
img.classList.add('hidden'); img.removeAttribute('src');
vid.src = src; vid.classList.remove('hidden');
vid.play?.().catch(() => {});
} else {
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
img.src = src; img.classList.remove('hidden');
}
}
function hidePreview() {
const img = $('#gm-preview');
const vid = $('#gm-preview-vid');
img.classList.add('hidden'); img.removeAttribute('src');
vid.classList.add('hidden'); vid.pause?.(); vid.removeAttribute('src');
}
// Live local preview when a file is chosen (before upload).
$('#gm-file').addEventListener('change', () => {
const file = $('#gm-file').files[0];
if (!file) return;
const url = URL.createObjectURL(file);
const isVideo = /\.(mp4|webm)$/i.test(file.name) || file.type.startsWith('video/');
showPreview(url, isVideo ? 'video' : 'image');
});
$('#gm-save').addEventListener('click', saveGhost);
async function saveGhost() {
+54
View File
@@ -118,6 +118,17 @@ async function resolveCode(code) {
============================================================ */
const TYPE_COLORS = { red: 0xff3b5c, yellow: 0xffc23b, blue: 0x3bb6ff };
// Detect VP9-with-alpha WebM support once. iOS Safari historically lacks
// reliable VP9-alpha, so those devices fall back to the GIF/WebP <img> path.
const SUPPORTS_WEBM_ALPHA = (() => {
try {
const v = document.createElement('video');
return !!v.canPlayType && v.canPlayType('video/webm; codecs="vp9"') !== '';
} catch {
return false;
}
})();
const hunt = {
running: false,
scene: null, camera: null, renderer: null, raf: null,
@@ -289,7 +300,46 @@ const hunt = {
let mesh;
let texture = null;
if (data.webm && SUPPORTS_WEBM_ALPHA) {
// WebM (VP9+alpha) billboard via VideoTexture. The browser decodes and
// updates the texture itself — no per-frame needsUpdate pumping needed.
const vid = document.createElement('video');
vid.crossOrigin = 'anonymous';
vid.muted = true; // required for autoplay
vid.loop = true;
vid.playsInline = true; // iOS: stay inline, don't fullscreen
vid.autoplay = true;
vid.preload = 'auto';
vid.src = data.webm;
texture = new THREE.VideoTexture(vid);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, side: THREE.DoubleSide, depthWrite: false });
mesh = new THREE.Mesh(new THREE.PlaneGeometry(1.2, 1.2), mat);
// If the video errors (e.g. alpha unsupported despite canPlayType), swap
// to the GIF/image billboard if we have one.
vid.onerror = () => {
if (group.userData.videoFellBack) return;
group.userData.videoFellBack = true;
if (data.image) {
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
img.src = data.image;
const t2 = new THREE.Texture(img);
img.onload = () => { t2.needsUpdate = true; };
mesh.material.map = t2;
mesh.material.needsUpdate = true;
group.userData.gifImg = img;
group.userData.gifTex = t2;
group.userData.vidEl = null;
}
};
const pr = vid.play();
if (pr && pr.catch) pr.catch(() => {});
group.userData.vidEl = vid;
} else if (data.image) {
// animated GIF billboard — texture.needsUpdate pumped each frame
const img = document.createElement('img');
img.crossOrigin = 'anonymous';
@@ -444,6 +494,7 @@ const hunt = {
captureTarget() {
const g = this.target;
const d = g.userData.data;
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
this.scene.remove(g);
this.ghosts = this.ghosts.filter((x) => x !== g);
this.target = null;
@@ -543,6 +594,9 @@ const hunt = {
removeEventListener('mouseup', this._blastEls.endBlast);
}
if (this.renderer) { this.renderer.dispose?.(); }
for (const g of this.ghosts) {
if (g.userData.vidEl) { try { g.userData.vidEl.pause(); g.userData.vidEl.src = ''; } catch {} g.userData.vidEl = null; }
}
this.ghosts = []; this.target = null;
},
};
+365
View File
@@ -0,0 +1,365 @@
<!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 can't advance an
// animated WebP in a WebGL texture on iOS, so we render it as a real DOM
// <img> overlay (which animates natively) and project an invisible 3D
// anchor's screen position onto it each frame. The ghost still tracks
// distance/size and bobs, and transparency is native to the WebP.
const ovl = document.createElement('img');
ovl.crossOrigin = 'anonymous'; ovl.src = image;
ovl.style.cssText = 'position:fixed;left:0;top:0;pointer-events:none;z-index:5;transform:translate(-50%,-50%);will-change:left,top,width;';
document.body.appendChild(ovl);
// invisible anchor mesh so the existing bob/lookAt/position math applies
mesh = new THREE.Mesh(
new THREE.PlaneGeometry(1, 1),
new THREE.MeshBasicMaterial({ visible: false })
);
group.userData.overlayImg = ovl;
group.userData.overlayAspect = 1;
ovl.onload = () => {
group.userData.overlayAspect = (ovl.naturalWidth || 1) / (ovl.naturalHeight || 1);
};
} 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.overlayImg) { try { current.userData.overlayImg.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();
});
const _v = new THREE.Vector3();
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;
// iOS WebP overlay: project the invisible anchor to screen space and
// position/scale the DOM <img> so it tracks the 3D ghost (and animates).
const ud = current.userData;
if (ud.overlayImg) {
_v.copy(current.position);
_v.project(camera);
const onScreen = _v.z < 1;
if (onScreen) {
const sx = (_v.x * 0.5 + 0.5) * innerWidth;
const sy = (-_v.y * 0.5 + 0.5) * innerHeight;
// size in px: shrink with distance. fov-based scale at the anchor depth.
const depth = Math.abs(distance);
const worldH = size; // plane is 1 unit * size
const vFov = (camera.fov * Math.PI) / 180;
const viewH = 2 * Math.tan(vFov / 2) * depth;
const px = (worldH / viewH) * innerHeight;
ud.overlayImg.style.display = '';
ud.overlayImg.style.left = sx + 'px';
ud.overlayImg.style.top = sy + 'px';
ud.overlayImg.style.height = px + 'px';
ud.overlayImg.style.width = px * ud.overlayAspect + 'px';
} else {
ud.overlayImg.style.display = 'none';
}
}
}
renderer.render(scene, camera);
}
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
</script>
</body>
</html>
+68 -14
View File
@@ -6,6 +6,7 @@ import { dirname, extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import db from '../db/index.js';
import { requireAuth } from './auth-middleware.js';
import { convertGhostMp4 } from '../lib/ghost-media.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const UPLOAD_DIR = join(__dirname, '..', process.env.UPLOAD_DIR || 'uploads');
@@ -14,7 +15,8 @@ mkdirSync(UPLOAD_DIR, { recursive: true });
const router = Router();
router.use(requireAuth); // everything here requires a valid JWT
const ALLOWED = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp']);
const ALLOWED = new Set(['.gif', '.png', '.jpg', '.jpeg', '.webp', '.webm', '.mp4']);
const VIDEO_EXTS = new Set(['.mp4', '.webm']);
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOAD_DIR),
filename: (_req, file, cb) => {
@@ -24,13 +26,20 @@ const storage = multer.diskStorage({
});
const upload = multer({
storage,
limits: { fileSize: 8 * 1024 * 1024 },
limits: { fileSize: 64 * 1024 * 1024 }, // 64MB — source MP4s are larger than GIFs
fileFilter: (_req, file, cb) => {
const ext = extname(file.originalname).toLowerCase();
cb(ALLOWED.has(ext) ? null : new Error('unsupported file type'), ALLOWED.has(ext));
},
});
// Remove an uploaded file by bare filename, ignoring errors.
function removeUpload(filename) {
if (!filename) return;
const p = join(UPLOAD_DIR, filename);
if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
}
const toInt = (v, d = 0) => (Number.isFinite(+v) ? parseInt(v, 10) : d);
/* ---------------- Ghosts ---------------- */
@@ -87,26 +96,71 @@ router.patch('/ghosts/:id', (req, res) => {
res.json({ ok: true });
});
router.post('/ghosts/:id/image', upload.single('image'), (req, res) => {
router.post('/ghosts/:id/image', upload.single('image'), async (req, res) => {
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
if (!ghost) return res.status(404).json({ error: 'not found' });
if (!req.file) return res.status(400).json({ error: 'no file' });
// remove old image file if present
if (ghost.image_path) {
const old = join(UPLOAD_DIR, ghost.image_path);
if (existsSync(old)) try { unlinkSync(old); } catch { /* ignore */ }
if (!ghost) {
if (req.file) removeUpload(req.file.filename);
return res.status(404).json({ error: 'not found' });
}
db.prepare('UPDATE ghosts SET image_path = ? WHERE id = ?').run(req.file.filename, ghost.id);
if (!req.file) return res.status(400).json({ error: 'no file' });
const ext = extname(req.file.filename).toLowerCase();
// Clear any previous media (image + video sprites) before recording the new set.
const cleanupOld = () => {
removeUpload(ghost.image_path);
removeUpload(ghost.webm_path);
removeUpload(ghost.webp_path);
};
if (ext === '.mp4') {
// Convert the source MP4 to a transparent WebM (VP9+alpha) plus a WebP
// fallback via luma keying. The original MP4 is removed afterwards.
let out;
try {
out = await convertGhostMp4(UPLOAD_DIR, req.file.filename);
} catch (e) {
removeUpload(req.file.filename);
return res.status(500).json({ error: 'conversion failed', detail: e.message });
}
removeUpload(req.file.filename); // discard the raw mp4
if (!out.webm && !out.webp) {
return res.status(500).json({ error: 'conversion produced no output (is ffmpeg installed?)' });
}
cleanupOld();
// webp doubles as the still/thumbnail image where present.
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = ?, image_path = ? WHERE id = ?')
.run(out.webm, out.webp, out.webp, ghost.id);
return res.json({
ok: true,
webm: out.webm ? `/uploads/${out.webm}` : null,
webp: out.webp ? `/uploads/${out.webp}` : null,
image: out.webp ? `/uploads/${out.webp}` : null,
});
}
if (ext === '.webm') {
// Pre-made transparent WebM uploaded directly — store as-is.
cleanupOld();
db.prepare('UPDATE ghosts SET webm_path = ?, webp_path = NULL, image_path = NULL WHERE id = ?')
.run(req.file.filename, ghost.id);
return res.json({ ok: true, webm: `/uploads/${req.file.filename}` });
}
// Plain image (gif/png/jpg/webp) — the original billboard path. Clear any
// previous video sprites so the ghost falls back cleanly to the image.
cleanupOld();
db.prepare('UPDATE ghosts SET image_path = ?, webm_path = NULL, webp_path = NULL WHERE id = ?')
.run(req.file.filename, ghost.id);
res.json({ ok: true, image: `/uploads/${req.file.filename}` });
});
router.delete('/ghosts/:id', (req, res) => {
const ghost = db.prepare('SELECT * FROM ghosts WHERE id = ?').get(req.params.id);
if (!ghost) return res.status(404).json({ error: 'not found' });
if (ghost.image_path) {
const p = join(UPLOAD_DIR, ghost.image_path);
if (existsSync(p)) try { unlinkSync(p); } catch { /* ignore */ }
}
removeUpload(ghost.image_path);
removeUpload(ghost.webm_path);
removeUpload(ghost.webp_path);
db.prepare('DELETE FROM ghosts WHERE id = ?').run(ghost.id);
res.json({ ok: true });
});
+5 -1
View File
@@ -24,6 +24,8 @@ function rowToGhost(g) {
setNumber: g.set_number,
setName: g.set_name,
image: g.image_path ? `/uploads/${g.image_path}` : null,
webm: g.webm_path ? `/uploads/${g.webm_path}` : null,
webp: g.webp_path ? `/uploads/${g.webp_path}` : null,
};
}
@@ -73,7 +75,9 @@ router.get('/scan/:code', (req, res) => {
router.get('/freehunt', (req, res) => {
const n = Math.min(parseInt(req.query.n, 10) || 3, 10);
const type = req.query.type; // optional red|yellow|blue filter
let q = 'SELECT * FROM ghosts WHERE enabled = 1 AND is_boss = 0';
// Free hunt only spawns ghosts with an uploaded video (webm); the webp/image
// fallbacks are derived from it, so these always render on every platform.
let q = 'SELECT * FROM ghosts WHERE enabled = 1 AND is_boss = 0 AND webm_path IS NOT NULL';
const params = [];
if (type && ['red', 'yellow', 'blue'].includes(type)) {
q += ' AND type = ?';
+5
View File
@@ -35,6 +35,11 @@ app.get('/admin', (_req, res) => {
res.sendFile(join(__dirname, 'public', 'admin.html'));
});
// Ghost preview page (admin-gated client-side; ghost list requires a JWT).
app.get('/preview', (_req, res) => {
res.sendFile(join(__dirname, 'public', 'preview.html'));
});
// JSON error handler (multer + thrown errors).
app.use((err, _req, res, _next) => {
console.error(err);