Landing page editor: logo/background/subtitle, markdown details page, editable disclaimer, photo shutter
This commit is contained in:
@@ -39,7 +39,7 @@ export const styles = `
|
||||
export function nav(active) {
|
||||
return `<header>
|
||||
<h1>Newbury Exhibit — Admin</h1>
|
||||
${['preview', 'layout', 'plan', 'playlist', 'characters', 'print', 'calibrate', 'errors'].map(p =>
|
||||
${['preview', 'layout', 'plan', 'playlist', 'characters', 'landing', 'print', 'calibrate', 'errors'].map(p =>
|
||||
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
|
||||
<a href="/">viewer ↗</a>
|
||||
</header>`;
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Landing Page — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
import { renderMarkdown } from '/js/md.js';
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
#wrap { display:grid; grid-template-columns: 1fr 420px; height:calc(100vh - 44px); }
|
||||
#form { padding:14px 18px; overflow:auto; }
|
||||
#form h2 { font-size:.9rem; color:#8fb8ff; margin:16px 0 6px; }
|
||||
#form h2:first-child { margin-top:0; }
|
||||
.row { display:flex; gap:8px; align-items:center; margin:6px 0; }
|
||||
.row label { width:120px; font-size:.78rem; }
|
||||
.row input, .row select, .row textarea { flex:1; min-width:0; font-family:inherit; }
|
||||
.row input[type=color] { padding:1px; height:28px; max-width:60px; }
|
||||
textarea { min-height:190px; background:#141a33; color:#fff; border:1px solid #2a3a6e;
|
||||
border-radius:5px; padding:8px; line-height:1.5; resize:vertical; }
|
||||
.badge { font-size:.72rem; opacity:.62; line-height:1.5; }
|
||||
.imgpick { display:flex; gap:8px; align-items:center; }
|
||||
.imgpick img { width:44px; height:44px; object-fit:contain; background:#0d1226; border-radius:5px; }
|
||||
#preview { background:#080b16; border-left:1px solid #1e2748; overflow:auto; padding:14px; }
|
||||
.phone { width:340px; height:680px; margin:0 auto; border-radius:26px; border:8px solid #222a44;
|
||||
overflow:hidden; position:relative; background:#06080f; }
|
||||
.scr { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; text-align:center; padding:22px; background-size:cover; background-position:center; }
|
||||
.scr::before { content:''; position:absolute; inset:0; background:#06080f; }
|
||||
.scr > * { position:relative; z-index:1; }
|
||||
.scr img.logo { max-width:74%; max-height:30%; margin-bottom:14px; }
|
||||
.scr h1 { font-size:1.6rem; margin:0 0 8px; letter-spacing:.05em; }
|
||||
.scr p.sub { font-size:.85rem; opacity:.85; margin:0 0 22px; line-height:1.5; }
|
||||
.scr .b1 { padding:11px 30px; border-radius:999px; border:0; font-weight:700; font-size:.95rem; }
|
||||
.scr .b2 { margin-top:11px; padding:7px 18px; border-radius:999px; background:transparent;
|
||||
border:1px solid rgba(255,255,255,.35); font-size:.8rem; }
|
||||
.scr .disc { position:absolute; bottom:12px; left:14px; right:14px; font-size:.58rem; opacity:.55; line-height:1.35; z-index:1; }
|
||||
.dscr { position:absolute; inset:0; overflow:auto; padding:18px; background:#080b16; }
|
||||
.dscr h1 { font-size:1.25rem; margin:0 0 10px; }
|
||||
.dscr p, .dscr li { font-size:.82rem; line-height:1.6; opacity:.9; }
|
||||
.tabs { display:flex; gap:6px; justify-content:center; margin-bottom:10px; }
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
let L = await api('/api/landing');
|
||||
let assets = await api('/api/assets');
|
||||
let tab = 'start';
|
||||
|
||||
const images = () => assets.assets.filter(a => a.kind === 'texture');
|
||||
|
||||
function render() {
|
||||
app.innerHTML = `${nav('landing')}
|
||||
<div id="wrap">
|
||||
<div id="form">
|
||||
<h2>Artwork</h2>
|
||||
<div class="row"><label>logo</label>
|
||||
<div class="imgpick" style="flex:1">
|
||||
<img src="${L.logoUrl || ''}" onerror="this.style.visibility='hidden'">
|
||||
<select id="logoUrl" style="flex:1"><option value="">— none (show text title) —</option>
|
||||
${images().map(a => `<option value="${a.url}" ${L.logoUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div></div>
|
||||
<div class="row"><label>background</label>
|
||||
<div class="imgpick" style="flex:1">
|
||||
<img src="${L.backgroundUrl || ''}" onerror="this.style.visibility='hidden'">
|
||||
<select id="backgroundUrl" style="flex:1"><option value="">— none —</option>
|
||||
${images().map(a => `<option value="${a.url}" ${L.backgroundUrl === a.url ? 'selected' : ''}>${a.name}</option>`).join('')}
|
||||
</select></div></div>
|
||||
<div class="row"><label>upload</label><input type="file" id="file" accept=".png,.jpg,.jpeg,.webp" multiple></div>
|
||||
<div class="badge">Uploads share the asset library with the character manager.</div>
|
||||
|
||||
<h2>Landing page</h2>
|
||||
<div class="row"><label>title</label><input id="title" value="${esc(L.title)}"></div>
|
||||
<div class="badge">Shown only when no logo is selected.</div>
|
||||
<div class="row"><label>subtitle</label><input id="subtitle" value="${esc(L.subtitle)}"></div>
|
||||
<div class="row"><label>start button</label><input id="startButton" value="${esc(L.startButton)}"></div>
|
||||
<div class="row"><label>about button</label><input id="detailsButton" value="${esc(L.detailsButton)}"></div>
|
||||
|
||||
<h2>Details page</h2>
|
||||
<div class="row"><label>heading</label><input id="detailsTitle" value="${esc(L.detailsTitle)}"></div>
|
||||
<div class="row" style="align-items:flex-start"><label>body</label>
|
||||
<textarea id="detailsMarkdown">${esc(L.detailsMarkdown)}</textarea></div>
|
||||
<div class="badge">Markdown: <code>#</code> heading · <code>**bold**</code> · <code>*italic*</code> ·
|
||||
<code>- list</code> · <code>[text](https://…)</code> · blank line = new paragraph.
|
||||
Leave empty to hide the About button entirely.</div>
|
||||
|
||||
<h2>Theme</h2>
|
||||
<div class="row"><label>accent</label><input type="color" id="accent" value="${L.accent}">
|
||||
<label style="width:auto">text</label><input type="color" id="textColor" value="${L.textColor}"></div>
|
||||
<div class="row"><label>bg dimming</label>
|
||||
<input type="range" min="0" max="1" step="0.05" id="overlay" value="${L.overlay}">
|
||||
<span class="badge" id="ovv">${L.overlay}</span></div>
|
||||
|
||||
<h2>Disclaimer</h2>
|
||||
<div class="row" style="align-items:flex-start"><label>text</label>
|
||||
<textarea id="disclaimer" style="min-height:80px">${esc(L.disclaimer)}</textarea></div>
|
||||
<div class="badge">Always shown on both screens — wording is yours to edit, but it can't be removed.
|
||||
If left blank it reverts to the default wording.</div>
|
||||
|
||||
<button id="save" class="primary" style="width:100%;padding:11px;margin:16px 0 6px">Save landing page</button>
|
||||
<div id="status" class="badge"></div>
|
||||
</div>
|
||||
|
||||
<div id="preview">
|
||||
<div class="tabs">
|
||||
<button data-tab="start" class="${tab === 'start' ? 'primary' : ''}">Landing</button>
|
||||
<button data-tab="details" class="${tab === 'details' ? 'primary' : ''}">Details</button>
|
||||
</div>
|
||||
<div class="phone">${tab === 'start' ? startPreview() : detailsPreview()}</div>
|
||||
<div class="badge" style="text-align:center;margin-top:8px">Live preview · approximate phone size</div>
|
||||
</div>
|
||||
</div>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function startPreview() {
|
||||
return `<div class="scr" style="background-image:${L.backgroundUrl ? `url('${L.backgroundUrl}')` : 'none'};color:${L.textColor}">
|
||||
<style>.phone .scr::before{opacity:${L.overlay}}</style>
|
||||
${L.logoUrl ? `<img class="logo" src="${L.logoUrl}">` : `<h1>${esc(L.title)}</h1>`}
|
||||
<p class="sub">${esc(L.subtitle)}</p>
|
||||
<button class="b1" style="background:linear-gradient(135deg,${L.accent},#529eff);color:#04121a">${esc(L.startButton)}</button>
|
||||
${L.detailsMarkdown ? `<button class="b2" style="color:${L.textColor}">${esc(L.detailsButton)}</button>` : ''}
|
||||
<div class="disc">${esc(L.disclaimer)}</div>
|
||||
</div>`;
|
||||
}
|
||||
function detailsPreview() {
|
||||
return `<div class="dscr" style="color:${L.textColor}">
|
||||
<h1 style="color:${L.accent}">${esc(L.detailsTitle)}</h1>
|
||||
${renderMarkdown(L.detailsMarkdown)}
|
||||
<div class="disc" style="position:static;margin-top:24px;font-size:.6rem;opacity:.55">${esc(L.disclaimer)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const live = (id, key) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.oninput = () => {
|
||||
L[key] = el.type === 'range' ? parseFloat(el.value) : el.value;
|
||||
if (id === 'overlay') document.getElementById('ovv').textContent = L.overlay;
|
||||
// repaint preview only — keeps focus/caret in the field being edited
|
||||
document.querySelector('.phone').innerHTML = tab === 'start' ? startPreview() : detailsPreview();
|
||||
};
|
||||
el.onchange = () => { if (['logoUrl', 'backgroundUrl'].includes(id)) render(); };
|
||||
};
|
||||
['title', 'subtitle', 'startButton', 'detailsButton', 'detailsTitle', 'detailsMarkdown',
|
||||
'disclaimer', 'accent', 'textColor', 'overlay', 'logoUrl', 'backgroundUrl'].forEach(k => live(k, k));
|
||||
|
||||
document.querySelectorAll('[data-tab]').forEach(b => b.onclick = () => { tab = b.dataset.tab; render(); });
|
||||
|
||||
document.getElementById('file').onchange = async e => {
|
||||
for (const f of [...e.target.files]) {
|
||||
const dataBase64 = await new Promise((res, rej) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => res(String(r.result).split(',')[1]);
|
||||
r.onerror = () => rej(new Error('read failed'));
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
try { await api('/api/assets', 'POST', { name: f.name, kind: 'texture', dataBase64 }); }
|
||||
catch (err) { alert(`${f.name}: ${err.message}`); }
|
||||
}
|
||||
assets = await api('/api/assets');
|
||||
render();
|
||||
};
|
||||
|
||||
document.getElementById('save').onclick = async () => {
|
||||
const st = document.getElementById('status');
|
||||
try { L = await api('/api/landing', 'PUT', L); render();
|
||||
document.getElementById('status').textContent = 'Saved ' + new Date().toLocaleTimeString() +
|
||||
' — reload the viewer to see it.';
|
||||
} catch (e) { st.textContent = 'Save failed: ' + e.message; }
|
||||
};
|
||||
}
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
+50
-15
@@ -3,29 +3,52 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Newbury Exhibit</title>
|
||||
<title>Newbury Nights</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { margin:0; height:100%; overflow:hidden; background:#06080f; font-family:system-ui, sans-serif; color:#e8ecff; }
|
||||
:root { color-scheme: dark; --accent:#51eaf1; --text:#e8ecff; }
|
||||
html, body { margin:0; height:100%; overflow:hidden; background:#06080f; font-family:system-ui, sans-serif; color:var(--text); }
|
||||
#cam { position:fixed; inset:0; width:100%; height:100%; object-fit:cover; }
|
||||
#gl { position:fixed; inset:0; width:100%; height:100%; }
|
||||
.hidden { display:none !important; }
|
||||
|
||||
#startScreen { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
background:radial-gradient(ellipse at 50% 30%, #12203f 0%, #06080f 70%); text-align:center; padding:24px; z-index:10; }
|
||||
#startScreen h1 { font-size:2rem; margin:0 0 4px; letter-spacing:.06em; }
|
||||
#startScreen .sub { opacity:.7; margin-bottom:28px; }
|
||||
#start { font-size:1.2rem; padding:14px 42px; border-radius:999px; border:0; cursor:pointer;
|
||||
background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:700; }
|
||||
text-align:center; padding:24px; z-index:10; background-size:cover; background-position:center; }
|
||||
#startScreen::before { content:''; position:absolute; inset:0; background:#06080f; opacity:var(--ov,.55); }
|
||||
#startScreen > * { position:relative; }
|
||||
#logo { max-width:min(78vw, 460px); max-height:34vh; margin-bottom:18px; }
|
||||
#title { font-size:clamp(1.8rem,7vw,2.8rem); margin:0 0 10px; letter-spacing:.06em; }
|
||||
#subtitle { opacity:.85; margin:0 0 30px; max-width:34rem; line-height:1.5; }
|
||||
.btn { font-size:1.15rem; padding:14px 40px; border-radius:999px; border:0; cursor:pointer;
|
||||
background:linear-gradient(135deg, var(--accent), #529eff); color:#04121a; font-weight:700; }
|
||||
.btn.ghost { background:transparent; color:var(--text); border:1px solid rgba(255,255,255,.35);
|
||||
font-size:.92rem; padding:9px 22px; font-weight:500; margin-top:14px; }
|
||||
.disclaimer { position:absolute; bottom:14px; left:16px; right:16px; font-size:.68rem; opacity:.55; line-height:1.4; }
|
||||
|
||||
/* details page */
|
||||
#details { position:fixed; inset:0; z-index:20; overflow:auto; background:#080b16; padding:0 0 90px; }
|
||||
#details .inner { max-width:44rem; margin:0 auto; padding:24px 20px 0; }
|
||||
#details h1 { font-size:1.6rem; color:var(--accent); }
|
||||
#details h2, #details h3 { color:var(--accent); margin-top:1.4em; }
|
||||
#details p, #details li { line-height:1.65; opacity:.9; }
|
||||
#details a { color:var(--accent); }
|
||||
#backBtn { position:fixed; bottom:0; left:0; right:0; padding:14px; border:0; font-size:1rem;
|
||||
background:#111730; color:var(--text); cursor:pointer; border-top:1px solid #1e2748; }
|
||||
|
||||
#hud { position:fixed; top:0; left:0; right:0; display:flex; justify-content:space-between; padding:10px 14px;
|
||||
pointer-events:none; z-index:5; text-shadow:0 1px 3px #000; }
|
||||
#hud span { background:rgba(6,8,15,.55); border-radius:8px; padding:5px 10px; font-size:.85rem; }
|
||||
#trk.warn { color:#ffd166; }
|
||||
|
||||
#toast { position:fixed; bottom:32px; left:50%; transform:translateX(-50%) translateY(20px);
|
||||
background:rgba(20,28,55,.9); padding:10px 20px; border-radius:999px; opacity:0; transition:all .3s; z-index:6; }
|
||||
/* shutter */
|
||||
#shutter { position:fixed; bottom:28px; left:50%; transform:translateX(-50%); z-index:6;
|
||||
width:68px; height:68px; border-radius:50%; border:4px solid rgba(255,255,255,.85);
|
||||
background:rgba(255,255,255,.22); cursor:pointer; backdrop-filter:blur(2px); }
|
||||
#shutter:active { transform:translateX(-50%) scale(.92); background:rgba(255,255,255,.5); }
|
||||
#flash { position:fixed; inset:0; background:#fff; opacity:0; pointer-events:none; z-index:30; transition:opacity .35s; }
|
||||
#flash.on { opacity:.85; transition:opacity .05s; }
|
||||
|
||||
#toast { position:fixed; bottom:110px; left:50%; transform:translateX(-50%) translateY(20px);
|
||||
background:rgba(20,28,55,.92); padding:10px 20px; border-radius:999px; opacity:0; transition:all .3s; z-index:6; }
|
||||
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||||
</style>
|
||||
</head>
|
||||
@@ -34,17 +57,29 @@
|
||||
<canvas id="gl"></canvas>
|
||||
|
||||
<div id="startScreen">
|
||||
<h1>NEWBURY EXHIBIT</h1>
|
||||
<div class="sub">Point your phone at the Newbury Crests to reveal the hidden side of the town.</div>
|
||||
<button id="start">Start Ghost Watching</button>
|
||||
<div class="disclaimer">Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group.
|
||||
LEGO® and Hidden Side™ are trademarks of the LEGO Group.</div>
|
||||
<img id="logo" class="hidden" alt="">
|
||||
<h1 id="title"></h1>
|
||||
<p id="subtitle"></p>
|
||||
<button id="start" class="btn"></button>
|
||||
<button id="detailsBtn" class="btn ghost"></button>
|
||||
<div class="disclaimer" id="disc1"></div>
|
||||
</div>
|
||||
|
||||
<div id="details" class="hidden">
|
||||
<div class="inner">
|
||||
<h1 id="detailsTitle"></h1>
|
||||
<div id="detailsBody"></div>
|
||||
<div class="disclaimer" style="position:static;margin-top:32px" id="disc2"></div>
|
||||
</div>
|
||||
<button id="backBtn">← Back</button>
|
||||
</div>
|
||||
|
||||
<div id="hud" class="hidden">
|
||||
<span id="trk">—</span>
|
||||
<span id="cnt">—</span>
|
||||
</div>
|
||||
<button id="shutter" class="hidden" aria-label="Take photo"></button>
|
||||
<div id="flash"></div>
|
||||
<div id="toast"></div>
|
||||
|
||||
<!-- js-aruco2 vendor chain — load order matters: cv -> svd -> posit -> aruco -> dictionary -->
|
||||
|
||||
+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