Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efadc85195 | |||
| 653980a52e | |||
| 25832dd58f | |||
| 4cf8ec07a4 | |||
| 793a17dbdf | |||
| 6f8b67b583 | |||
| b2a863bd80 | |||
| 2d66d809d4 | |||
| e81f779ea5 | |||
| a8552592c7 | |||
| 458c66a2c0 | |||
| 327b37babb | |||
| 3fa50c4c2f | |||
| 8e8e259b4c | |||
| 2fe4d67518 | |||
| 0c5123e3a6 | |||
| dc5e032b3a | |||
| 22983caa18 | |||
| 9866e44445 | |||
| ec4442d4ce |
+14
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+1496
File diff suppressed because it is too large
Load Diff
+4
-3
@@ -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>
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+68
-14
@@ -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
@@ -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 = ?';
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user