From ed664e8107115fe5ff05735a976b4e110a303a38 Mon Sep 17 00:00:00 2001 From: Neko Date: Sat, 11 Jul 2026 23:44:41 +0800 Subject: [PATCH] fix(stage-ui-live2d): normalize Live2D model file references (#2047) Authored-by-agent: Codex --- .../src/utils/live2d-zip-loader.test.ts | 96 +++++++++++++++++++ .../src/utils/live2d-zip-loader.ts | 17 +++- 2 files changed, 112 insertions(+), 1 deletion(-) 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 c925f26a6..7fc811bad 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,19 @@ function createShisihangshiSettingsText(): string { }) } +function createNonAsciiSettingsText(): string { + return JSON.stringify({ + Version: 3, + FileReferences: { + Moc: '模型文件.moc3', + Textures: ['模型贴图.4096/texture_00.png'], + Physics: '模型文件.physics3.json', + DisplayInfo: '模型文件.cdi3.json', + }, + Groups: [], + }) +} + 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', () => { @@ -137,6 +150,89 @@ describe('live2d zip loader settings sanitization', () => { expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow() }) + it('loads an OPFS-restored file directory when model3.json references non-ASCII file names', async () => { + await import('./live2d-zip-loader') + const { FileLoader } = await import('pixi-live2d-display/cubism4') + + const files = [ + fileWithRelativePath( + createNonAsciiSettingsText(), + '模型文件.model3.json', + '非ASCII模型26045/模型文件.model3.json', + ), + fileWithRelativePath( + new Uint8Array([77, 79, 67, 51]), + '模型文件.moc3', + '非ASCII模型26045/模型文件.moc3', + ), + fileWithRelativePath( + new Uint8Array([1, 2, 3]), + 'texture_00.png', + '非ASCII模型26045/模型贴图.4096/texture_00.png', + ), + fileWithRelativePath( + '{}', + '模型文件.physics3.json', + '非ASCII模型26045/模型文件.physics3.json', + ), + fileWithRelativePath( + '{}', + '模型文件.cdi3.json', + '非ASCII模型26045/模型文件.cdi3.json', + ), + ] + + const settings = await FileLoader.createSettings(files) + + // ROOT CAUSE: + // + // pixi-live2d-display validates OPFS-restored File objects against + // encodeURI(file.webkitRelativePath), but the settings created from the + // original model3.json currently keep non-ASCII file references unencoded. + // + // Before the fix, the settings expect "模型文件.moc3" while the available + // file list contains URI-encoded non-ASCII directory paths, so validation + // reports that the moc3 file does not exist. + expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow() + }) + + it('does not double encode existing URI-encoded model3.json file references', async () => { + await import('./live2d-zip-loader') + const { FileLoader } = await import('pixi-live2d-display/cubism4') + + const files = [ + fileWithRelativePath( + JSON.stringify({ + Version: 3, + FileReferences: { + Moc: '%E6%A8%A1%E5%9E%8B%E6%96%87%E4%BB%B6.moc3', + Textures: ['%E6%A8%A1%E5%9E%8B%E8%B4%B4%E5%9B%BE.4096/texture_00.png'], + }, + Groups: [], + }), + '模型文件.model3.json', + '非ASCII模型26045/模型文件.model3.json', + ), + fileWithRelativePath( + new Uint8Array([77, 79, 67, 51]), + '模型文件.moc3', + '非ASCII模型26045/模型文件.moc3', + ), + fileWithRelativePath( + new Uint8Array([1, 2, 3]), + 'texture_00.png', + '非ASCII模型26045/模型贴图.4096/texture_00.png', + ), + ] + + const settings = await FileLoader.createSettings(files) + + expect(settings.moc).toBe('%E6%A8%A1%E5%9E%8B%E6%96%87%E4%BB%B6.moc3') + expect(settings.textures).toEqual(['%E6%A8%A1%E5%9E%8B%E8%B4%B4%E5%9B%BE.4096/texture_00.png']) + expect(settings.moc).not.toContain('%25E6') + 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') 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 d79cbef5c..384ae62ec 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts @@ -109,7 +109,22 @@ function createModelSettings(text: string, url: string): ModelSettings { throw new Error('Unknown settings JSON') } - return runtime.createModelSettings(settingsJSON) + // pixi-live2d-display validates files with encodeURI(file.webkitRelativePath). + // Decode first so archives that already store URI-encoded references do not + // get `%` encoded again. + const settings = runtime.createModelSettings(settingsJSON) + const normalizeFileReference = (file: string) => { + try { + return encodeURI(decodeURI(file)) + } + catch { + return encodeURI(file) + } + } + settings.url = normalizeFileReference(settings.url) + settings.replaceFiles(normalizeFileReference) + + return settings } export function isSettingsFile(file: string) {