diff --git a/packages/stage-ui-live2d/src/utils/decode-zip-filename.test.ts b/packages/stage-ui-live2d/src/utils/decode-zip-filename.test.ts new file mode 100644 index 000000000..b51b13e17 --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/decode-zip-filename.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { decodeZipFileName } from './decode-zip-filename' + +describe('decodeZipFileName', () => { + it('passes ASCII names through unchanged', () => { + const bytes = new TextEncoder().encode('Sparkle.model3.json') + expect(decodeZipFileName(bytes)).toBe('Sparkle.model3.json') + }) + + it('decodes GBK names as GBK even when the bytes are also valid UTF-8', () => { + // GBK `一` is bytes D2 BB, which is *also* a well-formed UTF-8 sequence for `һ`. + // Preferring valid UTF-8 here would yield mojibake; the decoder must choose GBK. + const bytes = new Uint8Array([0xD2, 0xBB, ...new TextEncoder().encode('.exp3.json')]) + expect(decodeZipFileName(bytes)).toBe('一.exp3.json') + }) + + it('decodes multi-character GBK names', () => { + // GBK bytes for `高光` followed by an ASCII suffix. + const bytes = new Uint8Array([0xB8, 0xDF, 0xB9, 0xE2, ...new TextEncoder().encode('.exp3.json')]) + expect(decodeZipFileName(bytes)).toBe('高光.exp3.json') + }) + + it('passes a string[] through unchanged (JSZip option-signature branch)', () => { + expect(decodeZipFileName(['a', 'b', 'c'])).toBe('abc') + }) +}) diff --git a/packages/stage-ui-live2d/src/utils/decode-zip-filename.ts b/packages/stage-ui-live2d/src/utils/decode-zip-filename.ts new file mode 100644 index 000000000..4e73b259d --- /dev/null +++ b/packages/stage-ui-live2d/src/utils/decode-zip-filename.ts @@ -0,0 +1,31 @@ +// Some Live2D archives — notably VTube Studio exports from CJK authors — store entry +// names without the UTF-8 flag, encoded in a legacy codepage (most commonly GBK). JSZip +// decodes those as UTF-8 by default, turning names like `手姿势切换.exp3.json` into U+FFFD +// mojibake. +// +// JSZip only calls `decodeFileName` for entries *without* the UTF-8 flag, so any name +// carrying high bytes here is almost certainly legacy-encoded. We must not simply prefer +// UTF-8 when it happens to be valid: some GBK names are also well-formed UTF-8 yet decode +// to the wrong characters (e.g. GBK `一` is bytes `D2 BB`, which is valid UTF-8 for `һ`). +// Pure-ASCII names are identical across encodings, so fast-path those and decode the rest +// as GBK, falling back to UTF-8 only if a GBK decoder is unavailable in this runtime. +// +// Pass this to `JSZip.loadAsync(data, { decodeFileName })`. It must be shared by every +// code path that opens a model archive (loader and validator alike), otherwise one path +// sees mojibake names while another sees the decoded ones. +export function decodeZipFileName(bytes: string[] | Uint8Array): string { + // JSZip passes the raw filename bytes as a Uint8Array; the string[] branch only + // exists to satisfy its option signature and is passed through unchanged. + if (Array.isArray(bytes)) + return bytes.join('') + + if (bytes.every(byte => byte < 0x80)) + return new TextDecoder('utf-8').decode(bytes) + + try { + return new TextDecoder('gbk').decode(bytes) + } + catch { + return new TextDecoder('utf-8').decode(bytes) + } +} diff --git a/packages/stage-ui-live2d/src/utils/live2d-structure-report.ts b/packages/stage-ui-live2d/src/utils/live2d-structure-report.ts index 8acbcd47d..83a7cb69a 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-structure-report.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-structure-report.ts @@ -5,6 +5,8 @@ import { argv, exit } from 'node:process' import JSZip from 'jszip' +import { decodeZipFileName } from './decode-zip-filename' + /** * Enhanced reporting utility to analyze and validate Live2D ZIP structures. */ @@ -20,7 +22,7 @@ async function generateReport(zipPath: string) { } const data = fs.readFileSync(zipPath) - const zip = await JSZip.loadAsync(data) + const zip = await JSZip.loadAsync(data, { decodeFileName: decodeZipFileName }) const allFiles = Object.keys(zip.files) const report = { diff --git a/packages/stage-ui-live2d/src/utils/live2d-validator.ts b/packages/stage-ui-live2d/src/utils/live2d-validator.ts index cc7519922..7d18897ab 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-validator.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-validator.ts @@ -1,5 +1,7 @@ import JSZip from 'jszip' +import { decodeZipFileName } from './decode-zip-filename' + export interface Live2DValidationReport { fileName: string totalFiles: number @@ -17,7 +19,7 @@ export interface Live2DValidationReport { } export async function validateLive2DZip(file: File | Blob): Promise { - const zip = await JSZip.loadAsync(file) + const zip = await JSZip.loadAsync(file, { decodeFileName: decodeZipFileName }) const allPaths = Object.keys(zip.files) const report: Live2DValidationReport = { 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 7fc811bad..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,19 +47,6 @@ 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', () => { @@ -150,89 +137,6 @@ 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 384ae62ec..49dff15ca 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts @@ -4,7 +4,13 @@ import JSZip from 'jszip' import { Cubism4ModelSettings, FileLoader, Live2DFactory, ZipLoader } from 'pixi-live2d-display/cubism4' -ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data) +import { decodeZipFileName } from './decode-zip-filename' + +// Legacy/VTube-Studio archives often store entry names without the UTF-8 flag in a legacy +// codepage; decode them so non-ASCII names (e.g. `手姿势切换.exp3.json`) don't become +// mojibake. The same decoder must be used by `validateLive2DZip`, otherwise validation +// sees mojibake paths and rejects archives before they reach this loader. +ZipLoader.zipReader = (data: Blob, _url: string) => JSZip.loadAsync(data, { decodeFileName: decodeZipFileName }) interface IgnoredArchivePathSegmentRule { matches: (segment: string) => boolean @@ -109,22 +115,7 @@ function createModelSettings(text: string, url: string): ModelSettings { throw new Error('Unknown settings JSON') } - // 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 + return runtime.createModelSettings(settingsJSON) } export function isSettingsFile(file: string) { 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 6d5a6d556..3038cd774 100644 --- a/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.test.ts @@ -166,7 +166,7 @@ describe('opfs cache full directory persistence', () => { await OPFSCache.writeFile( dir as unknown as FileSystemDirectoryHandle, '__meta.json', - JSON.stringify({ sourceUrl: 'blob:first', version: 2 }), + JSON.stringify({ sourceUrl: 'blob:first', version: 3 }), ) const files = await OPFSCache.get('metadata-model', 'blob:second') diff --git a/packages/stage-ui-live2d/src/utils/opfs-loader.ts b/packages/stage-ui-live2d/src/utils/opfs-loader.ts index 40084186d..2686a1583 100644 --- a/packages/stage-ui-live2d/src/utils/opfs-loader.ts +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.ts @@ -2,6 +2,8 @@ import type { Live2DFactoryContext, Middleware } from 'pixi-live2d-display/cubis import JSZip from 'jszip' +import { decodeZipFileName } from './decode-zip-filename' + interface OPFSContext extends Live2DFactoryContext { opfsKey?: string opfsUrl?: string @@ -17,8 +19,11 @@ interface OPFSCacheMeta { * Cache schema version for OPFS-stored Live2D zip directories. * * Increment when the persisted directory shape changes. + * + * v3: entry paths are now decoded via `decodeZipFileName`; bumping invalidates + * caches written with the previous mojibake filenames so they are re-saved. */ -const live2DOpfsCacheVersion = 2 +const live2DOpfsCacheVersion = 3 interface IgnoredArchivePathSegmentRule { matches: (segment: string) => boolean @@ -190,7 +195,7 @@ export class OPFSCache { */ static async save(key: string, zipBlob: Blob, sourceUrl?: string): Promise { try { - const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer()) + const zip = await JSZip.loadAsync(await zipBlob.arrayBuffer(), { decodeFileName: decodeZipFileName }) const fileEntries = Object.values(zip.files) .filter(file => !file.dir && !shouldIgnoreLive2DArchiveEntry(file.name))