fix(stage-ui-live2d): ignore macOS files (#1991)

This commit is contained in:
Neko
2026-06-19 02:02:53 +08:00
committed by GitHub
parent cc8d6287e1
commit 7e3698147d
4 changed files with 166 additions and 10 deletions
@@ -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()
})
})
@@ -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)
}
})
@@ -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'] },
@@ -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<void> {
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}`)