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