TripoSplat-WebGPU / src /e2eWebMain.ts
Yosun's picture
Deploy TripoSplat WebGPU demo
31c7d49 verified
Raw
History Blame Contribute Delete
42 kB
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<Record<FlowStage, number>>
}
interface PreviewFrame {
position: [number, number, number]
target: [number, number, number]
}
function requiredElement<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector)
if (!element) throw new Error(`Required public runner element '${selector}' was not found.`)
return element
}
const fileInput = requiredElement<HTMLInputElement>('#image-file')
const chooseFileButton = requiredElement<HTMLButtonElement>('#choose-file')
const imageUrlInput = requiredElement<HTMLInputElement>('#image-url')
const loadImageUrlButton = requiredElement<HTMLButtonElement>('#load-image-url')
const imageUrlError = requiredElement<HTMLElement>('#image-url-error')
const imageSummary = requiredElement<HTMLElement>('#image-summary')
const imagePreview = requiredElement<HTMLElement>('#image-preview')
const imagePreviewImage = requiredElement<HTMLImageElement>('#image-preview-image')
const dropZone = requiredElement<HTMLElement>('#drop-zone')
const sourcePanel = requiredElement<HTMLElement>('.web-controls')
const modelBaseInput = requiredElement<HTMLInputElement>('#model-base')
const modelStatus = requiredElement<HTMLElement>('#model-status')
const cacheMode = requiredElement<HTMLElement>('#cache-mode')
const generateButton = requiredElement<HTMLButtonElement>('#generate')
const cancelButton = requiredElement<HTMLButtonElement>('#cancel')
const clearCacheButton = requiredElement<HTMLButtonElement>('#clear-cache')
const runStage = requiredElement<HTMLElement>('#run-stage')
const runStatus = requiredElement<HTMLElement>('#run-status')
const runAnnouncement = requiredElement<HTMLElement>('#run-announcement')
const progressTrack = requiredElement<HTMLElement>('.progress-track')
const progressFill = requiredElement<HTMLElement>('#progress-fill')
const progressDetailButtons = Array.from(document.querySelectorAll<HTMLButtonElement>('[data-progress-detail]'))
const progressDetailDescription = requiredElement<HTMLElement>('#progress-detail-description')
const generationFlow = requiredElement<HTMLOListElement>('#generation-flow')
const benchmarkReport = requiredElement<HTMLElement>('#benchmark-report')
const benchmarkSummary = requiredElement<HTMLElement>('#benchmark-summary')
const benchmarkDetails = requiredElement<HTMLElement>('#benchmark-details')
const copyBenchmarkButton = requiredElement<HTMLButtonElement>('#copy-benchmark')
const diagnostics = requiredElement<HTMLElement>('#diagnostics')
const diagnosticMessage = requiredElement<HTMLElement>('#diagnostic-message')
const diagnosticDetails = requiredElement<HTMLElement>('#diagnostic-details')
const platformBadge = requiredElement<HTMLElement>('#platform-badge')
const compatibilityList = requiredElement<HTMLUListElement>('#compatibility-list')
const previewMount = requiredElement<HTMLElement>('#web-preview-root')
const viewerState = requiredElement<HTMLElement>('#viewer-state')
const previewRunState = requiredElement<HTMLElement>('#preview-run-state')
const downloadPlyButton = requiredElement<HTMLButtonElement>('#download-ply')
const downloadSplatButton = requiredElement<HTMLButtonElement>('#download-splat')
const previewRoot: Root = createRoot(previewMount)
const flowOrder: readonly FlowStage[] = ['source', 'model', 'conditioning', 'sampling', 'decode', 'preview']
const flowItems = Array.from(generationFlow.querySelectorAll<HTMLElement>('[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<string>()
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<Record<string, unknown>>; 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<void> {
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<FlowStage, { stage: string; message: string; detail: string }> = {
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<string, string> = {
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<Pick<SelectedImage, 'width' | 'height' | 'hasAlpha'>> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<TripoSplatWebGPU> {
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<void> {
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 })