// 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();