diff --git a/packages/stage-ui-mmd/package.json b/packages/stage-ui-mmd/package.json index e825a793c..80e694359 100644 --- a/packages/stage-ui-mmd/package.json +++ b/packages/stage-ui-mmd/package.json @@ -26,7 +26,8 @@ "./utils/mmd-loader": "./src/utils/mmd-loader.ts", "./utils/mmd-preview": "./src/utils/mmd-preview.ts", "./utils/mmd-validator": "./src/utils/mmd-validator.ts", - "./utils/mmd-zip-loader": "./src/utils/mmd-zip-loader.ts" + "./utils/mmd-zip-loader": "./src/utils/mmd-zip-loader.ts", + "./utils/opfs-loader": "./src/utils/opfs-loader.ts" }, "scripts": { "typecheck": "vue-tsc --noEmit" diff --git a/packages/stage-ui-mmd/src/components/scenes/MMD.vue b/packages/stage-ui-mmd/src/components/scenes/MMD.vue index bcc42102a..282ce5c20 100644 --- a/packages/stage-ui-mmd/src/components/scenes/MMD.vue +++ b/packages/stage-ui-mmd/src/components/scenes/MMD.vue @@ -363,7 +363,7 @@ async function loadModel(src: string) { disposeModel() try { - resolved = await loadMMDModelFromSource(src) + resolved = await loadMMDModelFromSource(src, { cacheKey: props.modelId }) mesh = resolved.mesh modelGroup = new Group() diff --git a/packages/stage-ui-mmd/src/index.ts b/packages/stage-ui-mmd/src/index.ts index 1de474a17..8fb908292 100644 --- a/packages/stage-ui-mmd/src/index.ts +++ b/packages/stage-ui-mmd/src/index.ts @@ -9,3 +9,4 @@ export * from './utils/mmd-loader' export * from './utils/mmd-preview' export * from './utils/mmd-validator' export * from './utils/mmd-zip-loader' +export * from './utils/opfs-loader' diff --git a/packages/stage-ui-mmd/src/utils/mmd-loader.ts b/packages/stage-ui-mmd/src/utils/mmd-loader.ts index 6aad703fe..6824c3cd4 100644 --- a/packages/stage-ui-mmd/src/utils/mmd-loader.ts +++ b/packages/stage-ui-mmd/src/utils/mmd-loader.ts @@ -6,6 +6,7 @@ import type { MMDLoadedAssets, MMDModelFormat } from './mmd-zip-loader' import { createMMDLoaderContext, loadMMD } from '../composables/mmd/loader' import { prepareMMDMaterials } from './mmd-materials' import { loadMMDZip } from './mmd-zip-loader' +import { OPFSCache } from './opfs-loader' export interface ResolvedMMDModel { /** MMD runtime that owns IK, grant, morph, and optional physics state. */ @@ -20,6 +21,13 @@ export interface ResolvedMMDModel { } export interface LoadMMDOptions { + /** + * Stable cache key for a packaged ZIP source. The display-model id is the + * intended value; when provided, OPFS is checked before fetching `src`. + * Bare PMX/PMD URLs are loaded from their original URL so relative texture + * paths keep their server-relative base. + */ + cacheKey?: string /** * Wait for the model's textures to finish loading before resolving. * @@ -77,11 +85,17 @@ function formatFromUrl(url: string): MMDModelFormat { * does not dispose the mesh's GPU resources — the scene owns that lifecycle. */ export async function loadMMDModelFromSource(src: string, options: LoadMMDOptions = {}): Promise { - const response = await fetch(src) - if (!response.ok) - throw new Error(`Failed to fetch MMD model: ${response.status} ${response.statusText}`) - - const buffer = await response.arrayBuffer() + const cachedSource = options.cacheKey ? await OPFSCache.get(options.cacheKey, src) : null + let buffer: ArrayBuffer + if (cachedSource) { + buffer = await cachedSource.arrayBuffer() + } + else { + const response = await fetch(src) + if (!response.ok) + throw new Error(`Failed to fetch MMD model: ${response.status} ${response.statusText}`) + buffer = await response.arrayBuffer() + } if (isZip(buffer)) { const assets = await loadMMDZip(buffer) @@ -92,6 +106,8 @@ export async function loadMMDModelFromSource(src: string, options: LoadMMDOption prepareMMDMaterials(mmd.mesh) if (options.waitForTextures) await waitForManagerIdle(manager) + if (options.cacheKey && !cachedSource) + await OPFSCache.save(options.cacheKey, new Blob([buffer]), src) return { mmd, mesh: mmd.mesh, diff --git a/packages/stage-ui-mmd/src/utils/opfs-loader.ts b/packages/stage-ui-mmd/src/utils/opfs-loader.ts new file mode 100644 index 000000000..3a270491e --- /dev/null +++ b/packages/stage-ui-mmd/src/utils/opfs-loader.ts @@ -0,0 +1,116 @@ +interface OPFSCacheMeta { + sourceUrl?: string + version?: number +} + +/** + * Cache schema version for OPFS-stored MMD source files. + * + * Increment when the persisted directory shape changes. + */ +const mmdOpfsCacheVersion = 1 +const sourceFileName = '__source.bin' + +/** + * Stores the original MMD source bytes in OPFS so a model can be replayed + * without fetching its blob URL again after a reload. + * + * The cache key must be stable for the same imported model (the display-model + * id is the intended key). Blob URLs are deliberately not compared because + * they are recreated for the same IndexedDB-backed file on every session. + */ +export class OPFSCache { + static async clearAll(): Promise { + try { + const root = await navigator.storage.getDirectory() + const entryNames: string[] = [] + for await (const entry of root.values()) + entryNames.push(entry.name) + + await Promise.all(entryNames.map(name => root.removeEntry(name, { recursive: true }))) + } + catch (error) { + console.error('[OPFS] Failed to clear MMD cache:', error) + } + } + + private static async writeFile( + root: FileSystemDirectoryHandle, + fileName: string, + content: Blob | string, + ): Promise { + const fileHandle = await root.getFileHandle(fileName, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(content) + await writable.close() + } + + private static async readMeta(dirHandle: FileSystemDirectoryHandle): Promise { + try { + const metaHandle = await dirHandle.getFileHandle('__meta.json', { create: false }) + const metaFile = await metaHandle.getFile() + return JSON.parse(await metaFile.text()) as OPFSCacheMeta + } + catch { + return null + } + } + + /** + * Returns the cached source for a model, or `null` when the cache is absent + * or no longer matches the requested remote URL. + */ + static async get(key: string, sourceUrl: string): Promise { + try { + const root = await navigator.storage.getDirectory() + const dirHandle = await root.getDirectoryHandle(key, { create: false }) + const meta = await OPFSCache.readMeta(dirHandle) + + if (meta?.version !== mmdOpfsCacheVersion) { + // NOTICE: + // The cache stores one source file plus metadata. Invalidating the + // directory keeps a future format change from being interpreted as a + // valid model blob. + // Source/context: OPFSCache source-file persistence. + // Removal condition: the persisted directory format is permanently stable. + await root.removeEntry(dirHandle.name, { recursive: true }) + return null + } + + const shouldValidateSourceUrl = !sourceUrl.startsWith('blob:') + if (shouldValidateSourceUrl && meta.sourceUrl && meta.sourceUrl !== sourceUrl) { + // A stable model id can outlive a changed remote URL. Never serve the + // old source in that case. + await root.removeEntry(dirHandle.name, { recursive: true }) + return null + } + + const sourceHandle = await dirHandle.getFileHandle(sourceFileName, { create: false }) + return await sourceHandle.getFile() + } + catch { + // OPFS is an optional acceleration layer; a miss falls back to fetch. + return null + } + } + + /** + * Persists the original source bytes under a stable model key. + * Cache failures are intentionally swallowed so model loading remains usable + * in browsers where OPFS is unavailable or storage is full. + */ + static async save(key: string, source: Blob, sourceUrl?: string): Promise { + try { + const root = await navigator.storage.getDirectory() + const dirHandle = await root.getDirectoryHandle(key, { create: true }) + await OPFSCache.writeFile(dirHandle, sourceFileName, source) + await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({ + sourceUrl, + version: mmdOpfsCacheVersion, + })) + } + catch (error) { + console.error('[OPFS] Failed to save MMD cache:', error) + } + } +}