Fix preview.html encoding (store raw HTML, not base64)
This commit is contained in:
1 parent
a8552592c7
commit
e81f779ea5
1 file changed
+303
-1
+303
-1
@@ -1 +1,303 @@
|
|||||||
PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0idXRmLTgiIC8+CiAgPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLCB2aWV3cG9ydC1maXQ9Y292ZXIsIG1heGltdW0tc2NhbGU9MSIgLz4KICA8dGl0bGU+TmV3YnVyeSBOaWdodHMg4oCUIEdob3N0IFByZXZpZXc8L3RpdGxlPgogIDxsaW5rIHJlbD0ic3R5bGVzaGVldCIgaHJlZj0iL2Nzcy9hcHAuY3NzIiAvPgogIDxzY3JpcHQgdHlwZT0iaW1wb3J0bWFwIj4KICB7CiAgICAiaW1wb3J0cyI6IHsKICAgICAgInRocmVlIjogImh0dHBzOi8vY2RuLmpzZGVsaXZyLm5ldC9ucG0vdGhyZWVAMC4xNjAuMC9idWlsZC90aHJlZS5tb2R1bGUuanMiCiAgICB9CiAgfQogIDwvc2NyaXB0PgogIDxzdHlsZT4KICAgICNwdi1zdGFnZSB7IHBvc2l0aW9uOiBmaXhlZDsgaW5zZXQ6IDA7IG92ZXJmbG93OiBoaWRkZW47IGJhY2tncm91bmQ6ICMwNzA5MGY7IH0KICAgICNwdi12aWRlbyB7IHBvc2l0aW9uOiBhYnNvbHV0ZTsgaW5zZXQ6IDA7IHdpZHRoOiAxMDAlOyBoZWlnaHQ6IDEwMCU7IG9iamVjdC1maXQ6IGNvdmVyOyB9CiAgICAjcHYtY2FudmFzIHsgcG9zaXRpb246IGFic29sdXRlOyBpbnNldDogMDsgd2lkdGg6IDEwMCU7IGhlaWdodDogMTAwJTsgfQogICAgLnB2LWJhciB7CiAgICAgIHBvc2l0aW9uOiBmaXhlZDsgbGVmdDogMDsgcmlnaHQ6IDA7IHRvcDogMDsgei1pbmRleDogMTA7CiAgICAgIGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMTBweDsgZmxleC13cmFwOiB3cmFwOwogICAgICBwYWRkaW5nOiAxMHB4IDE0cHg7IGJhY2tncm91bmQ6IHJnYmEoNyw5LDE1LC44Mik7IGJhY2tkcm9wLWZpbHRlcjogYmx1cig4cHgpOwogICAgICBib3JkZXItYm90dG9tOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMDgpOwogICAgfQogICAgLnB2LWJhciAuYnJhbmQgeyBmb250LXdlaWdodDogNzAwOyBsZXR0ZXItc3BhY2luZzogLjA0ZW07IHRleHQtdHJhbnNmb3JtOiB1cHBlcmNhc2U7IGZvbnQtc2l6ZTogMTRweDsgfQogICAgLnB2LWJhciAuYnJhbmQgc3BhbiB7IGNvbG9yOiB2YXIoLS1ibHVlLCAjM2JiNmZmKTsgfQogICAgLnB2LWJhciBzZWxlY3QsIC5wdi1iYXIgYnV0dG9uIHsgZm9udC1zaXplOiAxM3B4OyB9CiAgICAucHYtYmFyIC5ncm93IHsgZmxleDogMTsgfQogICAgLnB2LWhpbnQgeyBwb3NpdGlvbjogZml4ZWQ7IGxlZnQ6IDA7IHJpZ2h0OiAwOyBib3R0b206IDA7IHotaW5kZXg6IDEwOwogICAgICB0ZXh0LWFsaWduOiBjZW50ZXI7IHBhZGRpbmc6IDhweDsgZm9udC1mYW1pbHk6IHZhcigtLW1vbm8sIG1vbm9zcGFjZSk7IGZvbnQtc2l6ZTogMTFweDsKICAgICAgY29sb3I6IHJnYmEoMjU1LDI1NSwyNTUsLjcpOyBiYWNrZ3JvdW5kOiBsaW5lYXItZ3JhZGllbnQodHJhbnNwYXJlbnQsIHJnYmEoNyw5LDE1LC43NSkpOyB9CiAgICAucHYtY29udHJvbHMgeyBwb3NpdGlvbjogZml4ZWQ7IHJpZ2h0OiAxNHB4OyBib3R0b206IDM2cHg7IHotaW5kZXg6IDEwOwogICAgICBkaXNwbGF5OiBmbGV4OyBmbGV4LWRpcmVjdGlvbjogY29sdW1uOyBnYXA6IDhweDsgYWxpZ24taXRlbXM6IHN0cmV0Y2g7IH0KICAgIC5wdi1jb250cm9scyBsYWJlbCB7IGZvbnQtZmFtaWx5OiB2YXIoLS1tb25vLCBtb25vc3BhY2UpOyBmb250LXNpemU6IDEwcHg7IGxldHRlci1zcGFjaW5nOiAuMWVtOwogICAgICB0ZXh0LXRyYW5zZm9ybTogdXBwZXJjYXNlOyBjb2xvcjogcmdiYSgyNTUsMjU1LDI1NSwuNjUpOyB9CiAgICAucHYtY29udHJvbHMgaW5wdXRbdHlwZT1yYW5nZV0geyB3aWR0aDogMTUwcHg7IH0KICAgIC8qIExvZ2luIGdhdGUgKi8KICAgICNwdi1sb2dpbiB7IHBvc2l0aW9uOiBmaXhlZDsgaW5zZXQ6IDA7IHotaW5kZXg6IDIwOyBkaXNwbGF5OiBncmlkOyBwbGFjZS1pdGVtczogY2VudGVyOwogICAgICBwYWRkaW5nOiAyNHB4OyBiYWNrZ3JvdW5kOiAjMDcwOTBmOyB9CiAgICAucHYtbG9naW4tY2FyZCB7IGJhY2tncm91bmQ6IHZhcigtLXBhbmVsLCAjMTExNTFmKTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7CiAgICAgIGJvcmRlci1yYWRpdXM6IDE0cHg7IHBhZGRpbmc6IDMwcHg7IHdpZHRoOiAxMDAlOyBtYXgtd2lkdGg6IDM2MHB4OyB9CiAgICAucHYtbG9naW4tY2FyZCBoMSB7IGZvbnQtc2l6ZTogMzBweDsgbGluZS1oZWlnaHQ6IC45OyBtYXJnaW46IDAgMCA0cHg7IH0KICAgIC5wdi1sb2dpbi1jYXJkIGgxIHNwYW4geyBjb2xvcjogdmFyKC0tYmx1ZSwgIzNiYjZmZik7IH0KICAgIC5wdi1sb2dpbi1jYXJkIC5zdWIgeyBmb250LXNpemU6IDExcHg7IGxldHRlci1zcGFjaW5nOiAuM2VtOyBjb2xvcjogcmdiYSgyNTUsMjU1LDI1NSwuNTUpOwogICAgICBtYXJnaW46IDZweCAwIDIycHg7IGZvbnQtZmFtaWx5OiB2YXIoLS1tb25vLCBtb25vc3BhY2UpOyB9CiAgICAucHYtbG9naW4tY2FyZCBsYWJlbCB7IGRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tdG9wOiAxMnB4OyBmb250LXNpemU6IDEzcHg7IH0KICAgIC5wdi1sb2dpbi1jYXJkIGlucHV0IHsgd2lkdGg6IDEwMCU7IGJveC1zaXppbmc6IGJvcmRlci1ib3g7IH0KICAgIC5wdi1lcnIgeyBjb2xvcjogdmFyKC0tZGFuZ2VyLCAjZmYzYjVjKTsgZm9udC1zaXplOiAxM3B4OyBtYXJnaW4tdG9wOiAxMnB4OyB9CiAgICAuaGlkZGVuIHsgZGlzcGxheTogbm9uZSAhaW1wb3J0YW50OyB9CiAgPC9zdHlsZT4KPC9oZWFkPgo8Ym9keT4KICA8IS0tIExvZ2luIGdhdGUgKHNhbWUgY2xpZW50LXNpZGUgZmxvdyBhcyAvYWRtaW4pIC0tPgogIDxkaXYgaWQ9InB2LWxvZ2luIj4KICAgIDxkaXYgY2xhc3M9InB2LWxvZ2luLWNhcmQiPgogICAgICA8aDE+TmV3YnVyeTxzcGFuPk5pZ2h0czwvc3Bhbj48L2gxPgogICAgICA8ZGl2IGNsYXNzPSJzdWIiPkdIT1NUIFBSRVZJRVc8L2Rpdj4KICAgICAgPGxhYmVsIGZvcj0icHYtdXNlciI+VXNlcm5hbWU8L2xhYmVsPgogICAgICA8aW5wdXQgaWQ9InB2LXVzZXIiIGF1dG9jb21wbGV0ZT0idXNlcm5hbWUiIC8+CiAgICAgIDxsYWJlbCBmb3I9InB2LXBhc3MiPlBhc3N3b3JkPC9sYWJlbD4KICAgICAgPGlucHV0IGlkPSJwdi1wYXNzIiB0eXBlPSJwYXNzd29yZCIgYXV0b2NvbXBsZXRlPSJjdXJyZW50LXBhc3N3b3JkIiAvPgogICAgICA8YnV0dG9uIGNsYXNzPSJwcmltYXJ5IiBpZD0icHYtZ28iIHN0eWxlPSJ3aWR0aDoxMDAlO21hcmdpbi10b3A6MTRweCI+U2lnbiBpbjwvYnV0dG9uPgogICAgICA8ZGl2IGlkPSJwdi1sb2dpbi1lcnIiIGNsYXNzPSJwdi1lcnIgaGlkZGVuIj48L2Rpdj4KICAgIDwvZGl2PgogIDwvZGl2PgoKICA8IS0tIFByZXZpZXcgc3RhZ2UgLS0+CiAgPGRpdiBpZD0icHYtc3RhZ2UiIGNsYXNzPSJoaWRkZW4iPgogICAgPHZpZGVvIGlkPSJwdi12aWRlbyIgcGxheXNpbmxpbmUgbXV0ZWQ+PC92aWRlbz4KICAgIDxjYW52YXMgaWQ9InB2LWNhbnZhcyI+PC9jYW52YXM+CiAgPC9kaXY+CgogIDxkaXYgaWQ9InB2LWFwcCIgY2xhc3M9ImhpZGRlbiI+CiAgICA8ZGl2IGNsYXNzPSJwdi1iYXIiPgogICAgICA8ZGl2IGNsYXNzPSJicmFuZCI+TmV3YnVyeTxzcGFuPk5pZ2h0czwvc3Bhbj4gwrcgUFJFVklFVzwvZGl2PgogICAgICA8c2VsZWN0IGlkPSJwdi1zZWxlY3QiPjxvcHRpb24gdmFsdWU9IiI+TG9hZGluZyBnaG9zdHPigKY8L29wdGlvbj48L3NlbGVLine truncated
|
<!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;
|
||||||
|
|
||||||
|
// API field names from rowToGhost: webm / webp / image (URLs or null).
|
||||||
|
const webm = data.webm || null;
|
||||||
|
const image = data.image || data.webp || null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
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 });
|
||||||
|
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);
|
||||||
|
$('#pv-hint').textContent = `${data.name}${data.webm ? ' · WebM' + (SUPPORTS_WEBM_ALPHA ? '' : ' (no VP9-alpha -> fallback)') : data.image ? ' · image' : ' · procedural wisp'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
Reference in new issue
Block a user