perf(stage-ui-tachie): use fflate instead of jszip (#2172)
This commit is contained in:
@@ -111,6 +111,7 @@ words:
|
|||||||
- eventa
|
- eventa
|
||||||
- Factorio
|
- Factorio
|
||||||
- feaxios
|
- feaxios
|
||||||
|
- fflate
|
||||||
- Flathub
|
- Flathub
|
||||||
- flexsearch
|
- flexsearch
|
||||||
- formkit
|
- formkit
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@proj-airi/stage-shared": "workspace:^",
|
"@proj-airi/stage-shared": "workspace:^",
|
||||||
"@proj-airi/ui": "workspace:^",
|
"@proj-airi/ui": "workspace:^",
|
||||||
"culori": "catalog:",
|
"culori": "catalog:",
|
||||||
"jszip": "catalog:",
|
"fflate": "catalog:",
|
||||||
"pinia": "catalog:",
|
"pinia": "catalog:",
|
||||||
"pixi-filters": "catalog:",
|
"pixi-filters": "catalog:",
|
||||||
"vue": "catalog:"
|
"vue": "catalog:"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { zipSync } from 'fflate'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
import { resolveTachieArchiveLayout } from './tachie-archive'
|
import { resolveTachieArchiveLayout, validateTachieZip } from './tachie-archive'
|
||||||
|
|
||||||
describe('resolveTachieArchiveLayout', () => {
|
describe('resolveTachieArchiveLayout', () => {
|
||||||
it('maps root images case-insensitively while ignoring metadata', () => {
|
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.')
|
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 { errorMessageFrom } from '@moeru/std'
|
||||||
|
import { unzip } from 'fflate'
|
||||||
|
|
||||||
import { DEFAULT_TACHIE_EMOTION, isTachieEmotion, TACHIE_EMOTIONS } from '../constants/emotions'
|
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'
|
export const TACHIE_ARCHIVE_SUFFIX = '.tachie.zip'
|
||||||
/** Maximum compressed ZIP size accepted by the loader. */
|
/** Maximum compressed ZIP size accepted by the loader. */
|
||||||
export const MAX_TACHIE_ARCHIVE_BYTES = 100 * 1024 * 1024
|
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. */
|
/** One recognized emotion image in its original archive location. */
|
||||||
export interface TachieArchiveEntry {
|
export interface TachieArchiveEntry {
|
||||||
@@ -102,12 +97,6 @@ export interface TachieValidationReport {
|
|||||||
warnings: string[]
|
warnings: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface JSZipObjectWithCompressedData extends JSZipType.JSZipObject {
|
|
||||||
_data?: {
|
|
||||||
uncompressedSize?: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Optional policy overrides for loading Tachie archives. */
|
/** Optional policy overrides for loading Tachie archives. */
|
||||||
export interface TachieArchiveLoadOptions {
|
export interface TachieArchiveLoadOptions {
|
||||||
/** Filename used for suffix validation and diagnostics. */
|
/** Filename used for suffix validation and diagnostics. */
|
||||||
@@ -286,6 +275,25 @@ function disposeDecodedImages(images: Iterable<TachieDecodedImage>) {
|
|||||||
image.dispose()
|
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(
|
async function inspectTachieArchive(
|
||||||
input: Blob | ArrayBuffer,
|
input: Blob | ArrayBuffer,
|
||||||
options: TachieArchiveLoadOptions = {},
|
options: TachieArchiveLoadOptions = {},
|
||||||
@@ -301,9 +309,12 @@ async function inspectTachieArchive(
|
|||||||
if (errors.length > 0)
|
if (errors.length > 0)
|
||||||
return { archiveBytes, detected: [], errors, ignoredEntries: [], warnings }
|
return { archiveBytes, detected: [], errors, ignoredEntries: [], warnings }
|
||||||
|
|
||||||
let zip: JSZipType
|
let files: Record<string, Uint8Array>
|
||||||
try {
|
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) {
|
catch (error) {
|
||||||
return {
|
return {
|
||||||
@@ -315,11 +326,10 @@ async function inspectTachieArchive(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const archiveFiles = Object.values(zip.files).filter(file => !file.dir)
|
const layout = resolveTachieArchiveLayout(Object.keys(files))
|
||||||
const layout = resolveTachieArchiveLayout(archiveFiles.map(file => file.name))
|
|
||||||
errors.push(...layout.errors)
|
errors.push(...layout.errors)
|
||||||
if (layout.ignoredEntries.length > 0)
|
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) {
|
if (errors.length > 0) {
|
||||||
return {
|
return {
|
||||||
archiveBytes,
|
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()
|
const maxTextureSize = options.maxTextureSize ?? maxTextureSizeFromBrowser()
|
||||||
if (maxTextureSize <= 0) {
|
if (maxTextureSize <= 0) {
|
||||||
errors.push('WebGL is unavailable, so Tachie textures cannot be rendered.')
|
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>()
|
const images = new Map<typeof TACHIE_EMOTIONS[number], TachieDecodedImage>()
|
||||||
let width = 0
|
let width = 0
|
||||||
let height = 0
|
let height = 0
|
||||||
let decodedImageBytes = 0
|
let decodedImageBytes = 0
|
||||||
|
|
||||||
for (const entry of layout.entries) {
|
for (const entry of layout.entries) {
|
||||||
const file = zip.file(entry.path)
|
const bytes = extractedImagesByPath.get(entry.path)
|
||||||
if (!file) {
|
if (!bytes) {
|
||||||
errors.push(`Missing archive entry "${entry.path}".`)
|
errors.push(`Missing archive entry "${entry.path}".`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bytes = await file.async('uint8array')
|
|
||||||
const blob = new Blob([Uint8Array.from(bytes).buffer])
|
const blob = new Blob([Uint8Array.from(bytes).buffer])
|
||||||
const decoded = await decodeImage(blob)
|
const decoded = await decodeImage(blob)
|
||||||
const dimensions = imageDimensions(decoded.source)
|
const dimensions = imageDimensions(decoded.source)
|
||||||
@@ -397,15 +383,7 @@ async function inspectTachieArchive(
|
|||||||
continue
|
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
|
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) {
|
if (width === 0 && height === 0) {
|
||||||
width = dimensions.width
|
width = dimensions.width
|
||||||
|
|||||||
Generated
+10
-7
@@ -702,6 +702,9 @@ catalogs:
|
|||||||
eventemitter3:
|
eventemitter3:
|
||||||
specifier: ^5.0.4
|
specifier: ^5.0.4
|
||||||
version: 5.0.4
|
version: 5.0.4
|
||||||
|
fflate:
|
||||||
|
specifier: ^0.8.3
|
||||||
|
version: 0.8.3
|
||||||
floating-vue:
|
floating-vue:
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.2.2
|
version: 5.2.2
|
||||||
@@ -4692,9 +4695,9 @@ importers:
|
|||||||
culori:
|
culori:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
jszip:
|
fflate:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.10.1
|
version: 0.8.3
|
||||||
pinia:
|
pinia:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
|
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:
|
fflate@0.6.10:
|
||||||
resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
|
resolution: {integrity: sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==}
|
||||||
|
|
||||||
fflate@0.8.2:
|
fflate@0.8.3:
|
||||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
@@ -19702,7 +19705,7 @@ snapshots:
|
|||||||
'@andrewbranch/untar.js': 1.0.3
|
'@andrewbranch/untar.js': 1.0.3
|
||||||
'@loaderkit/resolve': 1.0.4
|
'@loaderkit/resolve': 1.0.4
|
||||||
cjs-module-lexer: 1.4.3
|
cjs-module-lexer: 1.4.3
|
||||||
fflate: 0.8.2
|
fflate: 0.8.3
|
||||||
lru-cache: 11.3.5
|
lru-cache: 11.3.5
|
||||||
semver: 7.7.4
|
semver: 7.7.4
|
||||||
typescript: 5.6.1-rc
|
typescript: 5.6.1-rc
|
||||||
@@ -25090,7 +25093,7 @@ snapshots:
|
|||||||
'@tweenjs/tween.js': 23.1.3
|
'@tweenjs/tween.js': 23.1.3
|
||||||
'@types/stats.js': 0.17.4
|
'@types/stats.js': 0.17.4
|
||||||
'@types/webxr': 0.5.24
|
'@types/webxr': 0.5.24
|
||||||
fflate: 0.8.2
|
fflate: 0.8.3
|
||||||
meshoptimizer: 1.1.1
|
meshoptimizer: 1.1.1
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
@@ -29013,7 +29016,7 @@ snapshots:
|
|||||||
|
|
||||||
fflate@0.6.10: {}
|
fflate@0.6.10: {}
|
||||||
|
|
||||||
fflate@0.8.2: {}
|
fflate@0.8.3: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ catalog:
|
|||||||
eslint: ^10.2.1
|
eslint: ^10.2.1
|
||||||
eslint-plugin-oxlint: ^1.60.0
|
eslint-plugin-oxlint: ^1.60.0
|
||||||
eventemitter3: ^5.0.4
|
eventemitter3: ^5.0.4
|
||||||
|
fflate: ^0.8.3
|
||||||
floating-vue: ^5.2.2
|
floating-vue: ^5.2.2
|
||||||
fluent-ffmpeg: ^2.1.3
|
fluent-ffmpeg: ^2.1.3
|
||||||
get-port-please: ^3.2.0
|
get-port-please: ^3.2.0
|
||||||
|
|||||||
Reference in New Issue
Block a user