mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
fix(stage-ui-live2d): ignore macOS files (#1991)
This commit is contained in:
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user