import { createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { BackgroundRemovalRequiredError, CancelledError, TripoSplatError, TripoSplatWebGPU, clearModelCache, getModelCacheStatus, type CacheBackend, type CompatibilityReport, type GenerationProgress, type LoadProgress, } from '../packages/triposplat-webgpu/dist/index.js' import { SplatPreview, type SplatPreviewStatus } from './components/SplatPreview' const MODEL_BYTES = 6_465_182_402 const DEFAULT_MODEL_BASE = 'https://huggingface.co/Yosun/TripoSplat-WebGPU/resolve/main/triposplat-webgpu/0.1.0-fp32.20260715/' const DEFAULT_STEPS = 20 type FlowStage = 'source' | 'model' | 'conditioning' | 'sampling' | 'decode' | 'preview' type ProgressDetailMode = 'guided' | 'technical' interface RunStatusSnapshot { stage: string message: string progress?: number } interface SelectedImage { blob: Blob name: string previewUrl: string width: number height: number hasAlpha: boolean } interface RunTelemetry { startedAt: string startedAtMs: number activeFlowStage?: FlowStage flowStageStartedAtMs: number flowDurationsMs: Partial> } interface PreviewFrame { position: [number, number, number] target: [number, number, number] } function requiredElement(selector: string): T { const element = document.querySelector(selector) if (!element) throw new Error(`Required public runner element '${selector}' was not found.`) return element } const fileInput = requiredElement('#image-file') const chooseFileButton = requiredElement('#choose-file') const imageUrlInput = requiredElement('#image-url') const loadImageUrlButton = requiredElement('#load-image-url') const imageUrlError = requiredElement('#image-url-error') const imageSummary = requiredElement('#image-summary') const imagePreview = requiredElement('#image-preview') const imagePreviewImage = requiredElement('#image-preview-image') const dropZone = requiredElement('#drop-zone') const sourcePanel = requiredElement('.web-controls') const modelBaseInput = requiredElement('#model-base') const modelStatus = requiredElement('#model-status') const cacheMode = requiredElement('#cache-mode') const generateButton = requiredElement('#generate') const cancelButton = requiredElement('#cancel') const clearCacheButton = requiredElement('#clear-cache') const runStage = requiredElement('#run-stage') const runStatus = requiredElement('#run-status') const runAnnouncement = requiredElement('#run-announcement') const progressTrack = requiredElement('.progress-track') const progressFill = requiredElement('#progress-fill') const progressDetailButtons = Array.from(document.querySelectorAll('[data-progress-detail]')) const progressDetailDescription = requiredElement('#progress-detail-description') const generationFlow = requiredElement('#generation-flow') const benchmarkReport = requiredElement('#benchmark-report') const benchmarkSummary = requiredElement('#benchmark-summary') const benchmarkDetails = requiredElement('#benchmark-details') const copyBenchmarkButton = requiredElement('#copy-benchmark') const diagnostics = requiredElement('#diagnostics') const diagnosticMessage = requiredElement('#diagnostic-message') const diagnosticDetails = requiredElement('#diagnostic-details') const platformBadge = requiredElement('#platform-badge') const compatibilityList = requiredElement('#compatibility-list') const previewMount = requiredElement('#web-preview-root') const viewerState = requiredElement('#viewer-state') const previewRunState = requiredElement('#preview-run-state') const downloadPlyButton = requiredElement('#download-ply') const downloadSplatButton = requiredElement('#download-splat') const previewRoot: Root = createRoot(previewMount) const flowOrder: readonly FlowStage[] = ['source', 'model', 'conditioning', 'sampling', 'decode', 'preview'] const flowItems = Array.from(generationFlow.querySelectorAll('[data-flow-stage]')) let compatibility: CompatibilityReport | undefined let cacheBackend: CacheBackend = 'none' let selectedImage: SelectedImage | undefined let model: TripoSplatWebGPU | undefined let loadedModelBase: string | undefined let controller: AbortController | undefined let busy = false let activePlyUrl: string | undefined let downloadablePly: Blob | undefined let downloadableSplat: Blob | undefined let previewFrame: PreviewFrame | undefined let completionChimeContext: AudioContext | undefined let completionChimeArmed = false let previewGenerationKey = 0 let activeRunTelemetry: RunTelemetry | undefined let benchmarkReportText = '' let lastAnnouncedRunStage = '' let progressDetailMode: ProgressDetailMode = readProgressDetailMode() let latestRunStatus: RunStatusSnapshot = { stage: 'STATUS', message: 'Checking browser compatibility…', } const retiredPlyUrls = new Set() function formatBytes(bytes: number): string { if (bytes < 1_024) return `${bytes} B` if (bytes < 1_024 ** 2) return `${(bytes / 1_024).toFixed(1)} KiB` if (bytes < 1_024 ** 3) return `${(bytes / 1_024 ** 2).toFixed(1)} MiB` return `${(bytes / 1_024 ** 3).toFixed(2)} GiB` } function formatDuration(milliseconds: number): string { if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms` const seconds = milliseconds / 1_000 if (seconds < 60) return `${seconds.toFixed(2)} s` return `${Math.floor(seconds / 60)}m ${(seconds % 60).toFixed(1)}s` } function armCompletionChime(): void { completionChimeArmed = true if (!completionChimeContext) { try { completionChimeContext = new AudioContext() } catch { return } } void completionChimeContext.resume().catch(() => undefined) } function playCompletionChime(): void { if (!completionChimeArmed) return completionChimeArmed = false const context = completionChimeContext if (!context || context.state !== 'running') return // A gentle, original three-note appliance-completion chime synthesized in // the browser: no audio asset is downloaded or imitated from a specific device. const startedAt = context.currentTime + 0.03 for (const [index, frequency] of [783.99, 1046.5, 1318.51].entries()) { const oscillator = context.createOscillator() const gain = context.createGain() const noteAt = startedAt + index * 0.16 oscillator.type = 'sine' oscillator.frequency.setValueAtTime(frequency, noteAt) gain.gain.setValueAtTime(0.0001, noteAt) gain.gain.exponentialRampToValueAtTime(0.09, noteAt + 0.015) gain.gain.exponentialRampToValueAtTime(0.0001, noteAt + 0.25) oscillator.connect(gain).connect(context.destination) oscillator.start(noteAt) oscillator.stop(noteAt + 0.27) } } function fitPreviewFrame(positions: Float32Array): PreviewFrame | undefined { if (positions.length < 3) return undefined let minX = Infinity let minY = Infinity let minZ = Infinity let maxX = -Infinity let maxY = -Infinity let maxZ = -Infinity for (let index = 0; index + 2 < positions.length; index += 3) { const x = positions[index] const y = positions[index + 1] const z = positions[index + 2] if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) continue minX = Math.min(minX, x) minY = Math.min(minY, y) minZ = Math.min(minZ, z) maxX = Math.max(maxX, x) maxY = Math.max(maxY, y) maxZ = Math.max(maxZ, z) } if (![minX, minY, minZ, maxX, maxY, maxZ].every(Number.isFinite)) return undefined const center: [number, number, number] = [ (minX + maxX) / 2, -((minY + maxY) / 2), -((minZ + maxZ) / 2), ] const longestSide = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 0.1) const distance = Math.max(longestSide / (2 * Math.tan(Math.PI / 6)) * 1.35, 0.35) return { position: [center[0], center[1], center[2] + distance], target: center, } } function compactStatus(message: string): string { return message.length > 72 ? `${message.slice(0, 69)}…` : message } function startRunTelemetry(): void { const startedAtMs = performance.now() activeRunTelemetry = { startedAt: new Date().toISOString(), startedAtMs, flowStageStartedAtMs: startedAtMs, flowDurationsMs: {}, } } function recordFlowStage(flowStage: FlowStage | undefined): void { if (!activeRunTelemetry || !flowStage || activeRunTelemetry.activeFlowStage === flowStage) return const now = performance.now() if (activeRunTelemetry.activeFlowStage) { const previous = activeRunTelemetry.activeFlowStage activeRunTelemetry.flowDurationsMs[previous] = (activeRunTelemetry.flowDurationsMs[previous] ?? 0) + now - activeRunTelemetry.flowStageStartedAtMs } activeRunTelemetry.activeFlowStage = flowStage activeRunTelemetry.flowStageStartedAtMs = now } function finishRunTelemetry(): RunTelemetry | undefined { if (!activeRunTelemetry) return undefined const completed = activeRunTelemetry if (completed.activeFlowStage) { completed.flowDurationsMs[completed.activeFlowStage] = (completed.flowDurationsMs[completed.activeFlowStage] ?? 0) + performance.now() - completed.flowStageStartedAtMs } activeRunTelemetry = undefined return completed } function numericTimingEntries(value: unknown): Array<[string, number]> { if (!value || typeof value !== 'object' || Array.isArray(value)) return [] return Object.entries(value).flatMap(([name, timing]) => typeof timing === 'number' ? [[name, timing]] : []) } function buildBenchmarkReport( telemetry: RunTelemetry, scene: { count: number; metadata: { generationSettings: Readonly>; seed: number; modelRevision: string } }, base: string, ): string { const totalDuration = performance.now() - telemetry.startedAtMs const settings = scene.metadata.generationSettings const deviceMemory = (navigator as Navigator & { deviceMemory?: number }).deviceMemory const lines = [ 'TripoSplat WebGPU benchmark', `Completed (UTC): ${new Date().toISOString()}`, `Started (UTC): ${telemetry.startedAt}`, `End-to-end duration: ${formatDuration(totalDuration)}`, '', 'Environment', `Browser: ${compatibility?.browser ?? navigator.userAgent}`, `WebGPU adapter: ${compatibility?.adapterName ?? 'not exposed by this browser'}`, `Logical CPU cores: ${navigator.hardwareConcurrency || 'not exposed'}`, `Device memory hint: ${deviceMemory ? `${deviceMemory} GiB` : 'not exposed'}`, `Cache backend: ${cacheBackend}`, `Model revision: ${scene.metadata.modelRevision}`, `Model base: ${base}`, '', 'Input and output', `Image: ${selectedImage?.name ?? 'unknown'} · ${selectedImage ? `${selectedImage.width}×${selectedImage.height}` : 'unknown size'} · ${selectedImage?.hasAlpha ? 'alpha' : 'opaque'}`, `Gaussians: ${scene.count.toLocaleString()}`, `Steps: ${String(settings.steps ?? DEFAULT_STEPS)} · seed ${scene.metadata.seed} · precision ${String(settings.precision ?? 'unknown')}`, '', 'Pipeline wall time', ...flowOrder.flatMap((stage) => { const duration = telemetry.flowDurationsMs[stage] return duration === undefined ? [] : [`${stage}: ${formatDuration(duration)}`] }), ] const runtimeTimings = numericTimingEntries(settings.measuredTimingsMs) if (runtimeTimings.length) { lines.push('', 'Runtime timings reported by the model') lines.push(...runtimeTimings.map(([name, milliseconds]) => `${name}: ${formatDuration(milliseconds)}`)) } const limits = compatibility ? Object.entries(compatibility.limits) : [] if (limits.length) { lines.push('', 'Selected WebGPU limits') lines.push(...limits.map(([name, value]) => `${name}: ${value.toLocaleString()}`)) } return lines.join('\n') } function showBenchmarkReport(report: string, totalDuration: number): void { benchmarkReportText = report benchmarkReport.hidden = false benchmarkSummary.textContent = `Completed in ${formatDuration(totalDuration)}. Copy this report when sharing a benchmark result.` benchmarkDetails.textContent = report copyBenchmarkButton.disabled = false copyBenchmarkButton.textContent = 'Copy report' } async function copyBenchmarkReport(): Promise { if (!benchmarkReportText) return try { await navigator.clipboard.writeText(benchmarkReportText) } catch { const textarea = document.createElement('textarea') textarea.value = benchmarkReportText textarea.style.position = 'fixed' textarea.style.opacity = '0' document.body.appendChild(textarea) textarea.select() const copied = document.execCommand('copy') textarea.remove() if (!copied) throw new Error('The browser denied clipboard access.') } copyBenchmarkButton.textContent = 'Copied' benchmarkSummary.textContent = 'Benchmark report copied to your clipboard.' } function chooseCacheBackend(): CacheBackend { const storage = navigator.storage as StorageManager & { getDirectory?: unknown } if (typeof storage.getDirectory === 'function') return 'opfs' if ('caches' in window) return 'cache-api' return 'none' } function normalizedModelBase(value: string): string { const url = new URL(value.trim()) if (url.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(url.hostname)) { throw new Error('Use an HTTPS model-server URL. Local HTTP is only supported during development.') } if (url.pathname.endsWith('/manifest.json')) return new URL('.', url).href url.pathname = url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/` return url.href } function stageToFlowStage(stage: string): FlowStage | undefined { const normalized = stage.toLowerCase() if (normalized.includes('image') || normalized.includes('source')) return 'source' if (normalized.includes('model') || normalized.includes('manifest') || normalized.includes('runtime') || normalized.includes('graph')) return 'model' if (normalized.includes('preprocess') || normalized.includes('dino') || normalized.includes('vae')) return 'conditioning' if (normalized.includes('sampling')) return 'sampling' if (normalized.includes('octree') || normalized.includes('gaussian')) return 'decode' if (normalized.includes('packing') || normalized.includes('export') || normalized.includes('complete')) return 'preview' return undefined } const guidedStageCopy: Record = { source: { stage: 'SOURCE IMAGE', message: 'Reading your image and preparing it for local generation.', detail: 'Preparing the image', }, model: { stage: 'PREPARING THE ENGINE', message: 'Checking, downloading, and loading the model components this browser needs.', detail: 'Getting the AI model ready', }, conditioning: { stage: 'UNDERSTANDING THE IMAGE', message: 'Turning the image into visual features the 3D generator can work with.', detail: 'Learning the image structure', }, sampling: { stage: 'SHAPING THE 3D SCENE', message: 'Iteratively refining a hidden spatial representation of the object.', detail: 'Refining the spatial structure', }, decode: { stage: 'BUILDING THE GAUSSIANS', message: 'Converting the spatial representation into visible 3D Gaussian points.', detail: 'Creating visible 3D points', }, preview: { stage: 'PREPARING YOUR RESULT', message: 'Packaging the completed scene for the viewer and downloads.', detail: 'Preparing the interactive result', }, } function readProgressDetailMode(): ProgressDetailMode { try { return localStorage.getItem('triposplat-progress-detail') === 'guided' ? 'guided' : 'technical' } catch { return 'technical' } } function statusForDetailMode(snapshot: RunStatusSnapshot): { stage: string; message: string } { if (progressDetailMode === 'technical') return snapshot const flowStage = stageToFlowStage(snapshot.stage) if (!flowStage) return snapshot const guided = guidedStageCopy[flowStage] const percentage = snapshot.progress === undefined ? '' : ` ${Math.round(snapshot.progress * 100)}% through this stage.` return { stage: guided.stage, message: `${guided.message}${percentage}` } } function setFlowStage(activeStage: FlowStage, detail?: string): void { const activeIndex = flowOrder.indexOf(activeStage) for (const item of flowItems) { const itemStage = item.dataset.flowStage as FlowStage | undefined const itemIndex = itemStage ? flowOrder.indexOf(itemStage) : -1 const state = itemIndex < activeIndex ? 'complete' : itemIndex === activeIndex ? 'active' : 'waiting' item.dataset.state = state const status = item.querySelector('small') const visibleDetail = progressDetailMode === 'technical' ? compactStatus(detail ?? 'Working locally') : guidedStageCopy[activeStage].detail if (status) status.textContent = state === 'active' ? `RUNNING · ${visibleDetail}` : state === 'complete' ? 'COMPLETE' : 'WAITING' if (state === 'active') item.setAttribute('aria-current', 'step') else item.removeAttribute('aria-current') } } function renderRunStatus(announceStageChange = true): void { const visible = statusForDetailMode(latestRunStatus) runStage.textContent = visible.stage runStatus.textContent = visible.message if (announceStageChange && visible.stage !== lastAnnouncedRunStage) { runAnnouncement.textContent = `${visible.stage}. ${visible.message}` lastAnnouncedRunStage = visible.stage } const percent = latestRunStatus.progress === undefined ? 0 : Math.max(0, Math.min(100, latestRunStatus.progress * 100)) progressFill.style.width = `${percent.toFixed(1)}%` progressTrack.setAttribute('aria-valuenow', String(Math.round(percent))) progressTrack.setAttribute('aria-valuetext', `${visible.stage}: ${visible.message}`) } function applyProgressDetailMode(mode: ProgressDetailMode, announce = true): void { progressDetailMode = mode for (const button of progressDetailButtons) { button.setAttribute('aria-pressed', String(button.dataset.progressDetail === mode)) } progressDetailDescription.textContent = mode === 'technical' ? 'Technical mode shows exact graph stages, sampler steps, CFG invocations, and decode boundaries as they happen.' : 'Guided mode translates the same pipeline into plain-language milestones while keeping the six-stage process visible.' try { localStorage.setItem('triposplat-progress-detail', mode) } catch { // A blocked storage preference should not affect generation. } lastAnnouncedRunStage = '' renderRunStatus(false) const activeStage = stageToFlowStage(latestRunStatus.stage) if (activeStage) setFlowStage(activeStage, latestRunStatus.message) if (announce) runAnnouncement.textContent = `${mode === 'technical' ? 'Technical' : 'Guided'} progress mode selected.` } function setPreviewRunState(state: 'waiting' | 'working' | 'ready' | 'retained' | 'failed', message: string): void { previewRunState.dataset.state = state previewRunState.textContent = message } function setRunStatus(stage: string, message: string, progress?: number): void { latestRunStatus = progress === undefined ? { stage, message } : { stage, message, progress } const flowStage = stageToFlowStage(stage) if (flowStage) { recordFlowStage(flowStage) setFlowStage(flowStage, message) } renderRunStatus() } function setBusy(next: boolean): void { busy = next cancelButton.disabled = !next chooseFileButton.disabled = next loadImageUrlButton.disabled = next modelBaseInput.disabled = next imageUrlInput.disabled = next clearCacheButton.disabled = next updateGenerateButton() } function updateGenerateButton(): void { const compatible = compatibility?.supported === true const hasModelBase = modelBaseInput.value.trim().length > 0 generateButton.disabled = busy || !compatible || !selectedImage || !hasModelBase if (busy) { generateButton.classList.add('is-working') generateButton.firstElementChild!.textContent = 'Working in your browser…' } else { generateButton.classList.remove('is-working') generateButton.firstElementChild!.textContent = selectedImage && hasModelBase ? 'Generate spatial scene' : 'Choose an image to begin' } } function hideDiagnostics(): void { diagnostics.hidden = true diagnosticMessage.textContent = '' diagnosticDetails.textContent = '' } function showDiagnostics(message: string, detail?: unknown): void { diagnosticMessage.textContent = message diagnosticDetails.textContent = typeof detail === 'string' ? detail : JSON.stringify(detail ?? {}, null, 2) diagnostics.hidden = false } function friendlyError(error: unknown): { message: string; details: unknown } { if (error instanceof BackgroundRemovalRequiredError) { return { message: 'This image has no transparency. Use a PNG or WebP with a transparent background; automatic background removal is not bundled into this browser-only preview.', details: { code: error.code, stage: error.stage, diagnostics: error.diagnostics }, } } if (error instanceof CancelledError || (error instanceof DOMException && error.name === 'AbortError')) { return { message: 'Cancelled. A future run starts with a clean browser worker.', details: error.message } } if (error instanceof TripoSplatError) { const help: Record = { WEBGPU_UNAVAILABLE: 'WebGPU is unavailable. Use a current desktop Chrome or Edge browser with hardware acceleration enabled.', UNSUPPORTED_ADAPTER: 'Your GPU/browser combination does not meet this model’s current WebGPU requirements.', MODEL_DOWNLOAD_FAILED: 'The model server could not be read. Check the URL, CORS response headers, redirects, and network connection.', MODEL_INTEGRITY_FAILED: 'A downloaded model file did not match its manifest. Clear the cache and ask the model host to verify the immutable artifacts.', MANIFEST_INVALID: 'The model server manifest is missing or is not a valid TripoSplat manifest.', GRAPH_LOAD_FAILED: 'The downloaded model could not be initialized by ONNX Runtime WebGPU. Try a supported Chrome or Edge version and verify available device memory.', GRAPH_CAPABILITY_UNAVAILABLE: 'This manifest does not contain all five graphs needed for generation.', OUT_OF_MEMORY: 'The browser or GPU ran out of available memory. Close GPU-heavy tabs and retry on a higher-memory device.', INFERENCE_FAILED: 'The browser GPU could not complete the generation. The technical details may help diagnose the graph or driver.', } return { message: help[error.code] ?? error.message, details: { code: error.code, stage: error.stage, recoverable: error.recoverable, diagnostics: error.diagnostics, cause: String(error.cause ?? '') }, } } if (error instanceof TypeError && /fetch|network/i.test(error.message)) { return { message: 'The browser could not fetch that URL. Confirm it is reachable over HTTPS and explicitly permits CORS from this site.', details: error.message } } return { message: error instanceof Error ? error.message : String(error), details: error } } function setViewerStatus(status: SplatPreviewStatus): void { viewerState.dataset.state = status.state viewerState.textContent = status.state === 'ready' ? 'Interactive' : status.state if (status.state === 'ready') { playCompletionChime() setPreviewRunState('ready', 'Completed scene is framed to fit. Drag to orbit and scroll to zoom.') } else if (status.state === 'loading') setPreviewRunState('working', 'Loading the completed scene into the interactive viewer…') else if (status.state === 'failed') { completionChimeArmed = false setPreviewRunState('failed', 'The preview could not be loaded. Downloads remain available.') } else if (!activePlyUrl) setPreviewRunState('waiting', 'A completed scene will appear here without leaving this page.') } function releaseRetiredPlyUrl(plyUrl: string): void { if (!retiredPlyUrls.delete(plyUrl)) return URL.revokeObjectURL(plyUrl) } function renderPreview(): void { previewRoot.render(createElement(SplatPreview, { plyUrl: activePlyUrl ?? null, generationKey: previewGenerationKey, bgColor: '#030509', fov: 60, autoRotate: false, maxScreenSize: 2048, dynamicScene: false, initialCameraPosition: previewFrame?.position, initialCameraTarget: previewFrame?.target, splatPosition: [0, 0, 0], // The PLY already has TripoSplat's official +90° export mapping. This is // an additional proper presentation rotation for the viewer convention. splatRotation: [180, 0, 0], splatFlip: [false, false, false], onViewerStateChange: setViewerStatus, onViewerDisposed: releaseRetiredPlyUrl, })) } function replaceOutput(ply: Blob, splat: Blob, frame: PreviewFrame | undefined): void { const previousPlyUrl = activePlyUrl const nextPlyUrl = URL.createObjectURL(ply) downloadablePly = ply downloadableSplat = splat previewFrame = frame activePlyUrl = nextPlyUrl previewGenerationKey += 1 if (previousPlyUrl) retiredPlyUrls.add(previousPlyUrl) renderPreview() downloadPlyButton.disabled = false downloadSplatButton.disabled = false } function download(blob: Blob | undefined, name: string): void { if (!blob) return const url = URL.createObjectURL(blob) const anchor = document.createElement('a') anchor.href = url anchor.download = name document.body.appendChild(anchor) anchor.click() anchor.remove() window.setTimeout(() => URL.revokeObjectURL(url), 0) } async function inspectImage(blob: Blob): Promise> { const bitmap = await createImageBitmap(blob) try { const canvas = document.createElement('canvas') canvas.width = bitmap.width canvas.height = bitmap.height const context = canvas.getContext('2d', { willReadFrequently: true }) if (!context) throw new Error('Could not inspect the selected image in this browser.') context.drawImage(bitmap, 0, 0) const alpha = context.getImageData(0, 0, bitmap.width, bitmap.height).data let hasAlpha = false for (let index = 3; index < alpha.length; index += 4) { if (alpha[index] !== 255) { hasAlpha = true break } } return { width: bitmap.width, height: bitmap.height, hasAlpha } } finally { bitmap.close() } } async function setSelectedImage(blob: Blob, name: string): Promise { if (!blob.type.startsWith('image/')) throw new Error('Choose an image file, or a URL that returns an image content type.') const image = await inspectImage(blob) const previousPreviewUrl = selectedImage?.previewUrl const previewUrl = URL.createObjectURL(blob) selectedImage = { blob, name, previewUrl, ...image } imagePreviewImage.src = previewUrl imagePreview.hidden = false if (previousPreviewUrl) URL.revokeObjectURL(previousPreviewUrl) const alphaDescription = image.hasAlpha ? 'Transparency detected — ready for generation.' : 'No transparency detected — this will need a browser-local background remover and cannot run in this preview.' imageSummary.dataset.state = image.hasAlpha ? 'ready' : 'warning' imageSummary.textContent = `${name} · ${image.width}×${image.height} · ${alphaDescription}` updateGenerateButton() } function clearImageUrlError(): void { imageUrlError.hidden = true imageUrlError.textContent = '' imageUrlInput.removeAttribute('aria-invalid') } function showImageUrlError(message: string): void { imageUrlError.textContent = message imageUrlError.hidden = false imageUrlInput.setAttribute('aria-invalid', 'true') } function imageUrlErrorMessage(error: unknown): string { if (error instanceof Error) return error.message return 'The image could not be loaded. Check the URL and try another image host.' } async function loadImageFromUrl(): Promise { const value = imageUrlInput.value.trim() if (!value) throw new Error('Enter an image URL first.') let url: URL try { url = new URL(value) } catch { throw new Error('Enter a complete image URL, including https://.') } clearImageUrlError() setRunStatus('IMAGE URL', 'Downloading the image directly into this browser…') let response: Response try { response = await fetch(url, { mode: 'cors' }) } catch { throw new Error('The browser could not read this image. The host may be blocking cross-origin access (CORS), or the URL may be unavailable. Use an image host that sends Access-Control-Allow-Origin for this site, or choose a local file instead.') } if (!response.ok) throw new Error(`The image host returned HTTP ${response.status} ${response.statusText}. Check the URL or choose another image host.`) const blob = await response.blob() const name = decodeURIComponent(url.pathname.split('/').pop() || 'remote-image') await setSelectedImage(blob, name) clearImageUrlError() setRunStatus('IMAGE READY', 'Image loaded locally. The default browser model package is ready when you are.') } function updateCompatibilityList(items: Array<{ text: string; state: 'ready' | 'warning' | 'problem' }>): void { compatibilityList.replaceChildren(...items.map(({ text, state }) => { const item = document.createElement('li') item.className = `is-${state}` item.textContent = text return item })) } async function refreshCacheStatus(): Promise { const status = await getModelCacheStatus() const persistent = status.backends.find((entry) => entry.backend === cacheBackend) const cacheLabel = cacheBackend === 'none' ? 'NO PERSISTENT CACHE' : `${cacheBackend.toUpperCase()} CACHE` cacheMode.textContent = cacheLabel const storage = navigator.storage const estimate = await storage?.estimate?.().catch(() => undefined) const cached = status.entryCount > 0 ? `${formatBytes(status.totalBytes)} verified files cached.` : 'No verified model files cached yet.' const quota = estimate?.quota ? ` Browser quota: ${formatBytes(estimate.quota)}.` : '' const availability = persistent && !persistent.available ? ` Cache unavailable: ${persistent.error ?? 'unknown error'}` : '' modelStatus.textContent = `${cached}${quota}${availability}` } async function checkPlatform(): Promise { cacheBackend = chooseCacheBackend() try { compatibility = await TripoSplatWebGPU.checkCompatibility({ estimatedModelBytes: MODEL_BYTES }) const items: Array<{ text: string; state: 'ready' | 'warning' | 'problem' }> = [] items.push({ text: compatibility.webgpu ? 'WebGPU detected' : 'WebGPU unavailable', state: compatibility.webgpu ? 'ready' : 'problem' }) items.push({ text: `${cacheBackend === 'opfs' ? 'Persistent browser storage' : cacheBackend === 'cache-api' ? 'Cache API storage' : 'No persistent cache'} selected`, state: cacheBackend === 'none' ? 'warning' : 'ready' }) for (const warning of compatibility.warnings) items.push({ text: warning, state: 'warning' }) for (const blocker of compatibility.blockers) items.push({ text: blocker, state: 'problem' }) updateCompatibilityList(items) platformBadge.classList.toggle('is-ready', compatibility.supported) platformBadge.classList.toggle('is-missing', !compatibility.supported) platformBadge.lastElementChild!.textContent = compatibility.supported ? 'WebGPU ready' : 'WebGPU blocked' if (!compatibility.supported) setRunStatus('UNSUPPORTED', compatibility.blockers.join(' ') || 'WebGPU is unavailable in this browser.') } catch (error) { compatibility = undefined updateCompatibilityList([{ text: 'Could not inspect WebGPU compatibility.', state: 'problem' }]) platformBadge.classList.add('is-missing') platformBadge.lastElementChild!.textContent = 'Platform check failed' setRunStatus('CHECK FAILED', 'Could not inspect WebGPU compatibility.') showDiagnostics('Could not complete the browser compatibility check.', friendlyError(error).details) } await refreshCacheStatus() updateGenerateButton() } async function requestPersistentStorage(): Promise { if (cacheBackend === 'none') return const storage = navigator.storage if (!storage?.persisted || !storage.persist) return const persisted = await storage.persisted() if (!persisted) await storage.persist().catch(() => false) } async function verifyModelServer(base: string): Promise { setRunStatus('MODEL SERVER', 'Checking manifest access before the large download…') const manifestUrl = new URL('manifest.json', base) let response: Response try { response = await fetch(manifestUrl, { mode: 'cors', cache: 'no-cache' }) } catch (error) { throw new Error(`The model manifest could not be fetched. The server must permit CORS from this site. ${error instanceof Error ? error.message : String(error)}`) } if (!response.ok) throw new Error(`The model manifest returned HTTP ${response.status} ${response.statusText}. Use the CDN directory containing manifest.json.`) const manifest: unknown = await response.json().catch(() => undefined) if (!manifest || typeof manifest !== 'object') throw new Error('The model manifest was not valid JSON.') modelStatus.textContent = `Model server verified. First download: about ${formatBytes(MODEL_BYTES)}; verified cache: ${cacheBackend}.` } async function prepareModel(base: string, signal: AbortSignal): Promise { if (model && loadedModelBase === base) return model if (model) await model.dispose() model = new TripoSplatWebGPU({ modelBaseUrl: base, manifestUrl: 'manifest.json', executionProviders: ['webgpu'], cache: cacheBackend, wasmPaths: { mjs: '/ort/ort-wasm-simd-threaded.asyncify.mjs', wasm: '/ort/ort-wasm-simd-threaded.asyncify.wasm', }, }) loadedModelBase = undefined await model.load({ signal, onProgress: reportLoadProgress }) loadedModelBase = base return model } function reportLoadProgress(progress: LoadProgress): void { const fraction = progress.progress ?? (progress.totalBytes ? (progress.loadedBytes ?? 0) / progress.totalBytes : undefined) setRunStatus(`MODEL · ${progress.stage.toUpperCase()}`, progress.message, fraction) } function reportGenerationProgress(progress: GenerationProgress): void { const samplingFraction = progress.stage === 'sampling' && progress.totalInvocations ? (progress.invocation ?? 0) / progress.totalInvocations : undefined const fraction = samplingFraction ?? progress.progress ?? (progress.totalSteps ? (progress.step ?? 0) / progress.totalSteps : undefined) const samplingDetail = progress.stage === 'sampling' && progress.step && progress.totalSteps && progress.invocation && progress.totalInvocations ? ` Step ${progress.step}/${progress.totalSteps} · CFG invocation ${progress.invocation}/${progress.totalInvocations}.` : '' setRunStatus(`GENERATING · ${progress.stage.toUpperCase()}`, `${progress.message}${samplingDetail}`, fraction) } async function run(): Promise { if (!selectedImage) throw new Error('Choose an image before generating.') if (!compatibility?.supported) throw new Error('This browser does not meet the current WebGPU requirements.') const base = normalizedModelBase(modelBaseInput.value) hideDiagnostics() armCompletionChime() controller?.abort() controller = new AbortController() startRunTelemetry() setBusy(true) setRunStatus('SOURCE IMAGE', 'Image accepted. Starting a local, browser-only generation…') setPreviewRunState( activePlyUrl ? 'retained' : 'working', activePlyUrl ? 'Generating a replacement. The last completed scene remains interactive.' : 'Generating your first scene. This page and preview stay in place.', ) let completed = false try { await requestPersistentStorage() await verifyModelServer(base) const activeModel = await prepareModel(base, controller.signal) setRunStatus('PREPROCESSING', 'Preparing the image locally…') const scene = await activeModel.generate(selectedImage.blob, { steps: DEFAULT_STEPS, gaussianCount: 262_144, seed: 42, signal: controller.signal, onProgress: reportGenerationProgress, }) try { setRunStatus('EXPORTING', 'Encoding portable PLY and .splat files…') const ply = await scene.exportPLY() const splat = await scene.exportSplat() const frame = fitPreviewFrame(scene.positions) setPreviewRunState('working', 'Framing and swapping in the completed scene without resetting the page…') replaceOutput(ply, splat, frame) setRunStatus('COMPLETE', `Generated ${scene.count.toLocaleString()} Gaussians. Preview and downloads are ready.`, 1) const telemetry = finishRunTelemetry() if (telemetry) showBenchmarkReport(buildBenchmarkReport(telemetry, scene, base), performance.now() - telemetry.startedAtMs) await refreshCacheStatus() completed = true } finally { scene.dispose() } } catch (error) { const friendly = friendlyError(error) setRunStatus('NEEDS ATTENTION', friendly.message) showDiagnostics(friendly.message, friendly.details) } finally { if (controller?.signal.aborted) setRunStatus('CANCELLED', 'Cancelled. The next run will start a clean worker.') if (!completed) { completionChimeArmed = false finishRunTelemetry() setPreviewRunState( activePlyUrl ? 'retained' : 'failed', activePlyUrl ? 'The last completed scene is still available.' : 'No completed scene was produced. Review the guidance below and try again.', ) } controller = undefined setBusy(false) } } function modelBaseFromLocation(): string { const supplied = new URLSearchParams(location.search).get('modelBaseUrl') return supplied ?? DEFAULT_MODEL_BASE } modelBaseInput.value = modelBaseFromLocation() applyProgressDetailMode(progressDetailMode, false) renderPreview() for (const button of progressDetailButtons) { button.addEventListener('click', () => { const mode = button.dataset.progressDetail if (mode === 'guided' || mode === 'technical') applyProgressDetailMode(mode) }) } chooseFileButton.addEventListener('click', () => fileInput.click()) fileInput.addEventListener('change', () => { const file = fileInput.files?.[0] fileInput.value = '' if (!file) return void setSelectedImage(file, file.name).catch((error) => { const friendly = friendlyError(error) setRunStatus('IMAGE ERROR', friendly.message) showDiagnostics(friendly.message, friendly.details) }) }) loadImageUrlButton.addEventListener('click', () => { void loadImageFromUrl().catch((error) => { const message = imageUrlErrorMessage(error) showImageUrlError(message) setRunStatus('IMAGE URL ERROR', message) showDiagnostics(message, friendlyError(error).details) }) }) imageUrlInput.addEventListener('input', clearImageUrlError) for (const eventName of ['dragenter', 'dragover']) { sourcePanel.addEventListener(eventName, (event) => { event.preventDefault() sourcePanel.classList.add('is-dragging') }) } for (const eventName of ['dragleave', 'drop']) { sourcePanel.addEventListener(eventName, (event) => { event.preventDefault() sourcePanel.classList.remove('is-dragging') }) } sourcePanel.addEventListener('drop', (event) => { const file = event.dataTransfer?.files[0] if (!file) return void setSelectedImage(file, file.name).catch((error) => { const friendly = friendlyError(error) setRunStatus('IMAGE ERROR', friendly.message) showDiagnostics(friendly.message, friendly.details) }) }) dropZone.addEventListener('click', () => fileInput.click()) modelBaseInput.addEventListener('input', updateGenerateButton) generateButton.addEventListener('click', () => { void run().catch((error) => { const friendly = friendlyError(error) setRunStatus('NEEDS ATTENTION', friendly.message) showDiagnostics(friendly.message, friendly.details) }) }) cancelButton.addEventListener('click', () => controller?.abort(new DOMException('Cancelled by user.', 'AbortError'))) clearCacheButton.addEventListener('click', () => { void clearModelCache().then(async () => { await refreshCacheStatus() setRunStatus('CACHE CLEARED', 'Verified model files were removed from browser storage. A future run will download them again.') }).catch((error) => { const friendly = friendlyError(error) setRunStatus('CACHE ERROR', friendly.message) showDiagnostics(friendly.message, friendly.details) }) }) downloadPlyButton.addEventListener('click', () => download(downloadablePly, 'triposplat-scene.ply')) downloadSplatButton.addEventListener('click', () => download(downloadableSplat, 'triposplat-scene.splat')) copyBenchmarkButton.addEventListener('click', () => { void copyBenchmarkReport().catch((error) => { const friendly = friendlyError(error) benchmarkSummary.textContent = `Could not copy the report: ${friendly.message}` }) }) void checkPlatform() window.addEventListener('pagehide', () => { controller?.abort() void completionChimeContext?.close() void model?.dispose() previewRoot.unmount() if (selectedImage) URL.revokeObjectURL(selectedImage.previewUrl) if (activePlyUrl) URL.revokeObjectURL(activePlyUrl) for (const plyUrl of retiredPlyUrls) URL.revokeObjectURL(plyUrl) }, { once: true })