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()
+ }
+}