From cc8d6287e1f1882c7f13c002ba9c2ac5e2937a48 Mon Sep 17 00:00:00 2001 From: Nashchennc <59103980+NashChennc@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:19:26 +0800 Subject: [PATCH] fix(stage-ui-live2d): cache Live2D zips as original directories (#1934) --- .../src/utils/live2d-zip-loader.test.ts | 116 ++++++++ .../src/utils/live2d-zip-loader.ts | 42 ++- .../src/utils/opfs-loader.test.ts | 253 ++++++++++++++++++ .../stage-ui-live2d/src/utils/opfs-loader.ts | 164 ++++++------ 4 files changed, 490 insertions(+), 85 deletions(-) create mode 100644 packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts create mode 100644 packages/stage-ui-live2d/src/utils/opfs-loader.test.ts diff --git a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts new file mode 100644 index 000000000..e720d3498 --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts @@ -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() + }) +}) diff --git a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts index 101ca09e0..b92df7b57 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts @@ -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 + const refs = json.FileReferences + + if (refs && typeof refs === 'object') { + const fileReferences = refs as Record + 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) => { diff --git a/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts b/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts new file mode 100644 index 000000000..f4506c38f --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts @@ -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 { + return new File([this.content], this.name) + } + + async createWritable(): Promise<{ write: (content: Blob | string) => Promise, close: () => Promise }> { + 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() + + constructor(public readonly name: string) {} + + async getDirectoryHandle(name: string, options: { create?: boolean } = {}): Promise { + 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 { + 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 { + if (!this.entries.delete(name)) + throw new Error(`Entry not found: ${name}`) + } + + values(): IterableIterator { + 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): Promise { + 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 { + 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[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', + ]) + }) +}) diff --git a/packages/stage-ui-live2d/src/utils/opfs-loader.ts b/packages/stage-ui-live2d/src/utils/opfs-loader.ts index b30b7cc3a..31c832859 100644 --- a/packages/stage-ui-live2d/src/utils/opfs-loader.ts +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.ts @@ -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 { + 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 { 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 { - // 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 { 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[] = [] - - 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 = 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, assume it is for now. - settings?.FileReferences?.Motions[k].map((exp: { File: string }) => { - exp.File = encodeURI(exp.File) - return exp - }) - }) - return JSON.stringify(settings) -}