fix(stage-ui-live2d): cache Live2D zips as original directories (#1934)

This commit is contained in:
Nashchennc
2026-06-18 15:19:26 +08:00
committed by GitHub
parent 5bac31f60c
commit cc8d6287e1
4 changed files with 490 additions and 85 deletions
@@ -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)
}