perf(stage-ui-tachie): use fflate instead of jszip (#2172)

This commit is contained in:
藍+85CD
2026-07-30 08:36:41 +08:00
committed by GitHub
parent bd149f6a65
commit c97169bac7
6 changed files with 57 additions and 64 deletions
+1
View File
@@ -111,6 +111,7 @@ words:
- eventa
- Factorio
- feaxios
- fflate
- Flathub
- flexsearch
- formkit
+1 -1
View File
@@ -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:"
@@ -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.'])
})
})
@@ -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<TachieDecodedImage>) {
image.dispose()
}
function unzipAsync(data: Uint8Array): Promise<Record<string, Uint8Array>> {
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<string, Uint8Array>
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<typeof TACHIE_EMOTIONS[number], TachieDecodedImage>()
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
+10 -7
View File
@@ -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:
+1
View File
@@ -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