From c97169bac71bc2bd58f0182088a01a7fe7d23d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=97=8D+85CD?= <50108258+kwaa@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:36:41 +0800 Subject: [PATCH] perf(stage-ui-tachie): use fflate instead of jszip (#2172) --- cspell.config.yaml | 1 + packages/stage-ui-tachie/package.json | 2 +- .../src/utils/tachie-archive.test.ts | 12 ++- .../src/utils/tachie-archive.ts | 88 +++++++------------ pnpm-lock.yaml | 17 ++-- pnpm-workspace.yaml | 1 + 6 files changed, 57 insertions(+), 64 deletions(-) diff --git a/cspell.config.yaml b/cspell.config.yaml index 8cc9a934c..ebaa026e2 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -111,6 +111,7 @@ words: - eventa - Factorio - feaxios + - fflate - Flathub - flexsearch - formkit diff --git a/packages/stage-ui-tachie/package.json b/packages/stage-ui-tachie/package.json index 739f5fe3e..ccd876ecb 100644 --- a/packages/stage-ui-tachie/package.json +++ b/packages/stage-ui-tachie/package.json @@ -38,7 +38,7 @@ "@proj-airi/stage-shared": "workspace:^", "@proj-airi/ui": "workspace:^", "culori": "catalog:", - "jszip": "catalog:", + "fflate": "catalog:", "pinia": "catalog:", "pixi-filters": "catalog:", "vue": "catalog:" diff --git a/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts b/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts index f80c270a4..9b306f648 100644 --- a/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts +++ b/packages/stage-ui-tachie/src/utils/tachie-archive.test.ts @@ -1,6 +1,7 @@ +import { zipSync } from 'fflate' import { describe, expect, it } from 'vitest' -import { resolveTachieArchiveLayout } from './tachie-archive' +import { resolveTachieArchiveLayout, validateTachieZip } from './tachie-archive' describe('resolveTachieArchiveLayout', () => { it('maps root images case-insensitively while ignoring metadata', () => { @@ -70,4 +71,13 @@ describe('resolveTachieArchiveLayout', () => { expect(layout.errors).toContain('Emotion images must be stored at the archive root or inside one wrapping directory.') }) + + it('extracts ZIP entries before validating the archive layout', async () => { + const archive = zipSync({ 'happy.png': new Uint8Array([1, 2, 3]) }, { level: 0 }) + const report = await validateTachieZip(new File([archive], 'missing-neutral.tachie.zip')) + + expect(report.status).toBe('INVALID') + expect(report.detected).toEqual([{ emotion: 'happy', path: 'happy.png' }]) + expect(report.errors).toEqual(['Tachie archive must contain a neutral image.']) + }) }) diff --git a/packages/stage-ui-tachie/src/utils/tachie-archive.ts b/packages/stage-ui-tachie/src/utils/tachie-archive.ts index b381089ee..451763f69 100644 --- a/packages/stage-ui-tachie/src/utils/tachie-archive.ts +++ b/packages/stage-ui-tachie/src/utils/tachie-archive.ts @@ -1,8 +1,5 @@ -import type JSZipType from 'jszip' - -import JSZip from 'jszip' - import { errorMessageFrom } from '@moeru/std' +import { unzip } from 'fflate' import { DEFAULT_TACHIE_EMOTION, isTachieEmotion, TACHIE_EMOTIONS } from '../constants/emotions' @@ -10,8 +7,6 @@ import { DEFAULT_TACHIE_EMOTION, isTachieEmotion, TACHIE_EMOTIONS } from '../con export const TACHIE_ARCHIVE_SUFFIX = '.tachie.zip' /** Maximum compressed ZIP size accepted by the loader. */ export const MAX_TACHIE_ARCHIVE_BYTES = 100 * 1024 * 1024 -/** Maximum combined RGBA texture memory accepted after image decoding. */ -export const MAX_TACHIE_IMAGE_BYTES = 300 * 1024 * 1024 /** One recognized emotion image in its original archive location. */ export interface TachieArchiveEntry { @@ -102,12 +97,6 @@ export interface TachieValidationReport { warnings: string[] } -interface JSZipObjectWithCompressedData extends JSZipType.JSZipObject { - _data?: { - uncompressedSize?: number - } -} - /** Optional policy overrides for loading Tachie archives. */ export interface TachieArchiveLoadOptions { /** Filename used for suffix validation and diagnostics. */ @@ -286,6 +275,25 @@ function disposeDecodedImages(images: Iterable) { image.dispose() } +function unzipAsync(data: Uint8Array): Promise> { + return new Promise((resolve, reject) => { + try { + unzip(data, { + filter: file => !file.name.endsWith('/') && !file.name.endsWith('\\'), + }, (error, files) => { + if (error) { + reject(error) + return + } + resolve(files) + }) + } + catch (error) { + reject(error) + } + }) +} + async function inspectTachieArchive( input: Blob | ArrayBuffer, options: TachieArchiveLoadOptions = {}, @@ -301,9 +309,12 @@ async function inspectTachieArchive( if (errors.length > 0) return { archiveBytes, detected: [], errors, ignoredEntries: [], warnings } - let zip: JSZipType + let files: Record try { - zip = await JSZip.loadAsync(input) + const data = input instanceof Blob + ? await input.arrayBuffer().then(buffer => new Uint8Array(buffer)) + : new Uint8Array(input) + files = await unzipAsync(data) } catch (error) { return { @@ -315,11 +326,10 @@ async function inspectTachieArchive( } } - const archiveFiles = Object.values(zip.files).filter(file => !file.dir) - const layout = resolveTachieArchiveLayout(archiveFiles.map(file => file.name)) + const layout = resolveTachieArchiveLayout(Object.keys(files)) errors.push(...layout.errors) if (layout.ignoredEntries.length > 0) - warnings.push(`Ignored ${layout.ignoredEntries.length} unrecognized archive entr${layout.ignoredEntries.length === 1 ? 'y' : 'ies'}.`) + warnings.push(`Ignored ${layout.ignoredEntries.length} unrecognized archive ${layout.ignoredEntries.length === 1 ? 'entry' : 'entries'}.`) if (errors.length > 0) { return { archiveBytes, @@ -330,33 +340,6 @@ async function inspectTachieArchive( } } - let declaredImageBytes = 0 - for (const entry of layout.entries) { - const file = zip.file(entry.path) - if (!file) - continue - - // NOTICE: - // Preflight the declared uncompressed size before JSZip allocates a complete entry. - // JSZip keeps central-directory sizes on the private `_data` object but does not expose - // them in its public types. Source/context: `jszip/lib/compressedObject.js` and - // `jszip/index.d.ts` (the commented `CompressedObject` declaration). - // Removal condition: JSZip exposes uncompressed entry sizes through its public API. - const declaredSize = (file as JSZipObjectWithCompressedData)._data?.uncompressedSize - if (typeof declaredSize === 'number') - declaredImageBytes += declaredSize - } - if (declaredImageBytes > MAX_TACHIE_IMAGE_BYTES) { - errors.push('Tachie images exceed the 300 MiB uncompressed size limit.') - return { - archiveBytes, - detected: layout.entries, - errors, - ignoredEntries: layout.ignoredEntries, - warnings, - } - } - const maxTextureSize = options.maxTextureSize ?? maxTextureSizeFromBrowser() if (maxTextureSize <= 0) { errors.push('WebGL is unavailable, so Tachie textures cannot be rendered.') @@ -369,20 +352,23 @@ async function inspectTachieArchive( } } + const extractedImagesByPath = new Map( + Object.entries(files).map(([path, bytes]) => [normalizedArchivePath(path), bytes]), + ) + const images = new Map() let width = 0 let height = 0 let decodedImageBytes = 0 for (const entry of layout.entries) { - const file = zip.file(entry.path) - if (!file) { + const bytes = extractedImagesByPath.get(entry.path) + if (!bytes) { errors.push(`Missing archive entry "${entry.path}".`) continue } try { - const bytes = await file.async('uint8array') const blob = new Blob([Uint8Array.from(bytes).buffer]) const decoded = await decodeImage(blob) const dimensions = imageDimensions(decoded.source) @@ -397,15 +383,7 @@ async function inspectTachieArchive( continue } - // Browser textures use four 8-bit channels regardless of the source - // image's compression or alpha usage. Count this decoded footprint so - // high-resolution PNG/WebP inputs cannot bypass the memory budget. decodedImageBytes += dimensions.width * dimensions.height * 4 - if (decodedImageBytes > MAX_TACHIE_IMAGE_BYTES) { - decoded.dispose() - errors.push('Tachie images exceed the 300 MiB decoded texture memory limit.') - break - } if (width === 0 && height === 0) { width = dimensions.width diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f92d9db4..a067307c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -702,6 +702,9 @@ catalogs: eventemitter3: specifier: ^5.0.4 version: 5.0.4 + fflate: + specifier: ^0.8.3 + version: 0.8.3 floating-vue: specifier: ^5.2.2 version: 5.2.2 @@ -4692,9 +4695,9 @@ importers: culori: specifier: 'catalog:' version: 4.0.2 - jszip: + fflate: specifier: 'catalog:' - version: 3.10.1 + version: 0.8.3 pinia: specifier: 'catalog:' version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) @@ -14212,8 +14215,8 @@ packages: fflate@0.6.10: resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==} - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -19702,7 +19705,7 @@ snapshots: '@andrewbranch/untar.js': 1.0.3 '@loaderkit/resolve': 1.0.4 cjs-module-lexer: 1.4.3 - fflate: 0.8.2 + fflate: 0.8.3 lru-cache: 11.3.5 semver: 7.7.4 typescript: 5.6.1-rc @@ -25090,7 +25093,7 @@ snapshots: '@tweenjs/tween.js': 23.1.3 '@types/stats.js': 0.17.4 '@types/webxr': 0.5.24 - fflate: 0.8.2 + fflate: 0.8.3 meshoptimizer: 1.1.1 '@types/trusted-types@2.0.7': {} @@ -29013,7 +29016,7 @@ snapshots: fflate@0.6.10: {} - fflate@0.8.2: {} + fflate@0.8.3: {} file-entry-cache@8.0.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 84a275bd2..053195c8c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -263,6 +263,7 @@ catalog: eslint: ^10.2.1 eslint-plugin-oxlint: ^1.60.0 eventemitter3: ^5.0.4 + fflate: ^0.8.3 floating-vue: ^5.2.2 fluent-ffmpeg: ^2.1.3 get-port-please: ^3.2.0