mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
perf(stage-ui-tachie): use fflate instead of jszip (#2172)
This commit is contained in:
@@ -111,6 +111,7 @@ words:
|
||||
- eventa
|
||||
- Factorio
|
||||
- feaxios
|
||||
- fflate
|
||||
- Flathub
|
||||
- flexsearch
|
||||
- formkit
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+10
-7
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user