From a33667a48f4f9f110aad609076cba290c373e971 Mon Sep 17 00:00:00 2001 From: Mr-Quin <8700123+Mr-Quin@users.noreply.github.com> Date: Mon, 24 Nov 2025 01:15:51 -0800 Subject: [PATCH] implement opfs cache for zipped l2d models (#760) --- Cargo.toml | 9 +- .../settings/model-settings/index.vue | 1 + .../stage-ui/src/components/scenes/Live2D.vue | 3 + .../stage-ui/src/components/scenes/Stage.vue | 3 +- .../src/components/scenes/live2d/Model.vue | 11 +- .../stage-ui/src/stores/display-models.ts | 1 + .../src/utils/live2d-opfs-registration.ts | 18 ++ packages/stage-ui/src/utils/opfs-loader.ts | 181 ++++++++++++++++++ 8 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 packages/stage-ui/src/utils/live2d-opfs-registration.ts create mode 100644 packages/stage-ui/src/utils/opfs-loader.ts diff --git a/Cargo.toml b/Cargo.toml index 3252ca2b8..e7c818eed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,12 @@ [workspace] -members = [ "crates/tauri-plugin-ipc-audio-transcription-ort", "crates/tauri-plugin-ipc-audio-vad-ort", "crates/tauri-plugin-mcp", "crates/tauri-plugin-rdev", "crates/tauri-plugin-window-pass-through-on-hover", "crates/tauri-plugin-window-router-link" ] +members = [ + "crates/tauri-plugin-ipc-audio-transcription-ort", + "crates/tauri-plugin-ipc-audio-vad-ort", + "crates/tauri-plugin-mcp", + "crates/tauri-plugin-rdev", + "crates/tauri-plugin-window-pass-through-on-hover", + "crates/tauri-plugin-window-router-link" +] resolver = "2" [workspace.package] diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue index 3aceb8976..fc9b322d3 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/index.vue @@ -85,6 +85,7 @@ watch(selectedModel, async () => { diff --git a/packages/stage-ui/src/components/scenes/Live2D.vue b/packages/stage-ui/src/components/scenes/Live2D.vue index b717441bf..7c76914d9 100644 --- a/packages/stage-ui/src/components/scenes/Live2D.vue +++ b/packages/stage-ui/src/components/scenes/Live2D.vue @@ -9,9 +9,11 @@ import Live2DModel from './live2d/Model.vue' import { useLive2d } from '../../stores/live2d' import '../../utils/live2d-zip-loader' +import '../../utils/live2d-opfs-registration' withDefaults(defineProps<{ modelSrc?: string + modelId?: string paused?: boolean mouthOpenSize?: number @@ -64,6 +66,7 @@ defineExpose({ { v-model:state="componentState" min-w="50% () - if (modelSrcRef.value.startsWith('blob:')) { - const res = await fetch(modelSrcRef.value) - const blob = await res.blob() - await Live2DFactory.setupLive2DModel(live2DModel, [new File([blob], 'model.zip')], { autoInteract: false }) - } - else { - await Live2DFactory.setupLive2DModel(live2DModel, modelSrcRef.value, { autoInteract: false }) - } - + await Live2DFactory.setupLive2DModel(live2DModel, { url: modelSrcRef.value, id: props.modelId }, { autoInteract: false }) availableMotions.value.forEach((motion) => { if (motion.motionName in Emotion) { motionMap.value[motion.fileName] = motion.motionName diff --git a/packages/stage-ui/src/stores/display-models.ts b/packages/stage-ui/src/stores/display-models.ts index 4b6c09e5c..1b9357dc3 100644 --- a/packages/stage-ui/src/stores/display-models.ts +++ b/packages/stage-ui/src/stores/display-models.ts @@ -11,6 +11,7 @@ import { Live2DFactory, Live2DModel } from 'pixi-live2d-display/cubism4' import { ref } from 'vue' import '../utils/live2d-zip-loader' +import '../utils/live2d-opfs-registration' export enum DisplayModelFormat { Live2dZip = 'live2d-zip', diff --git a/packages/stage-ui/src/utils/live2d-opfs-registration.ts b/packages/stage-ui/src/utils/live2d-opfs-registration.ts new file mode 100644 index 000000000..a71f15fcc --- /dev/null +++ b/packages/stage-ui/src/utils/live2d-opfs-registration.ts @@ -0,0 +1,18 @@ +import { Live2DFactory, ZipLoader } from 'pixi-live2d-display/cubism4' + +import { OPFSCache } from './opfs-loader' + +const zipLoaderIndex = Live2DFactory.live2DModelMiddlewares.indexOf(ZipLoader.factory) + +if (Live2DFactory.live2DModelMiddlewares.includes(OPFSCache.checkMiddleware)) { + // Middlewares already registered. +} +else if (zipLoaderIndex !== -1) { + // Insert Check before ZipLoader + Live2DFactory.live2DModelMiddlewares.splice(zipLoaderIndex, 0, OPFSCache.checkMiddleware) + // Insert Save after ZipLoader + Live2DFactory.live2DModelMiddlewares.splice(zipLoaderIndex + 2, 0, OPFSCache.saveMiddleware) +} +else { + console.warn('[OPFS] ZipLoader not found in middlewares, caching disabled') +} diff --git a/packages/stage-ui/src/utils/opfs-loader.ts b/packages/stage-ui/src/utils/opfs-loader.ts new file mode 100644 index 000000000..5f5036987 --- /dev/null +++ b/packages/stage-ui/src/utils/opfs-loader.ts @@ -0,0 +1,181 @@ +import type { Live2DFactoryContext, Middleware, ModelSettings } from 'pixi-live2d-display/cubism4' + +interface OPFSContext extends Live2DFactoryContext { + opfsKey?: string +} + +declare global { + interface FileSystemDirectoryHandle { + values(): AsyncIterableIterator + } +} + +export class OPFSCache { + static async readDirectoryRecursive(dir: FileSystemDirectoryHandle, pathPrefix: string): Promise { + const files: File[] = [] + for await (const entry of dir.values()) { + if (entry.kind === 'file') { + const fileHandle = entry as FileSystemFileHandle + const file = await fileHandle.getFile() + // live2d-display expects this + Object.defineProperty(file, 'webkitRelativePath', { + value: pathPrefix + file.name, + }) + files.push(file) + } + else if (entry.kind === 'directory') { + const newPrefix = `${pathPrefix + entry.name}/` + const subFiles = await OPFSCache.readDirectoryRecursive(entry as FileSystemDirectoryHandle, newPrefix) + files.push(...subFiles) + } + } + return files + } + + static async resolveDirectory(root: FileSystemDirectoryHandle, path: string): Promise { + let currentDir = root + if (!path || path === '.' || path === './') + return currentDir + + const parts = path.split('/').filter(p => p && p !== '.') + for (const part of parts) { + currentDir = await currentDir.getDirectoryHandle(part, { create: true }) + } + return currentDir + } + + static async writeFile(root: FileSystemDirectoryHandle, filePath: string, content: Blob | string): Promise { + const parts = filePath.split('/') + const fileName = parts.pop()! + const dirPath = parts.join('/') + + const dirHandle = await OPFSCache.resolveDirectory(root, dirPath) + const fileHandle = await dirHandle.getFileHandle(fileName, { create: true }) + const writable = await fileHandle.createWritable() + await writable.write(content) + await writable.close() + } + + static async get(key: string): Promise { + try { + const root = await navigator.storage.getDirectory() + const dirHandle = await root.getDirectoryHandle(key, { create: false }) + console.debug(`[OPFS] Cache hit for ${key}`) + + const files = await OPFSCache.readDirectoryRecursive(dirHandle, '') + + if (files.length > 0) { + return files + } + } + catch (e) { + // Cache Miss + } + return null + } + + static async save(key: string, files: File[]): Promise { + console.debug(`[OPFS] Saving ${files.length} files to ${key}`) + + try { + const root = await navigator.storage.getDirectory() + const dirHandle = await root.getDirectoryHandle(key, { create: true }) + + const writePromises: Promise[] = [] + + for (const file of files) { + const relativePath = file.webkitRelativePath || file.name + writePromises.push(OPFSCache.writeFile(dirHandle, relativePath, file)) + } + + const settingsFile = files.find(f => f.name.endsWith('model.json') || f.name.endsWith('model3.json')) + + if (!settingsFile) { + // reconstruct settings files from ModelSettings + const settings: ModelSettings = (files as any).settings + if (settings) { + console.debug('[OPFS] Reconstructing settings file...') + const settingsJson = JSON.stringify(settings.json) + const settingsFileName = settings.url || 'model.model3.json' + + writePromises.push(OPFSCache.writeFile(dirHandle, settingsFileName, settingsJson)) + } + } + + await Promise.all(writePromises) + console.debug(`[OPFS] Saved to cache`) + } + catch (e) { + console.error('[OPFS] Failed to save to cache:', e) + } + } + + // Runs before ZipLoader to check if the file is already cached + static checkMiddleware: Middleware = async (context, next) => { + const source = context.source + let key: string | undefined + let blobUrl: string | undefined + + // In Model.vue, we pass {id, url} to the loader, extract them here + if ( + typeof source === 'object' + && source !== null + && 'id' in source + && 'url' in source + ) { + key = source.id + blobUrl = source.url + } + else { + return next() + } + + // check if url is blob or zip, pass through if not + if (!key || !blobUrl || (!blobUrl.startsWith('blob:') && !blobUrl.endsWith('.zip'))) { + context.source = blobUrl + return next() + } + + const files = await OPFSCache.get(key) + + if (files) { + // cache hit + context.source = files + return next() + } + + // cache miss + console.debug(`[OPFS] Cache miss for ${key}`) + context.opfsKey = key + + try { + const res = await fetch(blobUrl) + const blob = await res.blob() + const fileName = `${key}.zip` + context.source = [new File([blob], fileName)] + } + catch (e) { + console.error(`[OPFS] Failed to fetch blob for ${key}`, e) + throw e + } + + return next() + } + + // Runs after ZipLoader to cache the files + static saveMiddleware: Middleware = async (context, next) => { + if (!context.opfsKey || !Array.isArray(context.source)) { + return next() + } + + const files = context.source as File[] + + if (files.length === 0 || !(files[0] instanceof File)) { + return next() + } + + await OPFSCache.save(context.opfsKey, files) + + return next() + } +}