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 index e720d3498..c925f26a6 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts @@ -47,6 +47,8 @@ function createShisihangshiSettingsText(): string { }) } +const appleDoubleHeader = new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0, 77, 97, 99, 32, 79, 83, 32, 88]) + describe('live2d zip loader settings sanitization', () => { beforeEach(() => { vi.stubGlobal('window', { Live2DCubismCore: {} }) @@ -81,6 +83,27 @@ describe('live2d zip loader settings sanitization', () => { ]) }) + it('loads a zip model when a macOS AppleDouble settings sidecar is present before the real settings file', async () => { + await import('./live2d-zip-loader') + const { ZipLoader } = await import('pixi-live2d-display/cubism4') + + const zip = new JSZip() + zip.file('__MACOSX/302301_shisihangshi/._302301_shisihangshi.model3.json', appleDoubleHeader) + 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 filePaths = await ZipLoader.getFilePaths(reader) + + expect(settings.url).toBe('302301_shisihangshi/302301_shisihangshi.model3.json') + expect(settings.physics).toBeUndefined() + expect(filePaths).not.toContain('__MACOSX/302301_shisihangshi/._302301_shisihangshi.model3.json') + }) + 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') @@ -113,4 +136,42 @@ describe('live2d zip loader settings sanitization', () => { expect(settings.physics).toBeUndefined() expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow() }) + + it('loads an OPFS-restored file directory when a macOS AppleDouble settings sidecar is present before the real settings file', async () => { + await import('./live2d-zip-loader') + const { FileLoader } = await import('pixi-live2d-display/cubism4') + + const files = [ + fileWithRelativePath( + appleDoubleHeader, + '._302301_shisihangshi.model3.json', + '__MACOSX/302301_shisihangshi/._302301_shisihangshi.model3.json', + ), + 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.url).toBe('302301_shisihangshi/302301_shisihangshi.model3.json') + expect(settings.physics).toBeUndefined() + }) }) 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 b92df7b57..d79cbef5c 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts @@ -1,19 +1,34 @@ -import type { ModelSettings } from 'pixi-live2d-display/cubism4' +import type { JSONObject, ModelSettings } from 'pixi-live2d-display/cubism4' import JSZip from 'jszip' -import { Cubism4ModelSettings, FileLoader, ZipLoader } from 'pixi-live2d-display/cubism4' +import { Cubism4ModelSettings, FileLoader, Live2DFactory, ZipLoader } from 'pixi-live2d-display/cubism4' ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data) -const defaultCreateSettings = ZipLoader.createSettings +interface IgnoredArchivePathSegmentRule { + matches: (segment: string) => boolean +} + +const ignoredArchivePathSegmentRules: IgnoredArchivePathSegmentRule[] = [ + { matches: segment => segment === '__MACOSX' }, + { matches: segment => segment.startsWith('._') }, +] + +function shouldIgnoreLive2DArchiveEntry(filePath: string): boolean { + return filePath + .split('/') + .some(segment => ignoredArchivePathSegmentRules.some(rule => rule.matches(segment))) +} + ZipLoader.createSettings = async (reader: JSZip) => { const filePaths = Object.keys(reader.files) const settings = await (async () => { - if (!filePaths.some(file => isSettingsFile(file))) { + const settingsFilePath = filePaths.find(file => isSettingsFile(file)) + if (!settingsFilePath) { return createFakeSettings(filePaths) } - return defaultCreateSettings(reader) + return createModelSettings(await ZipLoader.readText(reader, settingsFilePath), settingsFilePath) })() // Extract CDI data from the zip if available @@ -81,8 +96,26 @@ function sanitizeModelSettingsText(text: string): string { return JSON.stringify(json) } +function createModelSettings(text: string, url: string): ModelSettings { + if (!text) { + throw new Error(`Empty settings file: ${url}`) + } + + const settingsJSON = JSON.parse(text) as JSONObject & { url?: string } + settingsJSON.url = url + const runtime = Live2DFactory.findRuntime(settingsJSON) + + if (!runtime) { + throw new Error('Unknown settings JSON') + } + + return runtime.createModelSettings(settingsJSON) +} + export function isSettingsFile(file: string) { - return file.endsWith('.model3.json') || file.endsWith('.model.json') + return !shouldIgnoreLive2DArchiveEntry(file) + && !file.endsWith('items_pinned_to_model.json') + && (file.endsWith('.model3.json') || file.endsWith('.model.json')) } export function isMocFile(file: string) { @@ -154,6 +187,21 @@ ZipLoader.readText = async (jsZip: JSZip, path: string) => { } const defaultFileLoaderReadText = FileLoader.readText +FileLoader.createSettings = async (files: File[]) => { + const settingsFile = files.find(file => isSettingsFile(file.webkitRelativePath || file.name)) + + if (!settingsFile) { + throw new TypeError('Settings file not found') + } + + const settingsUrl = settingsFile.webkitRelativePath || settingsFile.name + const settingsText = await FileLoader.readText(settingsFile) + const settings = createModelSettings(settingsText, settingsUrl) + Object.assign(settings, { _objectURL: URL.createObjectURL(settingsFile) }) + + return settings +} + FileLoader.readText = async (file: File) => { const text = await defaultFileLoaderReadText(file) const path = file.webkitRelativePath || file.name @@ -165,7 +213,7 @@ ZipLoader.getFilePaths = (jsZip: JSZip) => { const paths: string[] = [] jsZip.forEach((relativePath, file) => { - if (!file.dir) { + if (!file.dir && !shouldIgnoreLive2DArchiveEntry(relativePath)) { paths.push(relativePath) } }) diff --git a/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts b/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts index f4506c38f..6d5a6d556 100644 --- a/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts @@ -124,6 +124,8 @@ describe('opfs cache full directory persistence', () => { it('saves every zip entry and restores webkitRelativePath from the physical OPFS directory', async () => { const zipBlob = await createZip({ + '__MACOSX/._model.model3.json': new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0]), + '._model.moc3': new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0]), 'model.model3.json': JSON.stringify({ Version: 3, FileReferences: { Moc: 'model.moc3', Textures: ['textures/texture_00.png'] }, @@ -146,6 +148,33 @@ describe('opfs cache full directory persistence', () => { ]) }) + it('does not restore ignored archive metadata that already exists in OPFS', async () => { + const dir = await root.getDirectoryHandle('metadata-model', { create: true }) + await OPFSCache.writeFile( + dir as unknown as FileSystemDirectoryHandle, + '__MACOSX/._model.model3.json', + new Blob([new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0])]), + ) + await OPFSCache.writeFile( + dir as unknown as FileSystemDirectoryHandle, + 'model.model3.json', + JSON.stringify({ + Version: 3, + FileReferences: { Moc: 'model.moc3', Textures: ['texture.png'] }, + }), + ) + await OPFSCache.writeFile( + dir as unknown as FileSystemDirectoryHandle, + '__meta.json', + JSON.stringify({ sourceUrl: 'blob:first', version: 2 }), + ) + + const files = await OPFSCache.get('metadata-model', 'blob:second') + + expect(files).not.toBeNull() + expect(filePaths(files ?? [])).toEqual(['model.model3.json']) + }) + it('keeps the original model3.json text without reconstructing or double-encoding paths', async () => { const encodedMoc = encodeURI('八千代辉夜姬.moc3') const settingsText = JSON.stringify({ @@ -181,6 +210,7 @@ describe('opfs cache full directory persistence', () => { it('invalidates non-blob URL cache entries when the source URL changes', async () => { const zipBlob = await createZip({ + '__MACOSX/._model.model3.json': new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0]), 'model.model3.json': JSON.stringify({ Version: 3, FileReferences: { Moc: 'model.moc3', Textures: ['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 31c832859..40084186d 100644 --- a/packages/stage-ui-live2d/src/utils/opfs-loader.ts +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.ts @@ -20,6 +20,21 @@ interface OPFSCacheMeta { */ const live2DOpfsCacheVersion = 2 +interface IgnoredArchivePathSegmentRule { + matches: (segment: string) => boolean +} + +const ignoredArchivePathSegmentRules: IgnoredArchivePathSegmentRule[] = [ + { matches: segment => segment === '__MACOSX' }, + { matches: segment => segment.startsWith('._') }, +] + +function shouldIgnoreLive2DArchiveEntry(filePath: string): boolean { + return filePath + .split('/') + .some(segment => ignoredArchivePathSegmentRules.some(rule => rule.matches(segment))) +} + function blobFromBytes(data: Uint8Array): Blob { const buffer = new ArrayBuffer(data.byteLength) new Uint8Array(buffer).set(data) @@ -51,11 +66,12 @@ export class OPFSCache { if (entry.kind === 'file') { const fileHandle = entry as FileSystemFileHandle const file = await fileHandle.getFile() - if (file.name === '__meta.json') + const relativePath = pathPrefix + file.name + if (file.name === '__meta.json' || shouldIgnoreLive2DArchiveEntry(relativePath)) continue // live2d-display expects this Object.defineProperty(file, 'webkitRelativePath', { - value: pathPrefix + file.name, + value: relativePath, }) files.push(file) } @@ -175,7 +191,8 @@ export class OPFSCache { 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) + const fileEntries = Object.values(zip.files) + .filter(file => !file.dir && !shouldIgnoreLive2DArchiveEntry(file.name)) // eslint-disable-next-line no-console console.debug(`[OPFS] Saving ${fileEntries.length} zip entries to ${key}`)