implement opfs cache for zipped l2d models (#760)

This commit is contained in:
Mr-Quin
2025-11-24 17:15:51 +08:00
committed by GitHub
parent e34e7aa389
commit a33667a48f
8 changed files with 216 additions and 11 deletions
+8 -1
View File
@@ -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]
@@ -85,6 +85,7 @@ watch(selectedModel, async () => {
<Live2DScene
:focus-at="{ x: positionCursor.x.value, y: positionCursor.y.value }"
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:disable-focus-at="live2dDisableFocus"
/>
</div>
@@ -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({
<Live2DModel
v-model:state="componentStateModel"
:model-src="modelSrc"
:model-id="modelId"
:app="app"
:mouth-open-size="mouthOpenSize"
:width="width"
@@ -71,7 +71,7 @@ const { connectAudioContext, connectAudioAnalyser, clearAll, onPlaybackStarted,
const { currentAudioSource, playbackQueue } = storeToRefs(characterSpeechPlaybackQueue)
const settingsStore = useSettings()
const { stageModelRenderer, stageViewControlsEnabled, live2dDisableFocus, stageModelSelectedUrl } = storeToRefs(settingsStore)
const { stageModelRenderer, stageViewControlsEnabled, live2dDisableFocus, stageModelSelectedUrl, stageModelSelected } = storeToRefs(settingsStore)
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { audioContext, calculateVolume } = useAudioContext()
connectAudioContext(audioContext)
@@ -323,6 +323,7 @@ onPlaybackStarted(({ text }) => {
v-model:state="componentState" min-w="50% <lg:full" min-h="100 sm:100" h-full w-full
flex-1
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:focus-at="focusAt"
:mouth-open-size="mouthOpenSize"
:paused="paused"
@@ -25,6 +25,7 @@ type PixiLive2DInternalModel = InternalModel & {
const props = withDefaults(defineProps<{
modelSrc?: string
modelId?: string
app?: Application
mouthOpenSize?: number
@@ -176,15 +177,7 @@ async function loadModel() {
try {
const live2DModel = new Live2DModel<PixiLive2DInternalModel>()
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
@@ -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',
@@ -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')
}
+181
View File
@@ -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<FileSystemDirectoryHandle | FileSystemFileHandle>
}
}
export class OPFSCache {
static async readDirectoryRecursive(dir: FileSystemDirectoryHandle, pathPrefix: string): Promise<File[]> {
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<FileSystemDirectoryHandle> {
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<void> {
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<File[] | null> {
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<void> {
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<void>[] = []
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<OPFSContext> = 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<OPFSContext> = 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()
}
}