Landing page editor: logo/background/subtitle, markdown details page, editable disclaimer, photo shutter
This commit is contained in:
+83
-1
@@ -10,6 +10,7 @@ import { WorldFuser } from './ar/fuse.js';
|
||||
import { buildGhost } from './ghosts/loader.js';
|
||||
import { ghostTransform } from './ghosts/behavior.js';
|
||||
import { ExhibitNet, installErrorReporter } from './net.js';
|
||||
import { renderMarkdown } from './md.js';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
let info = { mode: 'dev' };
|
||||
@@ -26,17 +27,54 @@ async function boot() {
|
||||
info = await (await fetch('/api/info')).json();
|
||||
installErrorReporter(info.mode === 'dev');
|
||||
|
||||
await applyLanding();
|
||||
|
||||
const g = await (await fetch('/api/ghosts')).json();
|
||||
gradients = g.gradients.gradients || g.gradients;
|
||||
modelManifest = await (await fetch('/api/models')).json();
|
||||
characters = await (await fetch('/api/characters')).json();
|
||||
|
||||
$('#start').addEventListener('click', start, { once: true });
|
||||
$('#detailsBtn').addEventListener('click', () => $('#details').classList.remove('hidden'));
|
||||
$('#backBtn').addEventListener('click', () => $('#details').classList.add('hidden'));
|
||||
}
|
||||
|
||||
/* Paint the operator-authored landing page + details page. */
|
||||
async function applyLanding() {
|
||||
let L;
|
||||
try { L = await (await fetch('/api/landing')).json(); } catch { return; }
|
||||
const set = (sel, txt) => { const el = $(sel); if (el) el.textContent = txt || ''; };
|
||||
|
||||
document.documentElement.style.setProperty('--accent', L.accent || '#51eaf1');
|
||||
document.documentElement.style.setProperty('--text', L.textColor || '#e8ecff');
|
||||
document.documentElement.style.setProperty('--ov', L.overlay != null ? L.overlay : 0.55);
|
||||
|
||||
if (L.backgroundUrl) $('#startScreen').style.backgroundImage = `url("${L.backgroundUrl}")`;
|
||||
if (L.logoUrl) {
|
||||
const img = $('#logo');
|
||||
img.src = L.logoUrl; img.alt = L.title || 'logo';
|
||||
img.classList.remove('hidden');
|
||||
$('#title').classList.add('hidden'); // logo art replaces the text title
|
||||
} else {
|
||||
set('#title', L.title);
|
||||
}
|
||||
set('#subtitle', L.subtitle);
|
||||
set('#start', L.startButton || 'Start');
|
||||
set('#detailsBtn', L.detailsButton || 'About');
|
||||
set('#detailsTitle', L.detailsTitle);
|
||||
$('#detailsBody').innerHTML = renderMarkdown(L.detailsMarkdown);
|
||||
// disclaimer is always rendered on both screens
|
||||
set('#disc1', L.disclaimer);
|
||||
set('#disc2', L.disclaimer);
|
||||
if (!L.detailsMarkdown) $('#detailsBtn').classList.add('hidden');
|
||||
document.title = L.title || 'Newbury Nights';
|
||||
}
|
||||
|
||||
async function start() {
|
||||
$('#startScreen').classList.add('hidden');
|
||||
$('#hud').classList.remove('hidden');
|
||||
$('#shutter').classList.remove('hidden');
|
||||
$('#shutter').addEventListener('click', capturePhoto);
|
||||
|
||||
// camera
|
||||
video = $('#cam');
|
||||
@@ -52,7 +90,7 @@ async function start() {
|
||||
ctx2d = canvas2d.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
// three.js
|
||||
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true });
|
||||
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true, preserveDrawingBuffer: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
scene3 = new THREE.Scene();
|
||||
camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 2, 5000);
|
||||
@@ -195,6 +233,50 @@ function loop(t) {
|
||||
renderer.render(scene3, camera3);
|
||||
}
|
||||
|
||||
/* Shutter: composite the live camera frame with the rendered ghosts and save a PNG.
|
||||
* The WebGL canvas is drawn on top of a video frame at full video resolution. */
|
||||
async function capturePhoto() {
|
||||
try {
|
||||
const flash = $('#flash');
|
||||
flash.classList.add('on');
|
||||
setTimeout(() => flash.classList.remove('on'), 60);
|
||||
|
||||
const vw = video.videoWidth, vh = video.videoHeight;
|
||||
const out = document.createElement('canvas');
|
||||
out.width = vw; out.height = vh;
|
||||
const c = out.getContext('2d');
|
||||
c.drawImage(video, 0, 0, vw, vh);
|
||||
|
||||
// renderer canvas is CSS-sized to the viewport with object-fit:cover on the video,
|
||||
// so replicate that cover mapping when overlaying the 3D layer
|
||||
const gl = $('#gl');
|
||||
const scale = Math.max(vw / gl.width, vh / gl.height);
|
||||
const dw = gl.width * scale, dh = gl.height * scale;
|
||||
renderer.render(scene3, camera3); // ensure the buffer is current
|
||||
c.drawImage(gl, (vw - dw) / 2, (vh - dh) / 2, dw, dh);
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const blob = await new Promise(r => out.toBlob(r, 'image/png'));
|
||||
const file = new File([blob], `newbury-${stamp}.png`, { type: 'image/png' });
|
||||
|
||||
// native share sheet on mobile (lets users save to camera roll), download elsewhere
|
||||
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||
await navigator.share({ files: [file], title: 'Newbury Nights' });
|
||||
toast('Photo ready to share');
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = file.name; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
toast('Photo saved');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.name === 'AbortError') return; // user dismissed the share sheet
|
||||
toast('Could not save photo');
|
||||
console.warn('capture failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg) {
|
||||
const el = $('#toast');
|
||||
|
||||
Reference in New Issue
Block a user