From 5a56770f1c6077e26410e987014e92d0c9bc621f Mon Sep 17 00:00:00 2001 From: Neko Date: Tue, 4 Aug 2026 22:05:40 +0800 Subject: [PATCH] fix(stage-ui-live2d): support CJK paths in Live2D archives (#2219) --- .../src/utils/live2d-zip-loader.test.ts | 97 ++++++++++++++++++- patches/pixi-live2d-display.patch | 54 +++++++++++ pnpm-lock.yaml | 6 +- 3 files changed, 153 insertions(+), 4 deletions(-) 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..d0a9b89b1 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,17 @@ function createShisihangshiSettingsText(): string { }) } +function createCjkPathSettingsText(): string { + return JSON.stringify({ + Version: 3, + FileReferences: { + Moc: '测试角色.moc3', + Textures: ['中文纹理/texture_00.png'], + }, + 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', () => { @@ -83,6 +94,46 @@ describe('live2d zip loader settings sanitization', () => { ]) }) + it('loads a zip model whose settings and resources use CJK paths', async () => { + await import('./live2d-zip-loader') + const { FileLoader, Live2DModel, ZipLoader } = await import('pixi-live2d-display/cubism4') + + const zip = new JSZip() + zip.file('中文路径模型/测试角色.model3.json', createCjkPathSettingsText()) + zip.file('中文路径模型/测试角色.moc3', new Uint8Array([77, 79, 67, 51])) + zip.file('中文路径模型/中文纹理/texture_00.png', new Uint8Array([1, 2, 3])) + + const zipBytes = await zip.generateAsync({ type: 'uint8array' }) + const reader = await JSZip.loadAsync(await blobFromBytes(zipBytes).arrayBuffer()) + const settings = await ZipLoader.createSettings(reader) + const files = Object.assign(await ZipLoader.unzip(reader, settings), { settings }) + const objectUrl = `zip://test/${settings.url}` + Object.assign(settings, { _objectURL: objectUrl }) + + // ROOT CAUSE: + // + // FileLoader encoded each webkitRelativePath before comparing it with settings.resolveURL(). + // CJK names therefore became percent-encoded on only one side of the comparison. + // + // settings.resolveURL('测试角色.moc3') !== encodeURI(file.webkitRelativePath) + // + // The loader now keeps both sides as decoded archive paths, matching its unzip and upload stages. + const context = { + source: files, + options: {}, + live2dModel: new Live2DModel(), + } + + try { + await expect(FileLoader.factory(context, async () => {})).resolves.toBeUndefined() + } + finally { + for (const resourceUrl of Object.values(FileLoader.filesMap[objectUrl] ?? {})) + URL.revokeObjectURL(resourceUrl) + delete FileLoader.filesMap[objectUrl] + } + }) + 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') @@ -134,7 +185,51 @@ describe('live2d zip loader settings sanitization', () => { const settings = await FileLoader.createSettings(files) expect(settings.physics).toBeUndefined() - expect(() => settings.validateFiles(files.map(file => encodeURI(file.webkitRelativePath)))).not.toThrow() + expect(() => settings.validateFiles(files.map(file => file.webkitRelativePath))).not.toThrow() + }) + + it('loads an OPFS-restored file directory whose settings and resources use CJK paths', async () => { + await import('./live2d-zip-loader') + const { FileLoader, Live2DModel } = await import('pixi-live2d-display/cubism4') + + const files = [ + fileWithRelativePath( + createCjkPathSettingsText(), + '测试角色.model3.json', + '中文路径模型/测试角色.model3.json', + ), + fileWithRelativePath( + new Uint8Array([77, 79, 67, 51]), + '测试角色.moc3', + '中文路径模型/测试角色.moc3', + ), + fileWithRelativePath( + new Uint8Array([1, 2, 3]), + 'texture_00.png', + '中文路径模型/中文纹理/texture_00.png', + ), + ] + const existingObjectUrls = new Set(Object.keys(FileLoader.filesMap)) + const context = { + source: files, + options: {}, + live2dModel: new Live2DModel(), + } + + try { + await expect(FileLoader.factory(context, async () => {})).resolves.toBeUndefined() + } + finally { + for (const objectUrl of Object.keys(FileLoader.filesMap)) { + if (existingObjectUrls.has(objectUrl)) + continue + + for (const resourceUrl of Object.values(FileLoader.filesMap[objectUrl] ?? {})) + URL.revokeObjectURL(resourceUrl) + URL.revokeObjectURL(objectUrl) + delete FileLoader.filesMap[objectUrl] + } + } }) it('loads an OPFS-restored file directory when a macOS AppleDouble settings sidecar is present before the real settings file', async () => { diff --git a/patches/pixi-live2d-display.patch b/patches/pixi-live2d-display.patch index fb7e43e85..e3886442c 100644 --- a/patches/pixi-live2d-display.patch +++ b/patches/pixi-live2d-display.patch @@ -23,6 +23,15 @@ index 0d40d5d40533881d154e3c2cdda9b360c57eaaa6..8dbdb1b36e2da42da264dc3533523e61 if (!settingsFile) { throw new TypeError("Settings file not found"); } +@@ -1411,7 +1411,7 @@ FileLoader.factory = (context, next) => __async(void 0, null, function* () { + } else if (!settings._objectURL) { + throw new Error('"_objectURL" must be specified in ModelSettings'); + } +- settings.validateFiles(files.map((file) => encodeURI(file.webkitRelativePath))); ++ settings.validateFiles(files.map((file) => file.webkitRelativePath)); + yield _FileLoader.upload(files, settings); + settings.resolveURL = function(url2) { + return _FileLoader.resolveURL(this._objectURL, url2); @@ -1458,7 +1458,7 @@ const _ZipLoader = class { static createSettings(reader) { return __async(this, null, function* () { @@ -45,6 +54,15 @@ index c3db490f8dbfdd63ad40648fe0e0325604f22e95..4b452db0b109ba7deaddf397ef351ae7 if (!settingsFile) { throw new TypeError("Settings file not found"); } +@@ -1411,7 +1411,7 @@ var __async = (__this, __arguments, generator) => { + } else if (!settings._objectURL) { + throw new Error('"_objectURL" must be specified in ModelSettings'); + } +- settings.validateFiles(files.map((file) => encodeURI(file.webkitRelativePath))); ++ settings.validateFiles(files.map((file) => file.webkitRelativePath)); + yield _FileLoader.upload(files, settings); + settings.resolveURL = function(url) { + return _FileLoader.resolveURL(this._objectURL, url); @@ -1458,7 +1458,7 @@ var __async = (__this, __arguments, generator) => { static createSettings(reader) { return __async(this, null, function* () { @@ -67,6 +85,15 @@ index f21619f5794c00542bb2a9df340e50d4453a0367..38bd069a5d1ec37380c376c2ff2d7fed if (!settingsFile) { throw new TypeError("Settings file not found"); } +@@ -5050,7 +5050,7 @@ FileLoader.factory = (context, next) => __async(void 0, null, function* () { + } else if (!settings._objectURL) { + throw new Error('"_objectURL" must be specified in ModelSettings'); + } +- settings.validateFiles(files.map((file) => encodeURI(file.webkitRelativePath))); ++ settings.validateFiles(files.map((file) => file.webkitRelativePath)); + yield _FileLoader.upload(files, settings); + settings.resolveURL = function(url2) { + return _FileLoader.resolveURL(this._objectURL, url2); @@ -5097,7 +5097,7 @@ const _ZipLoader = class { static createSettings(reader) { return __async(this, null, function* () { @@ -89,6 +116,15 @@ index 03c1fb2b32b3b908a8b0e604aa3ee50aa9130169..99fc587e3ca7e6f433099e13f33e0e48 if (!settingsFile) { throw new TypeError("Settings file not found"); } +@@ -5050,7 +5050,7 @@ var __async = (__this, __arguments, generator) => { + } else if (!settings._objectURL) { + throw new Error('"_objectURL" must be specified in ModelSettings'); + } +- settings.validateFiles(files.map((file) => encodeURI(file.webkitRelativePath))); ++ settings.validateFiles(files.map((file) => file.webkitRelativePath)); + yield _FileLoader.upload(files, settings); + settings.resolveURL = function(url) { + return _FileLoader.resolveURL(this._objectURL, url); @@ -5097,7 +5097,7 @@ var __async = (__this, __arguments, generator) => { static createSettings(reader) { return __async(this, null, function* () { @@ -111,6 +147,15 @@ index f969d304f346c4e420b6798dbd4dec81b902a5e4..7e6b4d3ef53f1ea0648030899de9debd if (!settingsFile) { throw new TypeError("Settings file not found"); } +@@ -1411,7 +1411,7 @@ FileLoader.factory = (context, next) => __async(void 0, null, function* () { + } else if (!settings._objectURL) { + throw new Error('"_objectURL" must be specified in ModelSettings'); + } +- settings.validateFiles(files.map((file) => encodeURI(file.webkitRelativePath))); ++ settings.validateFiles(files.map((file) => file.webkitRelativePath)); + yield _FileLoader.upload(files, settings); + settings.resolveURL = function(url2) { + return _FileLoader.resolveURL(this._objectURL, url2); @@ -1458,7 +1458,7 @@ const _ZipLoader = class { static createSettings(reader) { return __async(this, null, function* () { @@ -133,6 +178,15 @@ index bc3d800c45889979190175cc88cdd8251feb9b5e..d1a660708ff81e1379f173709a32c6f4 if (!settingsFile) { throw new TypeError("Settings file not found"); } +@@ -1411,7 +1411,7 @@ var __async = (__this, __arguments, generator) => { + } else if (!settings._objectURL) { + throw new Error('"_objectURL" must be specified in ModelSettings'); + } +- settings.validateFiles(files.map((file) => encodeURI(file.webkitRelativePath))); ++ settings.validateFiles(files.map((file) => file.webkitRelativePath)); + yield _FileLoader.upload(files, settings); + settings.resolveURL = function(url) { + return _FileLoader.resolveURL(this._objectURL, url); @@ -1458,7 +1458,7 @@ var __async = (__this, __arguments, generator) => { static createSettings(reader) { return __async(this, null, function* () { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eb674bb5..fd4e513e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1207,7 +1207,7 @@ patchedDependencies: hash: 4bbfdca823ab48b74086e6e7d4b2f9baf5ee7c0ba9aee0c279b3c91c50bfd797 path: patches/mineflayer-pathfinder.patch pixi-live2d-display: - hash: 122ac09349321d5bfe9d9817aa095cd1c9c4132af86345aa9e27ba8b63dadf2c + hash: 7881eb52793f46a10d6c04d62b1799a3d0ef79f83edc0283777baa6db339ed6d path: patches/pixi-live2d-display.patch sponsorkit@17.1.0: hash: f5887da52a29ae85732e01644d05c0c274231fe76b95aebeafbfab6dc4444f88 @@ -4745,7 +4745,7 @@ importers: version: 4.2.0(5af797a0b5bd919a06578c24a7479046) pixi-live2d-display: specifier: 'catalog:' - version: 0.4.0(patch_hash=122ac09349321d5bfe9d9817aa095cd1c9c4132af86345aa9e27ba8b63dadf2c)(0736f5bd67d2e4de7e9fff36ad63fa2e) + version: 0.4.0(patch_hash=7881eb52793f46a10d6c04d62b1799a3d0ef79f83edc0283777baa6db339ed6d)(0736f5bd67d2e4de7e9fff36ad63fa2e) three: specifier: 'catalog:' version: 0.184.0 @@ -33525,7 +33525,7 @@ snapshots: - '@pixi/settings' - '@pixi/utils' - pixi-live2d-display@0.4.0(patch_hash=122ac09349321d5bfe9d9817aa095cd1c9c4132af86345aa9e27ba8b63dadf2c)(0736f5bd67d2e4de7e9fff36ad63fa2e): + pixi-live2d-display@0.4.0(patch_hash=7881eb52793f46a10d6c04d62b1799a3d0ef79f83edc0283777baa6db339ed6d)(0736f5bd67d2e4de7e9fff36ad63fa2e): dependencies: '@pixi/core': 6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))) '@pixi/display': 6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))