Yosun's picture
Deploy TripoSplat WebGPU demo
31c7d49 verified
Raw
History Blame Contribute Delete
40.1 kB
import {
CancelledError,
ModelDownloadError,
ModelIntegrityError,
TripoSplatError,
throwIfAborted,
} from './errors.js'
import type {
GraphManifestEntry,
IntegrityDescriptor,
ResolvedExternalDataDescriptor,
ResolvedGraphManifestEntry,
ResolvedTripoSplatModelManifest,
} from './manifest.js'
import type { CacheBackend, TripoSplatGraphName } from './types.js'
import { forwardReusableGraphInputCapability } from './runtime.js'
import type {
GraphInfo,
GraphRunResult,
LoadGraphOptions,
RunGraphOptions,
TripoSplatRuntime,
} from './runtime.js'
const CACHE_NAME = 'ai3d-triposplat-webgpu-v1'
const OPFS_DIRECTORY = 'ai3d-triposplat-webgpu-v1'
const OPFS_EPHEMERAL_DIRECTORY = 'ai3d-triposplat-webgpu-ephemeral-v1'
const CACHE_URL_PREFIX = '/.ai3d-triposplat-webgpu-cache/v1/'
const SHA256_INITIAL = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
])
const SHA256_CONSTANTS = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])
function rotateRight(value: number, bits: number): number {
return (value >>> bits) | (value << (32 - bits))
}
/** Incremental SHA-256 so multi-hundred-megabyte model files need no ArrayBuffer copy. */
export class Sha256 {
private readonly state = new Uint32Array(SHA256_INITIAL)
private readonly block = new Uint8Array(64)
private readonly words = new Uint32Array(64)
private blockLength = 0
private bytesHashed = 0
private finished = false
update(input: Uint8Array): this {
if (this.finished) throw new Error('SHA-256 digest is already finalized.')
this.bytesHashed += input.byteLength
let offset = 0
while (offset < input.byteLength) {
const available = Math.min(64 - this.blockLength, input.byteLength - offset)
this.block.set(input.subarray(offset, offset + available), this.blockLength)
this.blockLength += available
offset += available
if (this.blockLength === 64) {
this.compress(this.block)
this.blockLength = 0
}
}
return this
}
hex(): string {
if (!this.finished) {
const bitLength = this.bytesHashed * 8
this.block[this.blockLength] = 0x80
this.blockLength += 1
if (this.blockLength > 56) {
this.block.fill(0, this.blockLength)
this.compress(this.block)
this.blockLength = 0
}
this.block.fill(0, this.blockLength, 56)
const view = new DataView(this.block.buffer)
view.setUint32(56, Math.floor(bitLength / 0x1_0000_0000), false)
view.setUint32(60, bitLength >>> 0, false)
this.compress(this.block)
this.finished = true
}
return Array.from(this.state, (value) => value.toString(16).padStart(8, '0')).join('')
}
private compress(block: Uint8Array): void {
const view = new DataView(block.buffer, block.byteOffset, block.byteLength)
for (let index = 0; index < 16; index += 1) this.words[index] = view.getUint32(index * 4, false)
for (let index = 16; index < 64; index += 1) {
const first = this.words[index - 15]
const second = this.words[index - 2]
const sigma0 = rotateRight(first, 7) ^ rotateRight(first, 18) ^ (first >>> 3)
const sigma1 = rotateRight(second, 17) ^ rotateRight(second, 19) ^ (second >>> 10)
this.words[index] = (this.words[index - 16] + sigma0 + this.words[index - 7] + sigma1) >>> 0
}
let a = this.state[0]
let b = this.state[1]
let c = this.state[2]
let d = this.state[3]
let e = this.state[4]
let f = this.state[5]
let g = this.state[6]
let h = this.state[7]
for (let index = 0; index < 64; index += 1) {
const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25)
const choice = (e & f) ^ (~e & g)
const temporary1 = (h + sum1 + choice + SHA256_CONSTANTS[index] + this.words[index]) >>> 0
const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22)
const majority = (a & b) ^ (a & c) ^ (b & c)
const temporary2 = (sum0 + majority) >>> 0
h = g
g = f
f = e
e = (d + temporary1) >>> 0
d = c
c = b
b = a
a = (temporary1 + temporary2) >>> 0
}
this.state[0] = (this.state[0] + a) >>> 0
this.state[1] = (this.state[1] + b) >>> 0
this.state[2] = (this.state[2] + c) >>> 0
this.state[3] = (this.state[3] + d) >>> 0
this.state[4] = (this.state[4] + e) >>> 0
this.state[5] = (this.state[5] + f) >>> 0
this.state[6] = (this.state[6] + g) >>> 0
this.state[7] = (this.state[7] + h) >>> 0
}
}
export function sha256Hex(bytes: Uint8Array): string {
return new Sha256().update(bytes).hex()
}
export interface ModelCacheEntry {
cacheKey: string
namespace: string
label: string
byteLength: number
sha256: string
integrityVerified: boolean
createdAt: number
}
export interface StoredModelArtifact {
blob: Blob
metadata: ModelCacheEntry
}
export interface ModelArtifactWriter {
write(chunk: Uint8Array): Promise<void>
commit(metadata: ModelCacheEntry): Promise<StoredModelArtifact>
abort(): Promise<void>
}
export interface ModelArtifactStorage {
readonly backend: CacheBackend
get(cacheKey: string): Promise<StoredModelArtifact | undefined>
createWriter(cacheKey: string): Promise<ModelArtifactWriter>
delete(cacheKey: string): Promise<void>
entries(): Promise<ModelCacheEntry[]>
clear(namespace?: string): Promise<void>
}
class BlobWriter implements ModelArtifactWriter {
private readonly parts: ArrayBuffer[] = []
private closed = false
constructor(
private readonly commitBlob: (blob: Blob, metadata: ModelCacheEntry) => Promise<StoredModelArtifact>,
) {}
async write(chunk: Uint8Array): Promise<void> {
if (this.closed) throw new Error('Artifact writer is closed.')
const copy = new Uint8Array(chunk.byteLength)
copy.set(chunk)
this.parts.push(copy.buffer)
}
async commit(metadata: ModelCacheEntry): Promise<StoredModelArtifact> {
if (this.closed) throw new Error('Artifact writer is closed.')
this.closed = true
return this.commitBlob(new Blob(this.parts, { type: 'application/octet-stream' }), metadata)
}
async abort(): Promise<void> {
this.closed = true
this.parts.length = 0
}
}
/** In-memory adapter used by deterministic tests and advanced ephemeral integrations. */
export class MemoryModelArtifactStorage implements ModelArtifactStorage {
readonly backend: CacheBackend = 'none'
private readonly records = new Map<string, StoredModelArtifact>()
async get(cacheKey: string): Promise<StoredModelArtifact | undefined> {
return this.records.get(cacheKey)
}
async createWriter(cacheKey: string): Promise<ModelArtifactWriter> {
return new BlobWriter(async (blob, metadata) => {
const record = { blob, metadata }
this.records.set(cacheKey, record)
return record
})
}
async delete(cacheKey: string): Promise<void> {
this.records.delete(cacheKey)
}
async entries(): Promise<ModelCacheEntry[]> {
return Array.from(this.records.values(), ({ metadata }) => metadata)
}
async clear(namespace?: string): Promise<void> {
if (namespace === undefined) this.records.clear()
else {
for (const [key, record] of this.records) {
if (record.metadata.namespace === namespace) this.records.delete(key)
}
}
}
}
class EphemeralArtifactStorage implements ModelArtifactStorage {
readonly backend: CacheBackend = 'none'
async get(): Promise<undefined> { return undefined }
async createWriter(): Promise<ModelArtifactWriter> {
return new BlobWriter(async (blob, metadata) => ({ blob, metadata }))
}
async delete(): Promise<void> {}
async entries(): Promise<ModelCacheEntry[]> { return [] }
async clear(): Promise<void> {}
}
function cacheOrigin(): string {
if (typeof location !== 'undefined' && /^https?:$/.test(location.protocol)) return location.origin
return 'https://cache.triposplat.invalid'
}
function temporaryId(): string {
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function metadataHeader(metadata: ModelCacheEntry): string {
return encodeURIComponent(JSON.stringify(metadata))
}
function parseMetadataHeader(value: string | null): ModelCacheEntry | undefined {
if (!value) return undefined
try {
const parsed: unknown = JSON.parse(decodeURIComponent(value))
if (typeof parsed !== 'object' || parsed === null) return undefined
const candidate = parsed as Partial<ModelCacheEntry>
if (
typeof candidate.cacheKey !== 'string'
|| typeof candidate.namespace !== 'string'
|| typeof candidate.label !== 'string'
|| typeof candidate.byteLength !== 'number'
|| typeof candidate.sha256 !== 'string'
|| typeof candidate.integrityVerified !== 'boolean'
|| typeof candidate.createdAt !== 'number'
) return undefined
return candidate as ModelCacheEntry
} catch {
return undefined
}
}
class CacheApiArtifactStorage implements ModelArtifactStorage {
readonly backend: CacheBackend = 'cache-api'
private async cache(): Promise<Cache> {
if (typeof caches === 'undefined') {
throw new ModelDownloadError('Cache API is unavailable in this browser context.', {
diagnostics: { backend: this.backend },
})
}
return caches.open(CACHE_NAME)
}
private async request(cacheKey: string): Promise<Request> {
const id = sha256Hex(new TextEncoder().encode(cacheKey))
return new Request(`${cacheOrigin()}${CACHE_URL_PREFIX}${id}`)
}
async get(cacheKey: string): Promise<StoredModelArtifact | undefined> {
const cache = await this.cache()
const response = await cache.match(await this.request(cacheKey))
if (!response) return undefined
const metadata = parseMetadataHeader(response.headers.get('x-ai3d-metadata'))
if (!metadata || metadata.cacheKey !== cacheKey) return undefined
return { blob: await response.blob(), metadata }
}
async createWriter(cacheKey: string): Promise<ModelArtifactWriter> {
const cache = await this.cache()
const request = await this.request(cacheKey)
const temporaryRequest = new Request(`${request.url}.partial-${temporaryId()}`)
const stream = new TransformStream<Uint8Array, Uint8Array>()
const streamWriter = stream.writable.getWriter()
const cacheWrite = cache.put(temporaryRequest, new Response(stream.readable, {
headers: { 'content-type': 'application/octet-stream' },
}))
let closed = false
return {
async write(chunk) {
if (closed) throw new Error('Artifact writer is closed.')
await streamWriter.write(chunk)
},
async commit(metadata) {
if (closed) throw new Error('Artifact writer is closed.')
closed = true
await streamWriter.close()
await cacheWrite
try {
const temporary = await cache.match(temporaryRequest)
if (!temporary) throw new Error('Temporary Cache API artifact was not committed.')
await cache.put(request, new Response(temporary.body, {
headers: {
'content-type': 'application/octet-stream',
'content-length': String(metadata.byteLength),
'x-ai3d-metadata': metadataHeader(metadata),
},
}))
const finalResponse = await cache.match(request)
if (!finalResponse) throw new Error('Verified Cache API artifact was not committed.')
return { blob: await finalResponse.blob(), metadata }
} finally {
await cache.delete(temporaryRequest)
}
},
async abort() {
if (!closed) {
closed = true
await streamWriter.abort().catch(() => undefined)
}
await cacheWrite.catch(() => undefined)
await cache.delete(temporaryRequest)
},
}
}
async delete(cacheKey: string): Promise<void> {
const cache = await this.cache()
await cache.delete(await this.request(cacheKey))
}
async entries(): Promise<ModelCacheEntry[]> {
const cache = await this.cache()
const result: ModelCacheEntry[] = []
for (const request of await cache.keys()) {
if (!new URL(request.url).pathname.startsWith(CACHE_URL_PREFIX)) continue
if (request.url.includes('.partial-')) {
await cache.delete(request)
continue
}
const response = await cache.match(request)
const metadata = parseMetadataHeader(response?.headers.get('x-ai3d-metadata') ?? null)
if (metadata) result.push(metadata)
}
return result
}
async clear(namespace?: string): Promise<void> {
const cache = await this.cache()
if (namespace === undefined) {
await caches.delete(CACHE_NAME)
return
}
for (const entry of await this.entries()) {
if (entry.namespace === namespace) await cache.delete(await this.request(entry.cacheKey))
}
}
}
interface OpfsMetadataFile extends ModelCacheEntry {
id: string
}
interface IterableDirectoryHandle extends FileSystemDirectoryHandle {
entries(): AsyncIterableIterator<[string, FileSystemHandle]>
}
class OpfsArtifactStorage implements ModelArtifactStorage {
readonly backend: CacheBackend = 'opfs'
private async directory(): Promise<IterableDirectoryHandle> {
const storage = typeof navigator === 'undefined'
? undefined
: (navigator.storage as StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> })
if (!storage?.getDirectory) {
throw new ModelDownloadError('Origin Private File System is unavailable in this browser context.', {
diagnostics: { backend: this.backend },
})
}
const root = await storage.getDirectory()
return root.getDirectoryHandle(OPFS_DIRECTORY, { create: true }) as Promise<IterableDirectoryHandle>
}
private id(cacheKey: string): string {
return sha256Hex(new TextEncoder().encode(cacheKey))
}
private async metadata(directory: FileSystemDirectoryHandle, id: string): Promise<OpfsMetadataFile | undefined> {
try {
const handle = await directory.getFileHandle(`${id}.json`)
const parsed: unknown = JSON.parse(await (await handle.getFile()).text())
if (typeof parsed !== 'object' || parsed === null || (parsed as { id?: unknown }).id !== id) return undefined
return parsed as OpfsMetadataFile
} catch (error) {
if (error instanceof DOMException && error.name === 'NotFoundError') return undefined
return undefined
}
}
async get(cacheKey: string): Promise<StoredModelArtifact | undefined> {
const directory = await this.directory()
const id = this.id(cacheKey)
const metadata = await this.metadata(directory, id)
if (!metadata || metadata.cacheKey !== cacheKey) return undefined
try {
const file = await (await directory.getFileHandle(`${id}.bin`)).getFile()
return { blob: file, metadata }
} catch {
return undefined
}
}
async createWriter(cacheKey: string): Promise<ModelArtifactWriter> {
const directory = await this.directory()
const id = this.id(cacheKey)
const dataHandle = await directory.getFileHandle(`${id}.bin`, { create: true })
const writable = await dataHandle.createWritable()
let closed = false
return {
async write(chunk) {
if (closed) throw new Error('Artifact writer is closed.')
const copy = new Uint8Array(chunk.byteLength)
copy.set(chunk)
await writable.write(copy.buffer)
},
async commit(metadata) {
if (closed) throw new Error('Artifact writer is closed.')
closed = true
await writable.close()
const metadataHandle = await directory.getFileHandle(`${id}.json`, { create: true })
const metadataWriter = await metadataHandle.createWritable()
await metadataWriter.write(JSON.stringify({ ...metadata, id }))
await metadataWriter.close()
return { blob: await dataHandle.getFile(), metadata }
},
async abort() {
if (!closed) {
closed = true
await writable.abort().catch(() => undefined)
}
await directory.removeEntry(`${id}.bin`).catch(() => undefined)
await directory.removeEntry(`${id}.json`).catch(() => undefined)
},
}
}
async delete(cacheKey: string): Promise<void> {
const directory = await this.directory()
const id = this.id(cacheKey)
await directory.removeEntry(`${id}.bin`).catch(() => undefined)
await directory.removeEntry(`${id}.json`).catch(() => undefined)
}
async entries(): Promise<ModelCacheEntry[]> {
const directory = await this.directory()
const result: ModelCacheEntry[] = []
for await (const [name] of directory.entries()) {
if (!name.endsWith('.json')) continue
const metadata = await this.metadata(directory, name.slice(0, -5))
if (metadata) result.push(metadata)
}
return result
}
async clear(namespace?: string): Promise<void> {
const directory = await this.directory()
if (namespace === undefined) {
for await (const [name] of directory.entries()) await directory.removeEntry(name).catch(() => undefined)
return
}
for (const entry of await this.entries()) {
if (entry.namespace === namespace) await this.delete(entry.cacheKey)
}
}
}
/**
* Disk-backed, non-persistent staging for `cache: 'none'`.
*
* Multi-gigabyte sidecars must not be accumulated as JS ArrayBuffer chunks.
* When OPFS is available this writer streams each artifact into a temporary
* origin-private file, returns a file-backed Blob for ORT session creation, and
* removes the directory entry as soon as the prepared graph is released.
*/
class EphemeralOpfsArtifactStorage implements ModelArtifactStorage {
readonly backend: CacheBackend = 'none'
private readonly ids = new Map<string, string>()
private async directory(): Promise<IterableDirectoryHandle> {
const storage = navigator.storage as StorageManager & {
getDirectory: () => Promise<FileSystemDirectoryHandle>
}
const root = await storage.getDirectory()
return root.getDirectoryHandle(OPFS_EPHEMERAL_DIRECTORY, { create: true }) as Promise<IterableDirectoryHandle>
}
async get(): Promise<undefined> { return undefined }
async createWriter(cacheKey: string): Promise<ModelArtifactWriter> {
const directory = await this.directory()
const id = `${sha256Hex(new TextEncoder().encode(cacheKey))}-${temporaryId()}.bin`
const handle = await directory.getFileHandle(id, { create: true })
const writable = await handle.createWritable()
let closed = false
return {
async write(chunk) {
if (closed) throw new Error('Artifact writer is closed.')
const copy = new Uint8Array(chunk.byteLength)
copy.set(chunk)
await writable.write(copy.buffer)
},
commit: async (metadata) => {
if (closed) throw new Error('Artifact writer is closed.')
closed = true
await writable.close()
const previous = this.ids.get(cacheKey)
this.ids.set(cacheKey, id)
if (previous) await directory.removeEntry(previous).catch(() => undefined)
return { blob: await handle.getFile(), metadata }
},
abort: async () => {
if (!closed) {
closed = true
await writable.abort().catch(() => undefined)
}
await directory.removeEntry(id).catch(() => undefined)
},
}
}
async delete(cacheKey: string): Promise<void> {
const id = this.ids.get(cacheKey)
if (!id) return
this.ids.delete(cacheKey)
const directory = await this.directory()
await directory.removeEntry(id).catch(() => undefined)
}
async entries(): Promise<ModelCacheEntry[]> { return [] }
async clear(): Promise<void> {
this.ids.clear()
const directory = await this.directory()
for await (const [name] of directory.entries()) {
await directory.removeEntry(name).catch(() => undefined)
}
}
}
export interface ModelArtifactProgress {
graph: TripoSplatGraphName
label: string
source: 'network' | 'cache'
loadedBytes: number
totalBytes?: number
}
export interface ModelArtifactManagerOptions {
backend: CacheBackend
namespace: string
fetch?: typeof globalThis.fetch
requestInit?: Omit<RequestInit, 'signal' | 'body' | 'method'>
storage?: ModelArtifactStorage
onProgress?: (progress: ModelArtifactProgress) => void
}
export interface PreparedGraphArtifacts {
graph: ResolvedGraphManifestEntry
release(): void
}
interface ArtifactDescriptor {
graph: TripoSplatGraphName
label: string
url: string
byteLength?: number
integrity?: IntegrityDescriptor
}
interface VerifiedArtifact extends StoredModelArtifact {
source: 'network' | 'cache'
}
function persistentStorage(backend: CacheBackend): ModelArtifactStorage {
if (backend === 'opfs') return new OpfsArtifactStorage()
if (backend === 'cache-api') return new CacheApiArtifactStorage()
const storage = typeof navigator === 'undefined'
? undefined
: navigator.storage as StorageManager & { getDirectory?: () => Promise<FileSystemDirectoryHandle> }
if (typeof storage?.getDirectory === 'function') return new EphemeralOpfsArtifactStorage()
return new EphemeralArtifactStorage()
}
async function hashBlob(blob: Blob, signal?: AbortSignal): Promise<{ sha256: string; byteLength: number }> {
const hash = new Sha256()
let byteLength = 0
const reader = blob.stream().getReader()
try {
while (true) {
throwIfAborted(signal)
const { done, value } = await reader.read()
if (done) break
hash.update(value)
byteLength += value.byteLength
}
} catch (error) {
await reader.cancel(error).catch(() => undefined)
throw error
} finally {
reader.releaseLock()
}
return { sha256: hash.hex(), byteLength }
}
function assertArtifactIntegrity(
descriptor: ArtifactDescriptor,
actual: { sha256: string; byteLength: number },
): void {
if (descriptor.byteLength !== undefined && actual.byteLength !== descriptor.byteLength) {
throw new ModelIntegrityError(
`Model artifact '${descriptor.label}' has ${actual.byteLength} bytes; expected ${descriptor.byteLength}.`,
{
diagnostics: {
graph: descriptor.graph,
label: descriptor.label,
expectedByteLength: descriptor.byteLength,
actualByteLength: actual.byteLength,
},
},
)
}
if (descriptor.integrity && actual.sha256 !== descriptor.integrity.digest) {
throw new ModelIntegrityError(`SHA-256 mismatch for model artifact '${descriptor.label}'.`, {
diagnostics: {
graph: descriptor.graph,
label: descriptor.label,
expectedSha256: descriptor.integrity.digest,
actualSha256: actual.sha256,
},
})
}
}
export function modelCacheNamespace(
manifest: Pick<ResolvedTripoSplatModelManifest, 'name' | 'version' | 'modelRevision' | 'precision'>,
): string {
return `${manifest.name}/${manifest.version}/${manifest.modelRevision}/${manifest.precision}`
}
export class ModelArtifactManager {
readonly backend: CacheBackend
readonly namespace: string
private readonly fetchImplementation: typeof globalThis.fetch
private readonly requestInit: Omit<RequestInit, 'signal' | 'body' | 'method'> | undefined
private readonly storage: ModelArtifactStorage
private readonly onProgress: ((progress: ModelArtifactProgress) => void) | undefined
private readonly pending = new Map<string, Promise<VerifiedArtifact>>()
private readonly verifiedArtifacts = new Map<string, VerifiedArtifact>()
private readonly objectUrls = new Set<string>()
constructor(options: ModelArtifactManagerOptions) {
this.backend = options.backend
this.namespace = options.namespace
const fetchImplementation = options.fetch ?? globalThis.fetch
if (typeof fetchImplementation !== 'function') {
throw new ModelDownloadError('No Fetch implementation is available for model artifacts.')
}
this.fetchImplementation = fetchImplementation
this.requestInit = options.requestInit
this.storage = options.storage ?? persistentStorage(options.backend)
this.onProgress = options.onProgress
}
async prefetchManifest(
manifest: ResolvedTripoSplatModelManifest,
signal?: AbortSignal,
): Promise<void> {
for (const graph of Object.keys(manifest.graphs) as TripoSplatGraphName[]) {
const descriptor = manifest.graphs[graph]
if (!descriptor) continue
for (const asset of this.descriptors(graph, descriptor)) {
throwIfAborted(signal)
await this.getArtifact(asset, signal)
}
}
}
async prepareGraph(
name: TripoSplatGraphName,
graph: ResolvedGraphManifestEntry,
signal?: AbortSignal,
): Promise<PreparedGraphArtifacts> {
if (typeof URL.createObjectURL !== 'function') {
throw new ModelDownloadError('Blob object URLs are unavailable for verified ONNX artifacts.')
}
const urls: string[] = []
let ephemeralCacheKeys: string[] = []
try {
const descriptors = this.descriptors(name, graph)
if (this.backend === 'none') {
ephemeralCacheKeys = descriptors.map((descriptor) => this.cacheKey(descriptor))
}
const graphArtifact = await this.getArtifact(descriptors[0], signal)
const graphUrl = URL.createObjectURL(graphArtifact.blob)
urls.push(graphUrl)
this.objectUrls.add(graphUrl)
const externalData: ResolvedExternalDataDescriptor[] = []
for (let index = 1; index < descriptors.length; index += 1) {
const descriptor = descriptors[index]
const artifact = await this.getArtifact(descriptor, signal)
const url = URL.createObjectURL(artifact.blob)
urls.push(url)
this.objectUrls.add(url)
const source = graph.externalData?.[index - 1]
if (!source) throw new ModelDownloadError(`Missing external-data descriptor ${index - 1} for '${name}'.`)
externalData.push({ ...source, url })
}
const prepared: ResolvedGraphManifestEntry = { ...graph, url: graphUrl }
if (externalData.length > 0) prepared.externalData = externalData
else delete prepared.externalData
let released = false
return {
graph: prepared,
release: () => {
if (released) return
released = true
for (const url of urls) {
URL.revokeObjectURL(url)
this.objectUrls.delete(url)
}
for (const cacheKey of ephemeralCacheKeys) {
void this.storage.delete(cacheKey).catch(() => undefined)
}
},
}
} catch (error) {
for (const url of urls) {
URL.revokeObjectURL(url)
this.objectUrls.delete(url)
}
await Promise.all(ephemeralCacheKeys.map((cacheKey) => this.storage.delete(cacheKey).catch(() => undefined)))
throw error
}
}
async status(): Promise<ModelCacheEntry[]> {
return this.storage.entries()
}
async clear(namespace = this.namespace): Promise<void> {
await this.storage.clear(namespace)
if (namespace === this.namespace) this.verifiedArtifacts.clear()
}
dispose(): void {
for (const url of this.objectUrls) URL.revokeObjectURL(url)
this.objectUrls.clear()
this.verifiedArtifacts.clear()
if (this.backend === 'none') void this.storage.clear().catch(() => undefined)
}
private descriptors(name: TripoSplatGraphName, graph: ResolvedGraphManifestEntry): ArtifactDescriptor[] {
const graphDescriptor: ArtifactDescriptor = { graph: name, label: `${name}:graph`, url: graph.url }
if (graph.byteLength !== undefined) graphDescriptor.byteLength = graph.byteLength
if (graph.integrity !== undefined) graphDescriptor.integrity = graph.integrity
const result = [graphDescriptor]
for (const external of graph.externalData ?? []) {
const descriptor: ArtifactDescriptor = {
graph: name,
label: `${name}:external:${external.path}`,
url: external.url,
}
if (external.byteLength !== undefined) descriptor.byteLength = external.byteLength
if (external.integrity !== undefined) descriptor.integrity = external.integrity
result.push(descriptor)
}
return result
}
private cacheKey(descriptor: ArtifactDescriptor): string {
// Never persist signed URLs or credentials in OPFS metadata/Cache headers.
const identity = descriptor.integrity?.digest
?? sha256Hex(new TextEncoder().encode(descriptor.url))
return `${this.namespace}|${descriptor.label}|${identity}`
}
private getArtifact(descriptor: ArtifactDescriptor, signal?: AbortSignal): Promise<VerifiedArtifact> {
const cacheKey = this.cacheKey(descriptor)
if (this.backend !== 'none') {
const verified = this.verifiedArtifacts.get(cacheKey)
if (verified) {
try {
throwIfAborted(signal)
assertArtifactIntegrity(descriptor, {
sha256: verified.metadata.sha256,
byteLength: verified.metadata.byteLength,
})
return Promise.resolve(verified)
} catch (error) {
return Promise.reject(error)
}
}
}
const existing = this.pending.get(cacheKey)
if (existing) return existing
const pending = this.loadArtifact(cacheKey, descriptor, signal)
.then((artifact) => {
if (this.backend !== 'none') this.verifiedArtifacts.set(cacheKey, artifact)
return artifact
})
.finally(() => this.pending.delete(cacheKey))
this.pending.set(cacheKey, pending)
return pending
}
private async loadArtifact(
cacheKey: string,
descriptor: ArtifactDescriptor,
signal?: AbortSignal,
): Promise<VerifiedArtifact> {
throwIfAborted(signal)
let cached: StoredModelArtifact | undefined
try {
cached = await this.storage.get(cacheKey)
} catch (cause) {
throw new ModelDownloadError(`Could not read cached model artifact '${descriptor.label}'.`, {
cause,
diagnostics: { backend: this.backend, graph: descriptor.graph, label: descriptor.label },
})
}
if (cached) {
try {
const actual = await hashBlob(cached.blob, signal)
assertArtifactIntegrity(descriptor, actual)
if (
actual.sha256 !== cached.metadata.sha256
|| actual.byteLength !== cached.metadata.byteLength
|| cached.metadata.namespace !== this.namespace
) throw new ModelIntegrityError(`Cached model artifact '${descriptor.label}' is corrupted.`)
this.onProgress?.({
graph: descriptor.graph,
label: descriptor.label,
source: 'cache',
loadedBytes: actual.byteLength,
totalBytes: descriptor.byteLength ?? actual.byteLength,
})
return { ...cached, source: 'cache' }
} catch (error) {
if (error instanceof CancelledError) throw error
try {
await this.storage.delete(cacheKey)
} catch (cause) {
throw new ModelDownloadError(`Could not evict cached model artifact '${descriptor.label}'.`, {
cause,
diagnostics: { backend: this.backend, graph: descriptor.graph, label: descriptor.label },
})
}
// A corrupt local record is discarded; the authoritative CDN object is
// downloaded and independently verified before the session can load.
}
}
let response: Response
try {
const init: RequestInit = { ...this.requestInit, method: 'GET' }
if (signal !== undefined) init.signal = signal
const fetchImplementation = this.fetchImplementation
response = await fetchImplementation(descriptor.url, init)
} catch (cause) {
if (signal?.aborted) throw new CancelledError(undefined, { cause: signal.reason })
throw new ModelDownloadError(`Could not download model artifact '${descriptor.label}'.`, {
cause,
diagnostics: { graph: descriptor.graph, label: descriptor.label },
})
}
if (!response.ok) {
throw new ModelDownloadError(
`Model artifact '${descriptor.label}' failed with HTTP ${response.status} ${response.statusText}.`,
{ diagnostics: { graph: descriptor.graph, label: descriptor.label, status: response.status } },
)
}
let writer: ModelArtifactWriter
try {
writer = await this.storage.createWriter(cacheKey)
} catch (cause) {
await response.body?.cancel().catch(() => undefined)
throw new ModelDownloadError(`Could not open ${this.backend} storage for '${descriptor.label}'.`, {
cause,
diagnostics: { backend: this.backend, graph: descriptor.graph, label: descriptor.label },
})
}
const hash = new Sha256()
let byteLength = 0
const contentLength = Number(response.headers.get('content-length'))
const totalBytes = descriptor.byteLength
?? (Number.isSafeInteger(contentLength) && contentLength >= 0 ? contentLength : undefined)
try {
const body = response.body
if (body) {
const reader = body.getReader()
try {
while (true) {
throwIfAborted(signal)
const { done, value } = await reader.read()
if (done) break
hash.update(value)
byteLength += value.byteLength
await writer.write(value)
const progress: ModelArtifactProgress = {
graph: descriptor.graph,
label: descriptor.label,
source: 'network',
loadedBytes: byteLength,
}
if (totalBytes !== undefined) progress.totalBytes = totalBytes
this.onProgress?.(progress)
}
} catch (error) {
await reader.cancel(error).catch(() => undefined)
throw error
} finally {
reader.releaseLock()
}
}
const actual = { sha256: hash.hex(), byteLength }
assertArtifactIntegrity(descriptor, actual)
const metadata: ModelCacheEntry = {
cacheKey,
namespace: this.namespace,
label: descriptor.label,
byteLength,
sha256: actual.sha256,
integrityVerified: descriptor.integrity !== undefined,
createdAt: Date.now(),
}
const stored = await writer.commit(metadata)
return { ...stored, source: 'network' }
} catch (error) {
await writer.abort().catch(() => undefined)
if (signal?.aborted && !(error instanceof CancelledError)) {
throw new CancelledError(undefined, { cause: signal.reason })
}
if (error instanceof TripoSplatError) throw error
throw new ModelDownloadError(`Could not persist model artifact '${descriptor.label}'.`, {
cause: error,
diagnostics: { backend: this.backend, graph: descriptor.graph, label: descriptor.label },
})
}
}
}
/**
* Decorate a runtime so every ONNX graph and external-data shard is fetched,
* verified, and materialized locally before the worker creates a session.
*/
export function withVerifiedModelArtifacts(
runtime: TripoSplatRuntime,
manager: ModelArtifactManager,
graphForSession: Readonly<Record<string, TripoSplatGraphName>>,
): TripoSplatRuntime {
const decorated: TripoSplatRuntime = {
get disposed() { return runtime.disposed },
async loadGraph(
sessionId: string,
graph: ResolvedGraphManifestEntry,
options: LoadGraphOptions = {},
): Promise<GraphInfo> {
const name = graphForSession[sessionId]
if (!name) {
throw new ModelDownloadError(`No model-artifact identity is configured for session '${sessionId}'.`, {
diagnostics: { sessionId },
})
}
const prepared = await manager.prepareGraph(name, graph, options.signal)
try {
// The built-in runtime removes the signal before posting to the worker,
// but still needs it locally to terminate an in-flight ORT graph load.
return await runtime.loadGraph(sessionId, prepared.graph, options)
} finally {
prepared.release()
}
},
runGraph(
sessionId: string,
inputs: Parameters<TripoSplatRuntime['runGraph']>[1],
options?: RunGraphOptions,
): Promise<GraphRunResult> {
return runtime.runGraph(sessionId, inputs, options)
},
disposeGraph(sessionId: string): Promise<boolean> {
return runtime.disposeGraph(sessionId)
},
async dispose(): Promise<void> {
manager.dispose()
await runtime.dispose()
},
}
forwardReusableGraphInputCapability(runtime, decorated)
return decorated
}
export interface ModelCacheStatusOptions {
backend?: Exclude<CacheBackend, 'none'>
namespace?: string
}
export interface ModelCacheBackendStatus {
backend: Exclude<CacheBackend, 'none'>
available: boolean
entryCount: number
totalBytes: number
entries: ModelCacheEntry[]
error?: string
}
export interface ModelCacheStatus {
entryCount: number
totalBytes: number
backends: ModelCacheBackendStatus[]
}
function persistentBackends(
requested?: Exclude<CacheBackend, 'none'>,
): Array<Exclude<CacheBackend, 'none'>> {
return requested ? [requested] : ['opfs', 'cache-api']
}
export async function getModelCacheStatus(
options: ModelCacheStatusOptions = {},
): Promise<ModelCacheStatus> {
const backends: ModelCacheBackendStatus[] = []
for (const backend of persistentBackends(options.backend)) {
try {
const storage = persistentStorage(backend)
const allEntries = await storage.entries()
const entries = options.namespace === undefined
? allEntries
: allEntries.filter((entry) => entry.namespace === options.namespace)
backends.push({
backend,
available: true,
entryCount: entries.length,
totalBytes: entries.reduce((total, entry) => total + entry.byteLength, 0),
entries,
})
} catch (error) {
backends.push({
backend,
available: false,
entryCount: 0,
totalBytes: 0,
entries: [],
error: error instanceof Error ? error.message : String(error),
})
}
}
return {
entryCount: backends.reduce((total, status) => total + status.entryCount, 0),
totalBytes: backends.reduce((total, status) => total + status.totalBytes, 0),
backends,
}
}
export async function clearModelCache(options: ModelCacheStatusOptions = {}): Promise<void> {
for (const backend of persistentBackends(options.backend)) {
try {
await persistentStorage(backend).clear(options.namespace)
} catch {
// Clearing all backends is best-effort on browsers that expose only one.
if (options.backend !== undefined) throw new ModelDownloadError(`Could not clear ${backend} model cache.`)
}
}
}
export function graphArtifactByteLength(graph: GraphManifestEntry): number | undefined {
const lengths = [graph.byteLength, ...(graph.externalData ?? []).map((entry) => entry.byteLength)]
return lengths.every((length) => length !== undefined)
? lengths.reduce((total, length) => total + (length ?? 0), 0)
: undefined
}