fix(stage-ui-live2d): cache Live2D zips as original directories (#1934)
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function blobFromBytes(data: Uint8Array): Blob {
|
||||
const buffer = new ArrayBuffer(data.byteLength)
|
||||
new Uint8Array(buffer).set(data)
|
||||
return new Blob([buffer])
|
||||
}
|
||||
|
||||
function fileWithRelativePath(content: Blob | string | Uint8Array, name: string, webkitRelativePath: string): File {
|
||||
const fileContent = content instanceof Uint8Array ? blobFromBytes(content) : content
|
||||
const file = new File([fileContent], name)
|
||||
Object.defineProperty(file, 'webkitRelativePath', {
|
||||
value: webkitRelativePath,
|
||||
})
|
||||
return file
|
||||
}
|
||||
|
||||
class TestFileReader {
|
||||
result: string | null = null
|
||||
onload: (() => void) | null = null
|
||||
onerror: ((error: unknown) => void) | null = null
|
||||
|
||||
readAsText(file: File): void {
|
||||
void file.text()
|
||||
.then((text) => {
|
||||
this.result = text
|
||||
this.onload?.()
|
||||
})
|
||||
.catch(error => this.onerror?.(error))
|
||||
}
|
||||
}
|
||||
|
||||
function createShisihangshiSettingsText(): string {
|
||||
return JSON.stringify({
|
||||
Version: 3,
|
||||
FileReferences: {
|
||||
Moc: '302301_shisihangshi.moc3',
|
||||
Textures: ['textures/302301_shisihangshi_00.png'],
|
||||
Physics: null,
|
||||
Motions: {
|
||||
'': [{ File: 'motions/t_idle.motion3.json' }],
|
||||
},
|
||||
},
|
||||
Groups: [],
|
||||
})
|
||||
}
|
||||
|
||||
describe('live2d zip loader settings sanitization', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', { Live2DCubismCore: {} })
|
||||
vi.stubGlobal('FileReader', TestFileReader)
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('loads a zip model when model3.json contains Physics: null', async () => {
|
||||
await import('./live2d-zip-loader')
|
||||
const { ZipLoader } = await import('pixi-live2d-display/cubism4')
|
||||
|
||||
const zip = new JSZip()
|
||||
zip.file('302301_shisihangshi/302301_shisihangshi.model3.json', createShisihangshiSettingsText())
|
||||
zip.file('302301_shisihangshi/302301_shisihangshi.moc3', new Uint8Array([77, 79, 67, 51]))
|
||||
zip.file('302301_shisihangshi/textures/302301_shisihangshi_00.png', new Uint8Array([1, 2, 3]))
|
||||
zip.file('302301_shisihangshi/motions/t_idle.motion3.json', '{}')
|
||||
|
||||
const zipBytes = await zip.generateAsync({ type: 'uint8array' })
|
||||
const reader = await JSZip.loadAsync(await blobFromBytes(zipBytes).arrayBuffer())
|
||||
const settings = await ZipLoader.createSettings(reader)
|
||||
const files = await ZipLoader.unzip(reader, settings)
|
||||
|
||||
expect(settings.physics).toBeUndefined()
|
||||
expect(files.map(file => file.webkitRelativePath).sort()).toEqual([
|
||||
'302301_shisihangshi/302301_shisihangshi.moc3',
|
||||
'302301_shisihangshi/motions/t_idle.motion3.json',
|
||||
'302301_shisihangshi/textures/302301_shisihangshi_00.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('loads an OPFS-restored file directory when model3.json contains Physics: null', async () => {
|
||||
await import('./live2d-zip-loader')
|
||||
const { FileLoader } = await import('pixi-live2d-display/cubism4')
|
||||
|
||||
const files = [
|
||||
fileWithRelativePath(
|
||||
createShisihangshiSettingsText(),
|
||||
'302301_shisihangshi.model3.json',
|
||||
'302301_shisihangshi/302301_shisihangshi.model3.json',
|
||||
),
|
||||
fileWithRelativePath(
|
||||
new Uint8Array([77, 79, 67, 51]),
|
||||
'302301_shisihangshi.moc3',
|
||||
'302301_shisihangshi/302301_shisihangshi.moc3',
|
||||
),
|
||||
fileWithRelativePath(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
'302301_shisihangshi_00.png',
|
||||
'302301_shisihangshi/textures/302301_shisihangshi_00.png',
|
||||
),
|
||||
fileWithRelativePath(
|
||||
'{}',
|
||||
't_idle.motion3.json',
|
||||
'302301_shisihangshi/motions/t_idle.motion3.json',
|
||||
),
|
||||
]
|
||||
|
||||
const settings = await FileLoader.createSettings(files)
|
||||
|
||||
expect(settings.physics).toBeUndefined()
|
||||
expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import type { ModelSettings } from 'pixi-live2d-display/cubism4'
|
||||
|
||||
import JSZip from 'jszip'
|
||||
|
||||
import { Cubism4ModelSettings, ZipLoader } from 'pixi-live2d-display/cubism4'
|
||||
import { Cubism4ModelSettings, FileLoader, ZipLoader } from 'pixi-live2d-display/cubism4'
|
||||
|
||||
ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data)
|
||||
|
||||
@@ -55,6 +55,32 @@ ZipLoader.createSettings = async (reader: JSZip) => {
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes Live2D model settings JSON before upstream path resolution.
|
||||
*
|
||||
* Before:
|
||||
* - `{ "FileReferences": { "Physics": null } }`
|
||||
*
|
||||
* After:
|
||||
* - `{ "FileReferences": {} }`
|
||||
*/
|
||||
function sanitizeModelSettingsText(text: string): string {
|
||||
const json = JSON.parse(text) as Record<string, unknown>
|
||||
const refs = json.FileReferences
|
||||
|
||||
if (refs && typeof refs === 'object') {
|
||||
const fileReferences = refs as Record<string, unknown>
|
||||
if (fileReferences.Physics === null)
|
||||
delete fileReferences.Physics
|
||||
if (fileReferences.Pose === null)
|
||||
delete fileReferences.Pose
|
||||
if (fileReferences.DisplayInfo === null)
|
||||
delete fileReferences.DisplayInfo
|
||||
}
|
||||
|
||||
return JSON.stringify(json)
|
||||
}
|
||||
|
||||
export function isSettingsFile(file: string) {
|
||||
return file.endsWith('.model3.json') || file.endsWith('.model.json')
|
||||
}
|
||||
@@ -115,14 +141,24 @@ function createFakeSettings(files: string[]): ModelSettings {
|
||||
return settings
|
||||
}
|
||||
|
||||
ZipLoader.readText = (jsZip: JSZip, path: string) => {
|
||||
ZipLoader.readText = async (jsZip: JSZip, path: string) => {
|
||||
const file = jsZip.file(path)
|
||||
|
||||
if (!file) {
|
||||
throw new Error(`Cannot find file: ${path}`)
|
||||
}
|
||||
|
||||
return file.async('text')
|
||||
const text = await file.async('text')
|
||||
|
||||
return isSettingsFile(path) ? sanitizeModelSettingsText(text) : text
|
||||
}
|
||||
|
||||
const defaultFileLoaderReadText = FileLoader.readText
|
||||
FileLoader.readText = async (file: File) => {
|
||||
const text = await defaultFileLoaderReadText(file)
|
||||
const path = file.webkitRelativePath || file.name
|
||||
|
||||
return isSettingsFile(path) ? sanitizeModelSettingsText(text) : text
|
||||
}
|
||||
|
||||
ZipLoader.getFilePaths = (jsZip: JSZip) => {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { OPFSCache } from './opfs-loader'
|
||||
|
||||
class MemoryFileHandle {
|
||||
kind = 'file' as const
|
||||
private content: Blob = new Blob()
|
||||
|
||||
constructor(public readonly name: string) {}
|
||||
|
||||
async getFile(): Promise<File> {
|
||||
return new File([this.content], this.name)
|
||||
}
|
||||
|
||||
async createWritable(): Promise<{ write: (content: Blob | string) => Promise<void>, close: () => Promise<void> }> {
|
||||
return {
|
||||
write: async (content: Blob | string) => {
|
||||
this.content = typeof content === 'string'
|
||||
? new Blob([content])
|
||||
: content
|
||||
},
|
||||
close: async () => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MemoryHandle = MemoryDirectoryHandle | MemoryFileHandle
|
||||
|
||||
class MemoryDirectoryHandle {
|
||||
kind = 'directory' as const
|
||||
private entries = new Map<string, MemoryHandle>()
|
||||
|
||||
constructor(public readonly name: string) {}
|
||||
|
||||
async getDirectoryHandle(name: string, options: { create?: boolean } = {}): Promise<MemoryDirectoryHandle> {
|
||||
const entry = this.entries.get(name)
|
||||
if (entry instanceof MemoryDirectoryHandle)
|
||||
return entry
|
||||
if (entry)
|
||||
throw new Error(`File exists at directory path: ${name}`)
|
||||
if (!options.create)
|
||||
throw new Error(`Directory not found: ${name}`)
|
||||
|
||||
const dir = new MemoryDirectoryHandle(name)
|
||||
this.entries.set(name, dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async getFileHandle(name: string, options: { create?: boolean } = {}): Promise<MemoryFileHandle> {
|
||||
const entry = this.entries.get(name)
|
||||
if (entry instanceof MemoryFileHandle)
|
||||
return entry
|
||||
if (entry)
|
||||
throw new Error(`Directory exists at file path: ${name}`)
|
||||
if (!options.create)
|
||||
throw new Error(`File not found: ${name}`)
|
||||
|
||||
const file = new MemoryFileHandle(name)
|
||||
this.entries.set(name, file)
|
||||
return file
|
||||
}
|
||||
|
||||
async removeEntry(name: string): Promise<void> {
|
||||
if (!this.entries.delete(name))
|
||||
throw new Error(`Entry not found: ${name}`)
|
||||
}
|
||||
|
||||
values(): IterableIterator<MemoryHandle> {
|
||||
return this.entries.values()
|
||||
}
|
||||
}
|
||||
|
||||
function blobFromBytes(data: Uint8Array): Blob {
|
||||
const buffer = new ArrayBuffer(data.byteLength)
|
||||
new Uint8Array(buffer).set(data)
|
||||
return new Blob([buffer])
|
||||
}
|
||||
|
||||
async function createZip(entries: Record<string, Blob | string | Uint8Array>): Promise<Blob> {
|
||||
const zip = new JSZip()
|
||||
|
||||
for (const [path, content] of Object.entries(entries)) {
|
||||
zip.file(path, content)
|
||||
}
|
||||
|
||||
const data = await zip.generateAsync({ type: 'uint8array' })
|
||||
return new Blob([await blobFromBytes(data).arrayBuffer()], { type: 'application/zip' })
|
||||
}
|
||||
|
||||
function installMemoryOPFS(root = new MemoryDirectoryHandle('root')): MemoryDirectoryHandle {
|
||||
vi.stubGlobal('navigator', {
|
||||
storage: {
|
||||
getDirectory: vi.fn(async () => root as unknown as FileSystemDirectoryHandle),
|
||||
},
|
||||
})
|
||||
return root
|
||||
}
|
||||
|
||||
async function writeLegacyCache(root: MemoryDirectoryHandle, key: string): Promise<void> {
|
||||
const dir = await root.getDirectoryHandle(key, { create: true })
|
||||
await OPFSCache.writeFile(
|
||||
dir as unknown as FileSystemDirectoryHandle,
|
||||
'model.model3.json',
|
||||
JSON.stringify({ Version: 3 }),
|
||||
)
|
||||
}
|
||||
|
||||
function filePaths(files: File[]): string[] {
|
||||
return files.map(file => file.webkitRelativePath).sort()
|
||||
}
|
||||
|
||||
describe('opfs cache full directory persistence', () => {
|
||||
let root: MemoryDirectoryHandle
|
||||
|
||||
beforeEach(() => {
|
||||
root = installMemoryOPFS()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('saves every zip entry and restores webkitRelativePath from the physical OPFS directory', async () => {
|
||||
const zipBlob = await createZip({
|
||||
'model.model3.json': JSON.stringify({
|
||||
Version: 3,
|
||||
FileReferences: { Moc: 'model.moc3', Textures: ['textures/texture_00.png'] },
|
||||
}),
|
||||
'model.moc3': new Uint8Array([77, 79, 67, 51]),
|
||||
'textures/texture_00.png': new Uint8Array([1, 2, 3]),
|
||||
'extra/readme.txt': 'kept from original archive',
|
||||
})
|
||||
|
||||
await OPFSCache.save('live2d-model', zipBlob, 'blob:first')
|
||||
|
||||
const files = await OPFSCache.get('live2d-model', 'blob:second')
|
||||
|
||||
expect(files).not.toBeNull()
|
||||
expect(filePaths(files ?? [])).toEqual([
|
||||
'extra/readme.txt',
|
||||
'model.moc3',
|
||||
'model.model3.json',
|
||||
'textures/texture_00.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the original model3.json text without reconstructing or double-encoding paths', async () => {
|
||||
const encodedMoc = encodeURI('八千代辉夜姬.moc3')
|
||||
const settingsText = JSON.stringify({
|
||||
Version: 3,
|
||||
FileReferences: {
|
||||
Moc: encodedMoc,
|
||||
Textures: ['textures/texture_00.png'],
|
||||
},
|
||||
})
|
||||
const zipBlob = await createZip({
|
||||
'model.model3.json': settingsText,
|
||||
[encodedMoc]: new Uint8Array([77, 79, 67, 51]),
|
||||
'textures/texture_00.png': new Uint8Array([1, 2, 3]),
|
||||
})
|
||||
|
||||
await OPFSCache.save('encoded-model', zipBlob, 'blob:first')
|
||||
const files = await OPFSCache.get('encoded-model', 'blob:second')
|
||||
const settingsFile = files?.find(file => file.webkitRelativePath === 'model.model3.json')
|
||||
|
||||
expect(settingsFile).toBeDefined()
|
||||
expect(await settingsFile?.text()).toBe(settingsText)
|
||||
expect(await settingsFile?.text()).not.toContain('%25E5')
|
||||
})
|
||||
|
||||
it('invalidates caches that were written before the full-directory schema version', async () => {
|
||||
await writeLegacyCache(root, 'legacy-model')
|
||||
|
||||
const files = await OPFSCache.get('legacy-model', 'blob:current')
|
||||
|
||||
expect(files).toBeNull()
|
||||
await expect(root.getDirectoryHandle('legacy-model', { create: false })).rejects.toThrow('Directory not found')
|
||||
})
|
||||
|
||||
it('invalidates non-blob URL cache entries when the source URL changes', async () => {
|
||||
const zipBlob = await createZip({
|
||||
'model.model3.json': JSON.stringify({
|
||||
Version: 3,
|
||||
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
|
||||
}),
|
||||
'model.moc3': new Uint8Array([77, 79, 67, 51]),
|
||||
'texture.png': new Uint8Array([1, 2, 3]),
|
||||
})
|
||||
|
||||
await OPFSCache.save('remote-model', zipBlob, 'https://example.test/a.zip')
|
||||
const files = await OPFSCache.get('remote-model', 'https://example.test/b.zip')
|
||||
|
||||
expect(files).toBeNull()
|
||||
await expect(root.getDirectoryHandle('remote-model', { create: false })).rejects.toThrow('Directory not found')
|
||||
})
|
||||
|
||||
it('does not invalidate blob URL cache entries when the stable model key matches', async () => {
|
||||
const zipBlob = await createZip({
|
||||
'model.model3.json': JSON.stringify({
|
||||
Version: 3,
|
||||
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
|
||||
}),
|
||||
'model.moc3': new Uint8Array([77, 79, 67, 51]),
|
||||
'texture.png': new Uint8Array([1, 2, 3]),
|
||||
})
|
||||
|
||||
await OPFSCache.save('blob-model', zipBlob, 'blob:first')
|
||||
const files = await OPFSCache.get('blob-model', 'blob:second')
|
||||
|
||||
expect(files).not.toBeNull()
|
||||
expect(filePaths(files ?? [])).toEqual([
|
||||
'model.moc3',
|
||||
'model.model3.json',
|
||||
'texture.png',
|
||||
])
|
||||
})
|
||||
|
||||
it('caches the original fetched zip blob from middleware instead of the ZipLoader output list', async () => {
|
||||
const zipBlob = await createZip({
|
||||
'model.model3.json': JSON.stringify({
|
||||
Version: 3,
|
||||
FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] },
|
||||
}),
|
||||
'model.moc3': new Uint8Array([77, 79, 67, 51]),
|
||||
'texture.png': new Uint8Array([1, 2, 3]),
|
||||
'not-defined-by-settings.txt': 'still cached',
|
||||
})
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({
|
||||
blob: async () => zipBlob,
|
||||
})))
|
||||
const context = {
|
||||
source: { id: 'middleware-model', url: 'blob:source' },
|
||||
} as Parameters<typeof OPFSCache.checkMiddleware>[0]
|
||||
const checkNext = vi.fn(async () => {})
|
||||
|
||||
await OPFSCache.checkMiddleware(context, checkNext)
|
||||
context.source = []
|
||||
await OPFSCache.saveMiddleware(context, vi.fn(async () => {}))
|
||||
|
||||
const files = await OPFSCache.get('middleware-model', 'blob:next')
|
||||
|
||||
expect(checkNext).toHaveBeenCalledTimes(1)
|
||||
expect(files).not.toBeNull()
|
||||
expect(filePaths(files ?? [])).toEqual([
|
||||
'model.moc3',
|
||||
'model.model3.json',
|
||||
'not-defined-by-settings.txt',
|
||||
'texture.png',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,29 @@
|
||||
import type { Live2DFactoryContext, Middleware, ModelSettings } from 'pixi-live2d-display/cubism4'
|
||||
import type { Live2DFactoryContext, Middleware } from 'pixi-live2d-display/cubism4'
|
||||
|
||||
import JSZip from 'jszip'
|
||||
|
||||
interface OPFSContext extends Live2DFactoryContext {
|
||||
opfsKey?: string
|
||||
opfsUrl?: string
|
||||
opfsZipBlob?: Blob
|
||||
}
|
||||
|
||||
interface OPFSCacheMeta {
|
||||
sourceUrl?: string
|
||||
version?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache schema version for OPFS-stored Live2D zip directories.
|
||||
*
|
||||
* Increment when the persisted directory shape changes.
|
||||
*/
|
||||
const live2DOpfsCacheVersion = 2
|
||||
|
||||
function blobFromBytes(data: Uint8Array): Blob {
|
||||
const buffer = new ArrayBuffer(data.byteLength)
|
||||
new Uint8Array(buffer).set(data)
|
||||
return new Blob([buffer])
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -59,6 +80,18 @@ export class OPFSCache {
|
||||
return currentDir
|
||||
}
|
||||
|
||||
private static async clearDirectory(dirHandle: FileSystemDirectoryHandle): Promise<void> {
|
||||
const entryNames: string[] = []
|
||||
|
||||
// OPFS writes mirror the source zip exactly, so stale files from a previous
|
||||
// failed or superseded save must be removed before writing fresh entries.
|
||||
for await (const entry of dirHandle.values()) {
|
||||
entryNames.push(entry.name)
|
||||
}
|
||||
|
||||
await Promise.all(entryNames.map(name => dirHandle.removeEntry(name, { recursive: true })))
|
||||
}
|
||||
|
||||
static async writeFile(root: FileSystemDirectoryHandle, filePath: string, content: Blob | string): Promise<void> {
|
||||
const parts = filePath.split('/')
|
||||
const fileName = parts.pop()!
|
||||
@@ -76,7 +109,7 @@ export class OPFSCache {
|
||||
const metaHandle = await dirHandle.getFileHandle('__meta.json', { create: false })
|
||||
const metaFile = await metaHandle.getFile()
|
||||
const metaText = await metaFile.text()
|
||||
return JSON.parse(metaText) as { sourceUrl?: string }
|
||||
return JSON.parse(metaText) as OPFSCacheMeta
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
@@ -91,7 +124,20 @@ export class OPFSCache {
|
||||
console.debug(`[OPFS] Cache hit for ${key}`)
|
||||
|
||||
const meta = await OPFSCache.readMeta(dirHandle)
|
||||
if (meta?.sourceUrl && meta.sourceUrl !== sourceUrl) {
|
||||
if (meta?.version !== live2DOpfsCacheVersion) {
|
||||
// NOTICE: Rebuild caches created before OPFS stored the full zip directory.
|
||||
// Older caches may contain a reconstructed model3.json instead of the
|
||||
// original archive settings file.
|
||||
// Source/context: OPFSCache.saveMiddleware settings reconstruction.
|
||||
// Removal condition: old OPFS caches no longer need migration support.
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[OPFS] Cache mismatch for ${key}, schema version changed`)
|
||||
await root.removeEntry(dirHandle.name, { recursive: true })
|
||||
return null
|
||||
}
|
||||
|
||||
const shouldValidateSourceUrl = !sourceUrl.startsWith('blob:')
|
||||
if (shouldValidateSourceUrl && meta.sourceUrl && meta.sourceUrl !== sourceUrl) {
|
||||
// NOTICE: Skip cache when the requested URL changes while the key stays the same.
|
||||
// This avoids serving a stale model when ids are reused or props are out of sync.
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -112,25 +158,42 @@ export class OPFSCache {
|
||||
return null
|
||||
}
|
||||
|
||||
static async save(key: string, files: File[], sourceUrl?: string): Promise<void> {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[OPFS] Saving ${files.length} files to ${key}`)
|
||||
|
||||
/**
|
||||
* Persists every non-directory entry from a Live2D zip into OPFS.
|
||||
*
|
||||
* Use when:
|
||||
* - Caching a loaded Live2D zip for later FileLoader replay
|
||||
* - Preserving the original model3.json and archive paths exactly
|
||||
*
|
||||
* Expects:
|
||||
* - `zipBlob` is the original archive blob fetched by checkMiddleware
|
||||
* - ZIP entry paths are already the physical paths to persist
|
||||
*
|
||||
* Returns:
|
||||
* - A completed OPFS directory write, or logs and returns on cache write failure
|
||||
*/
|
||||
static async save(key: string, zipBlob: Blob, sourceUrl?: string): Promise<void> {
|
||||
try {
|
||||
const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer())
|
||||
const fileEntries = Object.values(zip.files).filter(file => !file.dir)
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[OPFS] Saving ${fileEntries.length} zip entries to ${key}`)
|
||||
|
||||
const root = await navigator.storage.getDirectory()
|
||||
const dirHandle = await root.getDirectoryHandle(key, { create: true })
|
||||
await OPFSCache.clearDirectory(dirHandle)
|
||||
|
||||
const writePromises: Promise<void>[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const relativePath = file.webkitRelativePath || file.name
|
||||
writePromises.push(OPFSCache.writeFile(dirHandle, relativePath, file))
|
||||
}
|
||||
const writePromises = fileEntries.map(async (file) => {
|
||||
const data = await file.async('uint8array')
|
||||
return OPFSCache.writeFile(dirHandle, file.name, blobFromBytes(data))
|
||||
})
|
||||
|
||||
await Promise.all(writePromises)
|
||||
if (sourceUrl) {
|
||||
await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({ sourceUrl }))
|
||||
}
|
||||
await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({
|
||||
sourceUrl,
|
||||
version: live2DOpfsCacheVersion,
|
||||
}))
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug(`[OPFS] Saved to cache`)
|
||||
}
|
||||
@@ -183,6 +246,7 @@ export class OPFSCache {
|
||||
const res = await fetch(blobUrl)
|
||||
const blob = await res.blob()
|
||||
const fileName = `${key}.zip`
|
||||
context.opfsZipBlob = blob
|
||||
context.source = [new File([blob], fileName)]
|
||||
}
|
||||
catch (e) {
|
||||
@@ -195,76 +259,12 @@ export class OPFSCache {
|
||||
|
||||
// Runs after ZipLoader to cache the files
|
||||
static saveMiddleware: Middleware<OPFSContext> = async (context, next) => {
|
||||
if (!context.opfsKey || !Array.isArray(context.source)) {
|
||||
if (!context.opfsKey || !context.opfsZipBlob) {
|
||||
return next()
|
||||
}
|
||||
|
||||
const files = context.source as File[]
|
||||
|
||||
if (files.length === 0 || !(files[0] instanceof File)) {
|
||||
return next()
|
||||
}
|
||||
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('[OPFS] Reconstructing settings file...')
|
||||
const settingsText = encodeModelSettings(settings.json)
|
||||
const settingsFilePath = settings.url || 'model.model3.json'
|
||||
const settingsFile = new File([settingsText], settingsFilePath)
|
||||
Object.defineProperty(settingsFile, 'webkitRelativePath', {
|
||||
value: encodeURI(settingsFilePath),
|
||||
})
|
||||
files.push(settingsFile)
|
||||
}
|
||||
delete (context.source as any).settings // force the loader to read re-created settings file
|
||||
}
|
||||
await OPFSCache.save(context.opfsKey, files, context.opfsUrl)
|
||||
await OPFSCache.save(context.opfsKey, context.opfsZipBlob, context.opfsUrl)
|
||||
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
function encodeProperty(obj: any, path: string) {
|
||||
let cursor = obj
|
||||
const propPath = path.split('.')
|
||||
// will lose reference when access to the last level
|
||||
while (propPath.length > 1 && cursor != null && typeof cursor === 'object' && propPath[0] in cursor) {
|
||||
cursor = cursor[propPath.shift()!]
|
||||
}
|
||||
if (cursor == null || cursor[propPath[0]] == null)
|
||||
return
|
||||
if (typeof cursor[propPath[0]] === 'string')
|
||||
cursor[propPath[0]] = encodeURI(cursor[propPath[0]])
|
||||
if (Array.isArray(cursor[propPath[0]]) && typeof cursor[propPath[0]][0] === 'string') {
|
||||
cursor[propPath[0]] = cursor[propPath[0]].map((s: string) => encodeURI(s))
|
||||
}
|
||||
}
|
||||
// TODO: find all file paths and encode them by recursively visiting the settings
|
||||
function encodeModelSettings(input: any): string {
|
||||
const settings = JSON.parse(JSON.stringify(input))
|
||||
const propertyToEncode = [
|
||||
'FileReferences.DisplayInfo',
|
||||
'FileReferences.Moc',
|
||||
'FileReferences.Textures',
|
||||
'FileReferences.Physics',
|
||||
'url',
|
||||
]
|
||||
propertyToEncode.forEach(k => encodeProperty(settings, k))
|
||||
settings?.FileReferences?.Expressions?.map((exp: { Name: string, File: string }) => {
|
||||
exp.File = encodeURI(exp.File)
|
||||
return exp
|
||||
})
|
||||
Object.keys(settings?.FileReferences?.Motions ?? {}).forEach((k) => {
|
||||
if (!Array.isArray(settings?.FileReferences?.Motions[k]))
|
||||
return // not sure whether 'Motions' is of type Record<string,[]>, assume it is for now.
|
||||
settings?.FileReferences?.Motions[k].map((exp: { File: string }) => {
|
||||
exp.File = encodeURI(exp.File)
|
||||
return exp
|
||||
})
|
||||
})
|
||||
return JSON.stringify(settings)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user