implement opfs cache for zipped l2d models (#760)
This commit is contained in:
+8
-1
@@ -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')
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user