img2threejs / app /static /app.js
Mike0021's picture
Fix deep-link gallery card race
a359c5a verified
Raw
History Blame Contribute Delete
55.2 kB
// img2threejs Space — custom client application.
// Upload -> truthful SSE feedback -> sandboxed viewer + public gallery.
// No progress value is invented: the UI only shows server events and elapsed time.
const $ = (id) => document.getElementById(id);
const panels = {
upload: $('panel-upload'),
progress: $('panel-progress'),
result: $('panel-result'),
gallery: $('panel-gallery'),
error: $('panel-error'),
};
const STAGES = [
'queued',
'intake',
'spec-authoring',
'generation',
'bundling',
'publishing',
'done',
];
const STAGE_LABELS = {
queued: ['Queued', 'The upload was accepted and is waiting for a worker.'],
intake: ['Image intake', 'Validate, normalize, and inspect the reference.'],
'spec-authoring': ['Sculpt spec', 'Author and validate components, materials, and proportions.'],
generation: ['Factory generation', 'Turn the accepted spec into procedural Three.js.'],
bundling: ['Browser bundle', 'Package Three.js and the generated factory for the viewer.'],
publishing: ['Gallery publishing', 'Copy opted-in results to stable public storage.'],
done: ['Complete', 'All requested artifacts are ready.'],
};
const ARTIFACT_NAMES = [
'reference.png',
'factory.ts',
'spec.json',
'model.bundle.js',
'standalone.html',
];
let pickedFile = null;
let previewObjectUrl = null;
let llmReady = null;
let workflowPanel = 'upload';
let activeRoute = 'create';
let eventSource = null;
let eventProbeInFlight = false;
let activeJobId = null;
let lastEventSeq = 0;
let generationStartedAt = null;
let generationTimer = null;
let jobShareChoice = true;
let resultViewerSession = null;
let resultViewerConfig = null;
let galleryViewerSession = null;
let galleryViewerConfig = null;
let latestGalleryItem = null;
const galleryState = {
items: new Map(),
offset: 0,
limit: 24,
total: 0,
hasMore: false,
loaded: false,
loading: false,
controller: null,
requestId: 0,
};
let detailController = null;
let openDetailId = null;
let commandSequence = 0;
// ---------------------------------------------------------------------------
// Small shared utilities
// ---------------------------------------------------------------------------
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function formatDuration(value) {
const total = Math.max(0, Math.floor(Number(value) || 0));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
if (hours) return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
return `${minutes}:${String(seconds).padStart(2, '0')}`;
}
function formatDate(value) {
if (!value) return 'Recently shared';
let date;
if (typeof value === 'number') {
date = new Date(value < 10_000_000_000 ? value * 1000 : value);
} else {
date = new Date(value);
}
if (Number.isNaN(date.getTime())) return 'Recently shared';
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(date);
}
function safeFilename(value, fallback = 'model') {
const cleaned = String(value || fallback)
.normalize('NFKD')
.replace(/[^\w.-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
return cleaned || fallback;
}
function joinNatural(parts) {
return parts.filter(Boolean).join(' · ');
}
function withDownload(url) {
if (!url) return '';
const parsed = new URL(url, window.location.href);
parsed.searchParams.set('download', '1');
return parsed.origin === window.location.origin
? `${parsed.pathname}${parsed.search}${parsed.hash}`
: parsed.href;
}
function secureLink(link) {
if (!link || !link.href) return;
const url = new URL(link.href, window.location.href);
if (url.origin !== window.location.origin) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
}
}
function secureExternalLinks(root = document) {
root.querySelectorAll('a[href]').forEach(secureLink);
}
function configureLink(link, url, options = {}) {
const { downloadName = null, newTab = false } = options;
if (!url) {
link.removeAttribute('href');
link.removeAttribute('download');
link.removeAttribute('target');
link.removeAttribute('rel');
link.classList.add('is-disabled');
link.setAttribute('aria-disabled', 'true');
link.tabIndex = -1;
return;
}
link.href = url;
link.classList.remove('is-disabled');
link.removeAttribute('aria-disabled');
link.removeAttribute('tabindex');
if (downloadName) link.setAttribute('download', downloadName);
else link.removeAttribute('download');
if (newTab) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
} else {
link.removeAttribute('target');
link.removeAttribute('rel');
secureLink(link);
}
}
async function parseJsonResponse(response) {
try {
return await response.json();
} catch {
return {};
}
}
async function fetchJson(url, options = {}, label = 'Request') {
const response = await fetch(url, options);
const payload = await parseJsonResponse(response);
if (!response.ok) {
const message = payload.detail || payload.message || payload.error || `HTTP ${response.status}`;
const error = new Error(`${label} failed: ${message}`);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function setPanel(name) {
for (const [key, panel] of Object.entries(panels)) panel.hidden = key !== name;
const galleryActive = name === 'gallery';
$('nav-create').classList.toggle('is-active', !galleryActive);
$('nav-gallery').classList.toggle('is-active', galleryActive);
if (galleryActive) {
$('nav-gallery').setAttribute('aria-current', 'page');
$('nav-create').removeAttribute('aria-current');
} else {
$('nav-create').setAttribute('aria-current', 'page');
$('nav-gallery').removeAttribute('aria-current');
}
}
function setWorkflowPanel(name, forceVisible = false) {
workflowPanel = name;
if (activeRoute === 'create' || forceVisible) {
activeRoute = 'create';
setPanel(name);
}
}
function galleryPath(id = null) {
return id ? `/gallery/${encodeURIComponent(id)}` : '/gallery';
}
function parseLocationRoute() {
const match = window.location.pathname.match(/^\/gallery(?:\/([^/]+))?\/?$/);
if (match) {
let id = null;
if (match[1]) {
try { id = decodeURIComponent(match[1]); } catch { id = match[1]; }
}
return { route: 'gallery', id };
}
if (window.location.hash === '#gallery') return { route: 'gallery', id: null };
return { route: 'create', id: null };
}
function navigate(route, options = {}) {
const { id = null, push = true } = options;
activeRoute = route === 'gallery' ? 'gallery' : 'create';
if (push) {
const path = activeRoute === 'gallery' ? galleryPath(id) : '/';
window.history.pushState({ route: activeRoute, id }, '', path);
}
if (activeRoute === 'gallery') {
setPanel('gallery');
if (!galleryState.loaded && !galleryState.loading) loadGallery({ reset: true });
if (id) openGalleryDetail(id);
else closeGalleryDetail({ updatePath: false });
} else {
closeGalleryDetail({ updatePath: false });
setPanel(workflowPanel);
}
}
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
async function loadConfig() {
const badge = $('llm-badge');
try {
const cfg = await fetchJson('/api/config', {}, 'Configuration');
if (cfg.llm_configured) {
llmReady = true;
badge.textContent = 'Model ready';
badge.className = 'badge badge-ok';
badge.title = `Vision model: ${cfg.model || 'configured'}`;
} else {
llmReady = false;
badge.textContent = 'Model unavailable';
badge.className = 'badge badge-bad';
const missing = Array.isArray(cfg.missing_llm_vars) ? cfg.missing_llm_vars.join(' and ') : 'LLM credentials';
const title = 'LLM credentials are not configured';
const message = `This Space needs a vision-capable model. The Space owner must set ${missing} and LLM_BASE_URL in Settings → Secrets, then restart. No model can be generated until then.`;
if (activeRoute === 'create') {
showError(title, message, null);
} else {
// Public gallery browsing does not depend on generation credentials.
// Prepare the create-route error without interrupting a gallery visit.
$('error-title').textContent = title;
$('error-message').textContent = message;
$('error-detail').hidden = true;
$('error-detail').textContent = '';
workflowPanel = 'error';
}
}
if (numberOrNull(cfg.max_upload_bytes)) {
$('file-input').dataset.maxBytes = String(cfg.max_upload_bytes);
}
} catch (error) {
badge.textContent = 'Config unavailable';
badge.className = 'badge badge-bad';
badge.title = error.message;
// A transient config read should not fabricate a fatal model state.
// Submission still gets an authoritative response from POST /api/jobs.
llmReady = null;
}
}
// ---------------------------------------------------------------------------
// Upload handling
// ---------------------------------------------------------------------------
function clearPreviewObjectUrl() {
if (previewObjectUrl) {
URL.revokeObjectURL(previewObjectUrl);
previewObjectUrl = null;
}
}
function acceptFile(file) {
if (!file) return;
const acceptedTypes = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp']);
if (!acceptedTypes.has(String(file.type || '').toLowerCase())) {
showError('Unsupported file', 'Choose a PNG, JPEG, WebP, GIF, or BMP image.', null);
return;
}
const maxBytes = Number($('file-input').dataset.maxBytes || 10 * 1024 * 1024);
if (file.size > maxBytes) {
showError(
'Image is too large',
`Choose an image no larger than ${(maxBytes / (1024 * 1024)).toFixed(0)} MiB.`,
null,
);
return;
}
pickedFile = file;
clearPreviewObjectUrl();
previewObjectUrl = URL.createObjectURL(file);
const preview = $('preview');
preview.onload = () => drawPalette(preview);
preview.onerror = () => {
showError('Could not preview this image', 'The browser could not decode the selected file. Try a different image.', null);
};
preview.src = previewObjectUrl;
$('preview-name').textContent =
`${file.name || 'Pasted image'} · ${(file.size / 1024).toFixed(file.size < 1024 * 100 ? 1 : 0)} KiB`;
$('preview-row').hidden = false;
$('run-btn').disabled = llmReady === false;
setWorkflowPanel('upload', true);
navigate('create', { push: false });
}
function drawPalette(image) {
try {
const canvas = document.createElement('canvas');
const size = 48;
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) return;
context.drawImage(image, 0, 0, size, size);
const { data } = context.getImageData(0, 0, size, size);
const buckets = new Map();
for (let index = 0; index < data.length; index += 4) {
if (data[index + 3] < 64) continue;
const key = [data[index] >> 5, data[index + 1] >> 5, data[index + 2] >> 5].join(',');
buckets.set(key, (buckets.get(key) || 0) + 1);
}
const top = [...buckets.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6);
const palette = $('palette');
palette.replaceChildren();
for (const [key] of top) {
const [red, green, blue] = key.split(',').map((value) => (Number(value) << 5) + 16);
const swatch = document.createElement('span');
swatch.className = 'swatch';
swatch.style.backgroundColor = `rgb(${red}, ${green}, ${blue})`;
swatch.title = `Approximately rgb(${red}, ${green}, ${blue})`;
palette.appendChild(swatch);
}
} catch {
$('palette').replaceChildren();
}
}
// ---------------------------------------------------------------------------
// Job lifecycle and truthful generation feedback
// ---------------------------------------------------------------------------
function buildStages() {
const list = $('stages');
list.replaceChildren();
for (const stage of STAGES) {
const [label, description] = STAGE_LABELS[stage];
const item = document.createElement('li');
item.id = `stage-${stage}`;
item.className = 'stage stage-pending';
item.dataset.stage = stage;
const dot = document.createElement('span');
dot.className = 'stage-dot';
dot.setAttribute('aria-hidden', 'true');
const copy = document.createElement('span');
copy.className = 'stage-copy';
const name = document.createElement('strong');
name.className = 'stage-name';
name.textContent = label;
const detail = document.createElement('small');
detail.className = 'stage-description';
detail.textContent = description;
copy.append(name, detail);
const note = document.createElement('span');
note.className = 'stage-note';
item.append(dot, copy, note);
list.appendChild(item);
}
}
function setStage(stage, status, note = '') {
const item = $(`stage-${stage}`);
if (!item) return;
item.className = `stage stage-${status}`;
const statusLabel = status === 'done' ? 'Complete' : status === 'active' ? 'In progress' : status === 'failed' ? 'Failed' : '';
item.querySelector('.stage-note').textContent = note || statusLabel;
item.setAttribute('aria-label', `${STAGE_LABELS[stage]?.[0] || stage}: ${statusLabel || 'pending'}${note ? `. ${note}` : ''}`);
}
function logLine(text) {
const log = $('log');
log.textContent += `${text}\n`;
log.scrollTop = log.scrollHeight;
}
function updateElapsed() {
if (!generationStartedAt) return;
$('progress-elapsed').textContent = formatDuration((Date.now() - generationStartedAt) / 1000);
}
function startGenerationTimer() {
stopGenerationTimer();
generationStartedAt = Date.now();
updateElapsed();
generationTimer = window.setInterval(updateElapsed, 1000);
}
function stopGenerationTimer(finalSeconds = null) {
if (generationTimer) window.clearInterval(generationTimer);
generationTimer = null;
if (finalSeconds !== null) $('progress-elapsed').textContent = formatDuration(finalSeconds);
}
function setConnection(text, kind = 'working') {
const pill = $('progress-connection');
pill.textContent = text;
pill.className = `status-pill status-${kind}`;
}
function setActivity(message, timestamp = null) {
$('progress-activity').textContent = message || 'Pipeline update received';
if (timestamp) {
const date = new Date(timestamp * 1000);
$('progress-activity-time').textContent = Number.isNaN(date.getTime())
? 'Just now'
: `Reported at ${date.toLocaleTimeString()}`;
} else {
$('progress-activity-time').textContent = 'Just now';
}
}
function setAttempt(data) {
if (!isObject(data) || numberOrNull(data.attempt) === null) return;
const attempt = Number(data.attempt);
const maximum = numberOrNull(data.maxAttempts);
$('progress-attempt-card').hidden = false;
$('progress-attempt').textContent = maximum ? `${attempt} of ${maximum}` : String(attempt);
}
function closeEventStream() {
if (eventSource) eventSource.close();
eventSource = null;
eventProbeInFlight = false;
}
async function startJob() {
if (!pickedFile || llmReady === false) return;
closeEventStream();
lastEventSeq = 0;
activeJobId = null;
jobShareChoice = $('share-toggle').checked;
$('run-btn').disabled = true;
$('progress-sharing').textContent = jobShareChoice ? 'Public' : 'Not published';
$('progress-attempt-card').hidden = true;
$('progress-attempt').textContent = '—';
$('progress-activity').textContent = 'Uploading your reference…';
$('progress-activity-time').textContent = 'Waiting for the first pipeline event';
$('log').replaceChildren();
buildStages();
setStage('queued', 'active', 'Submitting');
setConnection('Uploading', 'working');
startGenerationTimer();
setWorkflowPanel('progress', true);
navigate('create', { push: false });
const form = new FormData();
form.append('file', pickedFile, pickedFile.name || 'image.png');
const hint = $('hint').value.trim();
if (hint) form.append('hint', hint);
form.append('share', String(jobShareChoice));
try {
const response = await fetch('/api/jobs', { method: 'POST', body: form });
const payload = await parseJsonResponse(response);
if (!response.ok) {
if (response.status === 503 && payload.error === 'llm_not_configured') {
llmReady = false;
throw Object.assign(new Error(payload.detail || payload.message || 'The model is not configured.'), {
title: 'LLM credentials are not configured',
detail: payload,
});
}
throw Object.assign(
new Error(payload.detail || payload.message || payload.error || `HTTP ${response.status}`),
{ title: response.status === 429 ? 'Generation limit reached' : 'Upload rejected', detail: payload },
);
}
if (!payload.job_id) throw new Error('The server accepted the upload but did not return a job ID.');
activeJobId = payload.job_id;
setActivity('Upload accepted; waiting for the pipeline worker.');
setConnection('Live updates', 'working');
followJob(activeJobId);
} catch (error) {
showError(
error.title || (error.name === 'TypeError' ? 'Network error' : 'Could not start generation'),
error.message || String(error),
error.detail && Object.keys(error.detail).length ? JSON.stringify(error.detail, null, 2) : null,
);
}
}
function followJob(jobId) {
closeEventStream();
eventSource = new EventSource(`/api/jobs/${encodeURIComponent(jobId)}/events`);
eventSource.onopen = () => setConnection('Live updates', 'working');
eventSource.onmessage = (message) => {
if (activeJobId !== jobId) return;
let event;
try {
event = JSON.parse(message.data);
} catch {
return;
}
const sequence = numberOrNull(event.seq);
if (sequence !== null && sequence <= lastEventSeq) return;
if (sequence !== null) lastEventSeq = sequence;
handleEvent(event);
};
eventSource.onerror = () => {
if (!activeJobId || eventProbeInFlight) return;
setConnection('Reconnecting', 'warning');
setActivity('Live updates were interrupted; checking the job state while the stream reconnects.');
probeJobStatus(activeJobId);
};
}
async function probeJobStatus(jobId) {
eventProbeInFlight = true;
try {
const response = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`);
const payload = await parseJsonResponse(response);
if (activeJobId !== jobId) return;
if (response.status === 404) {
closeEventStream();
showError('Job expired', 'The job is no longer available, usually because the Space restarted or its retention window elapsed.', null);
return;
}
if (!response.ok) throw new Error(payload.detail || payload.error || `HTTP ${response.status}`);
if (payload.status === 'done' && payload.result) {
closeEventStream();
showResult(payload.result);
} else if (payload.status === 'error') {
closeEventStream();
const error = payload.error || {};
showError(errorTitle(error.code), error.message || 'The pipeline stopped.', error.detail ? JSON.stringify(error.detail, null, 2) : null);
}
} catch (error) {
setActivity(`The live status check failed (${error.message || error}); the event stream will keep retrying.`);
} finally {
eventProbeInFlight = false;
}
}
function handleEvent(event) {
const stage = String(event.stage || '');
const status = String(event.status || '');
const message = String(event.message || 'Pipeline update');
const data = isObject(event.data) ? event.data : {};
const eventDate = numberOrNull(event.ts) !== null
? new Date(Number(event.ts) * 1000).toLocaleTimeString()
: new Date().toLocaleTimeString();
logLine(`[${eventDate}] ${stage || 'pipeline'}: ${message}`);
setActivity(message, numberOrNull(event.ts));
setAttempt(data);
if (status === 'started' || status === 'progress') setStage(stage, 'active', message);
if (status === 'done' && stage !== 'done') setStage(stage, 'done', 'Complete');
if (stage === 'done' && status === 'done') {
closeEventStream();
setStage('done', 'done', 'Ready');
setConnection('Complete', 'success');
showResult(data.result || {});
return;
}
if (status === 'error') {
closeEventStream();
document.querySelectorAll('.stage-active').forEach((item) => {
setStage(item.dataset.stage, 'failed', 'Stopped');
});
showError(
errorTitle(data.code),
message,
data.detail ? JSON.stringify(data.detail, null, 2) : null,
);
}
}
function errorTitle(code) {
switch (code) {
case 'unsuitable_image': return 'This image is not a viable 3D target';
case 'spec_validation_failed': return 'The quality gate rejected the spec';
case 'llm_rejected': return 'The model endpoint rejected the request';
case 'llm_unavailable':
case 'llm_unreachable': return 'The model endpoint is unavailable';
case 'llm_truncated': return 'The model reply was truncated';
case 'generation_failed': return 'Factory generation failed';
case 'gallery_publish_failed': return 'The model was built but could not be shared';
case 'queue_full': return 'The conversion queue is full';
case 'job_timeout': return 'The conversion reached its time limit';
default: return 'The pipeline stopped honestly';
}
}
// ---------------------------------------------------------------------------
// Sandboxed viewer sessions and controls
// ---------------------------------------------------------------------------
function nextRequestId(prefix) {
commandSequence += 1;
return `${prefix}-${Date.now()}-${commandSequence}`;
}
function setViewerControlsEnabled(config, enabled) {
if (!config.controls) return;
config.controls.wireframe.disabled = !enabled;
config.controls.shadows.disabled = !enabled;
config.controls.reset.disabled = !enabled;
}
function updateViewerOptions(config, options = {}) {
if (!config.controls) return;
if (typeof options.wireframe === 'boolean') {
config.controls.wireframe.setAttribute('aria-pressed', String(options.wireframe));
}
if (typeof options.shadows === 'boolean') {
config.controls.shadows.setAttribute('aria-pressed', String(options.shadows));
}
}
function viewerStatus(session, message, error = false) {
if (!session || session.disposed) return;
session.config.controls.status.textContent = message;
session.config.controls.status.classList.toggle('is-error', error);
}
function showViewerOverlay(session, message, error = false) {
const { overlay, overlayCopy, retry, wrap } = session.config;
overlay.hidden = false;
overlay.classList.toggle('is-error', error);
overlayCopy.textContent = message;
retry.hidden = !error;
wrap.setAttribute('aria-busy', String(!error));
}
function markViewerError(session, message) {
if (!session || session.disposed) return;
window.clearTimeout(session.watchdog);
session.ready = false;
setViewerControlsEnabled(session.config, false);
showViewerOverlay(session, message, true);
session.config.stats.textContent = 'Preview unavailable';
session.config.wrap.setAttribute('aria-busy', 'false');
}
function settlePending(session, requestId, success, payload) {
if (!requestId || !session.pending.has(requestId)) return false;
const pending = session.pending.get(requestId);
session.pending.delete(requestId);
window.clearTimeout(pending.timer);
if (success) pending.resolve(payload);
else pending.reject(new Error(payload.message || 'Viewer command failed.'));
return true;
}
function mountViewer(config) {
const session = {
config,
ready: false,
disposed: false,
listener: null,
watchdog: null,
controller: new AbortController(),
pending: new Map(),
shellReady: false,
};
config.overlay.hidden = false;
config.overlay.classList.remove('is-error');
config.retry.hidden = true;
config.overlayCopy.textContent = 'Starting the secure viewer…';
config.stats.textContent = 'Preparing model';
config.wrap.setAttribute('aria-busy', 'true');
config.controls.status.textContent = '';
setViewerControlsEnabled(config, false);
updateViewerOptions(config, { wireframe: false, shadows: true });
if (!config.bundleUrl) {
markViewerError(session, 'The model bundle is missing. The source downloads may still be available.');
return session;
}
session.watchdog = window.setTimeout(() => {
if (!session.ready) {
markViewerError(
session,
'The generated model did not begin rendering within 45 seconds. Retry the viewer or use the source downloads.',
);
}
}, 45_000);
session.listener = async (event) => {
if (session.disposed || event.source !== config.frame.contentWindow) return;
const data = isObject(event.data) ? event.data : {};
if (data.type === 'shell-ready' && !session.shellReady) {
session.shellReady = true;
showViewerOverlay(session, 'Loading the generated model bundle…');
try {
const response = await fetch(config.bundleUrl, { signal: session.controller.signal });
if (!response.ok) throw new Error(`Bundle request failed with HTTP ${response.status}.`);
const bundleText = await response.text();
if (!bundleText.trim()) throw new Error('The model bundle was empty.');
if (!session.disposed) {
config.frame.contentWindow.postMessage({
type: 'init',
bundleText,
targetName: config.targetName || 'Generated model',
}, '*');
showViewerOverlay(session, 'Constructing the procedural scene…');
}
} catch (error) {
if (error.name !== 'AbortError') markViewerError(session, `Could not load the model bundle: ${error.message || error}`);
}
return;
}
if (data.type === 'ready') {
window.clearTimeout(session.watchdog);
session.ready = true;
config.overlay.hidden = true;
config.wrap.setAttribute('aria-busy', 'false');
setViewerControlsEnabled(config, true);
updateViewerOptions(config, data.options || {});
const stats = isObject(data.stats) ? data.stats : {};
const pieces = [];
if (numberOrNull(stats.meshes) !== null) pieces.push(`${stats.meshes} meshes`);
if (numberOrNull(stats.runtimeNodes) !== null) pieces.push(`${stats.runtimeNodes} runtime nodes`);
config.stats.textContent = pieces.length ? pieces.join(' · ') : 'Interactive preview ready';
viewerStatus(session, 'Preview ready');
if (typeof config.onReady === 'function') config.onReady(session);
return;
}
if (data.type === 'capture') {
settlePending(session, data.requestId, true, data);
return;
}
if (data.type === 'capture-error') {
settlePending(session, data.requestId, false, data);
return;
}
if (data.type === 'option-applied' || data.type === 'option') {
updateViewerOptions(config, { [data.option]: Boolean(data.value) });
settlePending(session, data.requestId, true, data);
return;
}
if (data.type === 'option-error') {
settlePending(session, data.requestId, false, data);
return;
}
if (data.type === 'camera-reset') {
settlePending(session, data.requestId, true, data);
return;
}
if (data.type === 'disposed') {
settlePending(session, data.requestId, true, data);
return;
}
if (data.type === 'error') {
if (settlePending(session, data.requestId, false, data)) return;
markViewerError(session, `Viewer error: ${data.message || 'The model failed to render.'}`);
}
};
window.addEventListener('message', session.listener);
config.frame.src = `/static/viewer.html?session=${encodeURIComponent(nextRequestId('viewer'))}`;
return session;
}
function viewerCommand(session, message, timeoutMs = 10_000) {
if (!session || session.disposed || !session.ready) {
return Promise.reject(new Error('The viewer is not ready.'));
}
const requestId = nextRequestId(message.type || 'command');
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
session.pending.delete(requestId);
reject(new Error('The viewer did not acknowledge the command in time.'));
}, timeoutMs);
session.pending.set(requestId, { resolve, reject, timer });
session.config.frame.contentWindow.postMessage({ ...message, requestId }, '*');
});
}
function teardownViewer(session) {
if (!session || session.disposed) return;
session.disposed = true;
window.clearTimeout(session.watchdog);
session.controller.abort();
if (session.ready && session.config.frame.contentWindow) {
session.config.frame.contentWindow.postMessage({ type: 'dispose', requestId: nextRequestId('dispose') }, '*');
}
session.pending.forEach((pending) => {
window.clearTimeout(pending.timer);
pending.reject(new Error('Viewer closed.'));
});
session.pending.clear();
if (session.listener) window.removeEventListener('message', session.listener);
setViewerControlsEnabled(session.config, false);
session.config.frame.src = 'about:blank';
session.config.wrap.setAttribute('aria-busy', 'false');
}
async function toggleViewerOption(session, option, button) {
const nextValue = button.getAttribute('aria-pressed') !== 'true';
button.disabled = true;
viewerStatus(session, `Applying ${option}…`);
try {
const reply = await viewerCommand(session, { type: 'set-option', option, value: nextValue });
const applied = typeof reply.value === 'boolean' ? reply.value : nextValue;
button.setAttribute('aria-pressed', String(applied));
viewerStatus(session, `${option === 'wireframe' ? 'Wireframe' : 'Shadows'} ${applied ? 'on' : 'off'}`);
} catch (error) {
viewerStatus(session, error.message || String(error), true);
} finally {
button.disabled = !session || session.disposed || !session.ready;
}
}
async function resetViewerCamera(session) {
if (!session) return;
session.config.controls.reset.disabled = true;
viewerStatus(session, 'Resetting view…');
try {
await viewerCommand(session, { type: 'reset-camera' });
viewerStatus(session, 'View reset');
} catch (error) {
viewerStatus(session, error.message || String(error), true);
} finally {
session.config.controls.reset.disabled = session.disposed || !session.ready;
}
}
async function saveScreenshot(session, button, status, filename) {
if (!session || !session.ready) return;
button.disabled = true;
button.classList.add('is-busy');
status.textContent = 'Capturing the current view…';
status.classList.remove('is-error', 'is-success');
try {
const reply = await viewerCommand(session, { type: 'capture' }, 20_000);
if (typeof reply.dataUrl !== 'string' || !reply.dataUrl.startsWith('data:image/png')) {
throw new Error('The viewer returned an invalid screenshot.');
}
const link = document.createElement('a');
link.href = reply.dataUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
status.textContent = 'Screenshot saved';
status.classList.add('is-success');
} catch (error) {
status.textContent = `Screenshot failed: ${error.message || error}`;
status.classList.add('is-error');
} finally {
button.classList.remove('is-busy');
button.disabled = !session || session.disposed || !session.ready;
}
}
function resultViewerElements() {
return {
frame: $('viewer-frame'),
wrap: $('viewer-frame-wrap'),
overlay: $('viewer-overlay'),
overlayCopy: $('viewer-overlay-copy'),
retry: $('viewer-retry'),
stats: $('viewer-stats'),
controls: {
wireframe: $('viewer-wireframe'),
shadows: $('viewer-shadows'),
reset: $('viewer-reset-camera'),
status: $('viewer-control-status'),
},
};
}
function galleryViewerElements() {
return {
frame: $('gallery-viewer-frame'),
wrap: $('gallery-viewer-wrap'),
overlay: $('gallery-viewer-overlay'),
overlayCopy: $('gallery-viewer-overlay-copy'),
retry: $('gallery-viewer-retry'),
stats: $('gallery-viewer-stats'),
controls: {
wireframe: $('gallery-viewer-wireframe'),
shadows: $('gallery-viewer-shadows'),
reset: $('gallery-viewer-reset-camera'),
status: $('gallery-viewer-control-status'),
},
};
}
function mountResultViewer() {
teardownViewer(resultViewerSession);
resultViewerSession = mountViewer({
...resultViewerElements(),
...resultViewerConfig,
onReady: () => {
$('dl-shot').disabled = false;
$('shot-status').textContent = 'Save the current camera view';
},
});
}
function mountGalleryViewer() {
teardownViewer(galleryViewerSession);
galleryViewerSession = mountViewer({
...galleryViewerElements(),
...galleryViewerConfig,
onReady: () => {
$('gallery-dl-shot').disabled = false;
$('gallery-shot-status').textContent = 'Save the current camera view';
},
});
}
// ---------------------------------------------------------------------------
// Result presentation
// ---------------------------------------------------------------------------
function galleryItemId(value) {
if (typeof value === 'string') return value;
if (isObject(value)) return value.id || value.galleryId || null;
return null;
}
function showResult(result) {
result = isObject(result) ? result : {};
const targetName = String(result.targetName || 'Generated model');
const elapsed = numberOrNull(result.elapsedSeconds);
stopGenerationTimer(elapsed);
closeEventStream();
activeJobId = null;
const galleryItem = isObject(result.galleryItem) ? result.galleryItem : null;
latestGalleryItem = galleryItem || (typeof result.galleryItem === 'string' ? { id: result.galleryItem } : null);
const ephemeralArtifacts = isObject(result.artifacts) ? result.artifacts : {};
const galleryArtifacts = galleryItem && isObject(galleryItem.artifacts) ? galleryItem.artifacts : {};
const artifacts = { ...ephemeralArtifacts, ...galleryArtifacts };
$('result-title').textContent = targetName;
$('result-sub').textContent = joinNatural([
numberOrNull(result.components) !== null ? `${result.components} components` : '',
numberOrNull(result.materials) !== null ? `${result.materials} materials` : '',
elapsed !== null ? `generated in ${formatDuration(elapsed)}` : '',
`review status: ${result.reviewStatus || 'unreviewed'}`,
]);
const baseName = safeFilename(targetName.toLowerCase());
configureLink($('dl-ts'), withDownload(artifacts['factory.ts']), { downloadName: `${safeFilename(`create-${targetName}-model`)}.ts` });
configureLink($('dl-spec'), withDownload(artifacts['spec.json']), { downloadName: `${baseName}-spec.json` });
configureLink($('dl-bundle'), withDownload(artifacts['model.bundle.js']), { downloadName: `${baseName}.bundle.js` });
configureLink($('dl-standalone'), withDownload(artifacts['standalone.html']), { downloadName: `${baseName}-viewer.html` });
const shareStatus = $('result-share-status');
const galleryButton = $('gallery-result-link');
shareStatus.removeAttribute('title');
galleryButton.hidden = true;
if (result.shared) {
shareStatus.textContent = 'Shared publicly';
shareStatus.className = 'status-pill status-success';
if (galleryItemId(result.galleryItem)) galleryButton.hidden = false;
} else if (result.shareRequested || jobShareChoice) {
shareStatus.textContent = 'Not published';
shareStatus.className = 'status-pill status-warning';
shareStatus.title = 'The result finished, but the gallery did not confirm publication.';
} else {
shareStatus.textContent = 'Not published';
shareStatus.className = 'status-pill status-neutral';
shareStatus.title = 'This result was not copied to the gallery. Its unlisted job URLs remain temporarily accessible to anyone who has them.';
}
const warnings = Array.isArray(result.validationWarnings) ? result.validationWarnings : [];
const publicationWarning = result.publicationWarning;
$('result-publication-warning').hidden = !publicationWarning;
$('result-publication-warning-copy').textContent = publicationWarning
? String(isObject(publicationWarning)
? publicationWarning.message || publicationWarning.detail || 'The public copy could not be confirmed. Your generated model and downloads are still available here.'
: publicationWarning)
: '';
$('warnings-list').replaceChildren();
$('warnings-box').hidden = warnings.length === 0;
$('warnings-count').textContent = warnings.length ? `(${warnings.length})` : '';
for (const warning of warnings.slice(0, 20)) {
const item = document.createElement('li');
item.textContent = String(warning);
$('warnings-list').appendChild(item);
}
const honesty = Array.isArray(result.honesty) && result.honesty.length
? result.honesty
: ['Approximate procedural reconstruction from one image; hidden geometry is inferred rather than measured.'];
$('honesty-list').replaceChildren();
for (const note of honesty) {
const item = document.createElement('li');
item.textContent = String(note);
$('honesty-list').appendChild(item);
}
$('dl-shot').disabled = true;
$('shot-status').textContent = 'Available when the viewer is ready';
$('shot-status').className = '';
resultViewerConfig = { bundleUrl: artifacts['model.bundle.js'], targetName };
mountResultViewer();
setWorkflowPanel('result');
if (activeRoute === 'create') setPanel('result');
if (result.shared) {
galleryState.loaded = false;
if (activeRoute === 'gallery') loadGallery({ reset: true });
}
}
// ---------------------------------------------------------------------------
// Community gallery
// ---------------------------------------------------------------------------
function stableGalleryArtifacts(id, supplied = {}) {
const artifacts = isObject(supplied) ? { ...supplied } : {};
if (!id) return artifacts;
for (const name of ARTIFACT_NAMES) {
artifacts[name] = `/api/gallery/${encodeURIComponent(id)}/artifacts/${encodeURIComponent(name)}`;
}
return artifacts;
}
function normalizeGalleryItem(raw) {
raw = isObject(raw?.item) ? raw.item : (isObject(raw) ? raw : {});
const id = String(raw.id || raw.galleryId || raw.jobId || '');
const stats = isObject(raw.stats) ? raw.stats : {};
const generation = isObject(raw.generation) ? raw.generation : {};
const artifacts = stableGalleryArtifacts(id, raw.artifacts);
return {
id,
jobId: raw.jobId || null,
targetName: String(raw.targetName || raw.title || 'Untitled model'),
createdAt: raw.createdAt || raw.created_at || raw.publishedAt || null,
thumbnailUrl: raw.thumbnailUrl || raw.thumbnail_url || artifacts['reference.png'] || '',
detailUrl: raw.detailUrl || (id ? galleryPath(id) : ''),
artifacts,
stats: {
components: numberOrNull(stats.components ?? raw.components),
materials: numberOrNull(stats.materials ?? raw.materials),
elapsedSeconds: numberOrNull(stats.elapsedSeconds ?? raw.elapsedSeconds),
},
generation: {
mode: generation.mode || raw.generationMode || 'hosted-unreviewed-preview',
generatedPass: generation.generatedPass || raw.generatedPass || '',
reviewStatus: generation.reviewStatus || raw.reviewStatus || 'unreviewed',
},
};
}
function galleryCard(item) {
const article = document.createElement('article');
article.className = 'gallery-card';
article.dataset.galleryId = item.id;
const button = document.createElement('a');
button.href = galleryPath(item.id);
button.className = 'gallery-card-button';
button.setAttribute('aria-label', `Open ${item.targetName}`);
const visual = document.createElement('span');
visual.className = 'gallery-card-visual';
const fallback = document.createElement('span');
fallback.className = 'gallery-card-fallback';
fallback.textContent = item.targetName.slice(0, 1).toUpperCase() || '3D';
visual.appendChild(fallback);
if (item.thumbnailUrl) {
const image = document.createElement('img');
image.loading = 'lazy';
image.alt = '';
image.src = item.thumbnailUrl;
image.addEventListener('load', () => { fallback.hidden = true; });
image.addEventListener('error', () => { image.hidden = true; fallback.hidden = false; }, { once: true });
visual.appendChild(image);
}
const badge = document.createElement('span');
badge.className = 'gallery-card-badge';
badge.textContent = 'Interactive';
visual.appendChild(badge);
const body = document.createElement('span');
body.className = 'gallery-card-body';
const title = document.createElement('strong');
title.textContent = item.targetName;
const metadata = document.createElement('small');
metadata.textContent = joinNatural([
item.stats.components !== null ? `${item.stats.components} parts` : '',
item.stats.materials !== null ? `${item.stats.materials} materials` : '',
]) || 'Procedural Three.js result';
const date = document.createElement('span');
date.textContent = formatDate(item.createdAt);
body.append(title, metadata, date);
const arrow = document.createElement('span');
arrow.className = 'gallery-card-arrow';
arrow.textContent = '↗';
arrow.setAttribute('aria-hidden', 'true');
button.append(visual, body, arrow);
button.addEventListener('click', (event) => {
if (
event.button === 0
&& !event.metaKey
&& !event.ctrlKey
&& !event.shiftKey
&& !event.altKey
) {
event.preventDefault();
navigate('gallery', { id: item.id });
}
});
article.appendChild(button);
return article;
}
function setGalleryLoading(loading, reset = false) {
$('gallery-loading').hidden = !loading;
$('gallery-loading').setAttribute('aria-hidden', String(!loading));
$('gallery-refresh').disabled = loading;
$('gallery-more').disabled = loading;
if (loading) $('gallery-status').textContent = reset ? 'Loading community gallery' : 'Loading more community results';
}
async function loadGallery({ reset = false } = {}) {
if (galleryState.loading && !reset) return;
if (reset && galleryState.controller) galleryState.controller.abort();
galleryState.loading = true;
galleryState.requestId += 1;
const requestId = galleryState.requestId;
galleryState.controller = new AbortController();
if (reset) {
galleryState.loaded = false;
galleryState.offset = 0;
galleryState.total = 0;
galleryState.hasMore = false;
galleryState.items.clear();
$('gallery-grid').replaceChildren();
$('gallery-empty').hidden = true;
}
$('gallery-error').hidden = true;
setGalleryLoading(true, reset);
const offset = reset ? 0 : galleryState.offset;
try {
const payload = await fetchJson(
`/api/gallery?offset=${offset}&limit=${galleryState.limit}`,
{ signal: galleryState.controller.signal },
'Gallery',
);
if (requestId !== galleryState.requestId) return;
const rows = Array.isArray(payload) ? payload : (Array.isArray(payload.items) ? payload.items : []);
let added = 0;
for (const raw of rows) {
const item = normalizeGalleryItem(raw);
if (!item.id) continue;
galleryState.items.set(item.id, item);
const alreadyRendered = [...$('gallery-grid').children]
.some((card) => card.dataset.galleryId === item.id);
if (alreadyRendered) continue;
$('gallery-grid').appendChild(galleryCard(item));
added += 1;
}
const responseOffset = numberOrNull(payload.offset) ?? offset;
galleryState.offset = responseOffset + rows.length;
galleryState.total = numberOrNull(payload.total) ?? galleryState.items.size;
galleryState.hasMore = typeof payload.hasMore === 'boolean'
? payload.hasMore
: galleryState.offset < galleryState.total;
galleryState.loaded = true;
$('gallery-empty').hidden = galleryState.items.size !== 0;
$('gallery-more').hidden = !galleryState.hasMore;
$('gallery-status').textContent = galleryState.items.size
? `${galleryState.items.size} community results loaded${added ? `; ${added} added` : ''}`
: 'The community gallery is empty';
} catch (error) {
if (error.name === 'AbortError' || requestId !== galleryState.requestId) return;
$('gallery-error-message').textContent = error.message || String(error);
$('gallery-error').hidden = false;
$('gallery-status').textContent = 'The community gallery could not be loaded';
} finally {
if (requestId === galleryState.requestId) {
galleryState.loading = false;
setGalleryLoading(false);
}
}
}
function openDialog(dialog) {
if (dialog.open) return;
if (typeof dialog.showModal === 'function') dialog.showModal();
else dialog.setAttribute('open', '');
}
async function openGalleryDetail(id) {
if (!id) return;
openDetailId = id;
if (detailController) detailController.abort();
detailController = new AbortController();
teardownViewer(galleryViewerSession);
galleryViewerSession = null;
$('gallery-detail-loading').hidden = false;
$('gallery-detail-error').hidden = true;
$('gallery-detail-content').hidden = true;
openDialog($('gallery-dialog'));
try {
const payload = await fetchJson(
`/api/gallery/${encodeURIComponent(id)}`,
{ signal: detailController.signal },
'Gallery detail',
);
if (openDetailId !== id) return;
const item = normalizeGalleryItem(payload);
galleryState.items.set(item.id, item);
renderGalleryDetail(item);
} catch (error) {
if (error.name === 'AbortError' || openDetailId !== id) return;
$('gallery-detail-loading').hidden = true;
$('gallery-detail-error-message').textContent = error.message || String(error);
$('gallery-detail-error').hidden = false;
}
}
function renderGalleryDetail(item) {
$('gallery-detail-loading').hidden = true;
$('gallery-detail-error').hidden = true;
$('gallery-detail-content').hidden = false;
$('gallery-detail-title').textContent = item.targetName;
$('gallery-detail-meta').textContent = joinNatural([
formatDate(item.createdAt),
item.stats.components !== null ? `${item.stats.components} components` : '',
item.stats.materials !== null ? `${item.stats.materials} materials` : '',
item.stats.elapsedSeconds !== null ? `${formatDuration(item.stats.elapsedSeconds)} generation` : '',
`review: ${item.generation.reviewStatus}`,
]);
const reference = $('gallery-detail-reference');
if (item.thumbnailUrl) {
reference.hidden = false;
reference.src = item.thumbnailUrl;
reference.onerror = () => { reference.hidden = true; };
} else {
reference.hidden = true;
reference.removeAttribute('src');
}
const baseName = safeFilename(item.targetName.toLowerCase());
configureLink($('gallery-dl-reference'), item.artifacts['reference.png'], { newTab: true });
configureLink($('gallery-dl-ts'), withDownload(item.artifacts['factory.ts']), { downloadName: `${baseName}-factory.ts` });
configureLink($('gallery-dl-spec'), withDownload(item.artifacts['spec.json']), { downloadName: `${baseName}-spec.json` });
configureLink($('gallery-dl-bundle'), withDownload(item.artifacts['model.bundle.js']), { downloadName: `${baseName}.bundle.js` });
configureLink($('gallery-dl-standalone'), withDownload(item.artifacts['standalone.html']), { downloadName: `${baseName}-viewer.html` });
$('gallery-dl-shot').disabled = true;
$('gallery-shot-status').textContent = 'Available when the viewer is ready';
$('gallery-shot-status').className = 'action-status';
galleryViewerConfig = { bundleUrl: item.artifacts['model.bundle.js'], targetName: item.targetName };
mountGalleryViewer();
}
function closeGalleryDetail({ updatePath = true } = {}) {
if (detailController) detailController.abort();
detailController = null;
openDetailId = null;
teardownViewer(galleryViewerSession);
galleryViewerSession = null;
galleryViewerConfig = null;
const dialog = $('gallery-dialog');
if (dialog.open) dialog.close();
if (updatePath && activeRoute === 'gallery' && window.location.pathname !== '/gallery') {
window.history.replaceState({ route: 'gallery' }, '', '/gallery');
}
}
// ---------------------------------------------------------------------------
// Errors and full reset
// ---------------------------------------------------------------------------
function showError(title, message, detail) {
stopGenerationTimer();
closeEventStream();
activeJobId = null;
$('error-title').textContent = title || 'Something went wrong';
$('error-message').textContent = message || '';
const detailElement = $('error-detail');
detailElement.textContent = detail || '';
detailElement.hidden = !detail;
setWorkflowPanel('error', true);
navigate('create', { push: false });
}
function clearResultState() {
teardownViewer(resultViewerSession);
resultViewerSession = null;
resultViewerConfig = null;
latestGalleryItem = null;
$('warnings-box').hidden = true;
$('warnings-list').replaceChildren();
$('warnings-count').textContent = '';
$('result-publication-warning').hidden = true;
$('result-publication-warning-copy').textContent = '';
$('honesty-list').replaceChildren();
$('gallery-result-link').hidden = true;
$('result-share-status').textContent = '';
$('result-share-status').removeAttribute('title');
$('dl-shot').disabled = true;
$('shot-status').textContent = 'Available when the viewer is ready';
for (const id of ['dl-ts', 'dl-spec', 'dl-bundle', 'dl-standalone']) configureLink($(id), '');
}
function reset() {
closeEventStream();
stopGenerationTimer();
closeGalleryDetail({ updatePath: false });
clearResultState();
activeJobId = null;
lastEventSeq = 0;
pickedFile = null;
clearPreviewObjectUrl();
$('preview').removeAttribute('src');
$('preview-row').hidden = true;
$('preview-name').textContent = '';
$('palette').replaceChildren();
$('file-input').value = '';
$('hint').value = '';
$('share-toggle').checked = true;
$('run-btn').disabled = llmReady === false;
$('stages').replaceChildren();
$('log').replaceChildren();
$('progress-attempt-card').hidden = true;
$('error-detail').hidden = true;
$('error-detail').textContent = '';
workflowPanel = 'upload';
navigate('create', { push: true });
}
// ---------------------------------------------------------------------------
// Wiring
// ---------------------------------------------------------------------------
$('dropzone').addEventListener('click', () => $('file-input').click());
$('dropzone').addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
$('file-input').click();
}
});
$('file-input').addEventListener('change', (event) => acceptFile(event.target.files?.[0]));
$('dropzone').addEventListener('dragover', (event) => {
event.preventDefault();
event.currentTarget.classList.add('drag');
});
$('dropzone').addEventListener('dragleave', (event) => event.currentTarget.classList.remove('drag'));
$('dropzone').addEventListener('drop', (event) => {
event.preventDefault();
event.currentTarget.classList.remove('drag');
acceptFile(event.dataTransfer?.files?.[0]);
});
window.addEventListener('paste', (event) => {
if (activeRoute !== 'create' || workflowPanel !== 'upload') return;
const item = [...(event.clipboardData?.items || [])].find((entry) => entry.type.startsWith('image/'));
if (item) acceptFile(item.getAsFile());
});
$('run-btn').addEventListener('click', startJob);
$('again-btn').addEventListener('click', reset);
$('error-again-btn').addEventListener('click', reset);
$('nav-create').addEventListener('click', () => navigate('create'));
$('nav-gallery').addEventListener('click', () => navigate('gallery'));
document.querySelector('.brand').addEventListener('click', (event) => {
event.preventDefault();
navigate('create');
});
$('gallery-refresh').addEventListener('click', () => loadGallery({ reset: true }));
$('gallery-retry').addEventListener('click', () => loadGallery({ reset: galleryState.items.size === 0 }));
$('gallery-more').addEventListener('click', () => loadGallery());
$('gallery-empty-create').addEventListener('click', () => navigate('create'));
$('gallery-detail-close').addEventListener('click', () => closeGalleryDetail());
$('gallery-dialog').addEventListener('click', (event) => {
if (event.target === $('gallery-dialog')) closeGalleryDetail();
});
$('gallery-dialog').addEventListener('close', () => {
if (openDetailId) closeGalleryDetail();
});
$('gallery-result-link').addEventListener('click', () => {
const id = galleryItemId(latestGalleryItem);
navigate('gallery', { id });
});
$('viewer-retry').addEventListener('click', mountResultViewer);
$('gallery-viewer-retry').addEventListener('click', mountGalleryViewer);
$('viewer-wireframe').addEventListener('click', () => toggleViewerOption(resultViewerSession, 'wireframe', $('viewer-wireframe')));
$('viewer-shadows').addEventListener('click', () => toggleViewerOption(resultViewerSession, 'shadows', $('viewer-shadows')));
$('viewer-reset-camera').addEventListener('click', () => resetViewerCamera(resultViewerSession));
$('gallery-viewer-wireframe').addEventListener('click', () => toggleViewerOption(galleryViewerSession, 'wireframe', $('gallery-viewer-wireframe')));
$('gallery-viewer-shadows').addEventListener('click', () => toggleViewerOption(galleryViewerSession, 'shadows', $('gallery-viewer-shadows')));
$('gallery-viewer-reset-camera').addEventListener('click', () => resetViewerCamera(galleryViewerSession));
$('dl-shot').addEventListener('click', () => saveScreenshot(
resultViewerSession,
$('dl-shot'),
$('shot-status'),
`${safeFilename($('result-title').textContent.toLowerCase())}-render.png`,
));
$('gallery-dl-shot').addEventListener('click', () => saveScreenshot(
galleryViewerSession,
$('gallery-dl-shot'),
$('gallery-shot-status'),
`${safeFilename($('gallery-detail-title').textContent.toLowerCase())}-render.png`,
));
window.addEventListener('popstate', () => {
const route = parseLocationRoute();
navigate(route.route, { id: route.id, push: false });
});
window.addEventListener('beforeunload', () => {
closeEventStream();
stopGenerationTimer();
teardownViewer(resultViewerSession);
teardownViewer(galleryViewerSession);
});
secureExternalLinks();
buildStages();
const initialRoute = parseLocationRoute();
navigate(initialRoute.route, { id: initialRoute.id, push: false });
loadConfig();