├── playground ├── src │ ├── debounce.ts │ ├── pwa │ │ ├── index.ts │ │ └── wasmWarmup.ts │ ├── styles │ │ └── main.css │ ├── utils │ │ └── index.ts │ └── main.ts ├── .npmrc ├── public │ └── icon.png ├── .gitignore ├── netlify.toml ├── shims.d.ts ├── tsconfig.json ├── index.html ├── package.json └── vite.config.ts ├── pnpm-workspace.yaml ├── .npmrc ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── general.md └── workflows │ └── ci.yml ├── assets ├── kv.png └── pic.png ├── utils └── index.ts ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── src ├── conversion │ ├── index.ts │ ├── types.ts │ └── convertImage.ts ├── tools │ ├── index.ts │ ├── compressWithBrowserImageCompression.ts │ ├── compressWithGifsicle.ts │ ├── compressWithCompressorJS.ts │ ├── compressWithCanvas.ts │ └── compressWithTinyPng.ts ├── utils │ ├── index.ts │ ├── abort.ts │ ├── lruCache.ts │ ├── logger.ts │ ├── preprocessImage.ts │ ├── imageQuality.ts │ ├── compressionWorker.ts │ └── compressionQueue.ts ├── convertBlobToType.ts ├── index.ts ├── tools.ts ├── types.ts ├── orchestrators │ └── compareConversion.ts └── compressEnhanced.ts ├── tsconfig.json ├── scripts └── copy-wasm.ts ├── eslint.config.mjs ├── license ├── toolConfigs-examples.ts ├── test ├── logger.test.ts ├── basic.test.ts ├── gifsicle-protection.test.ts ├── compression-tools-protection.test.ts ├── format-conversion.test.ts ├── features.test.ts └── new-features.test.ts ├── RELEASE_CHECKLIST.md ├── examples.md ├── docs ├── PERFORMANCE_IMPROVEMENTS.md ├── image-conversion-tasks.md ├── tree-shaking-guide.md ├── image-conversion-plan.md ├── performance-optimization-guide.md └── configuration-guide.md ├── package.json ├── .claude └── agents │ ├── playground-feature-updater.md │ └── browser-compress-dev.md ├── examples └── tree-shaking-examples.ts ├── CLAUDE.md └── CSS_COMPATIBILITY.md /playground/src/debounce.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /playground/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | node-linker=hoist 3 | -------------------------------------------------------------------------------- /playground/src/pwa/index.ts: -------------------------------------------------------------------------------- 1 | export { warmupJsquashWasm } from './wasmWarmup.js' 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Simon-He95 2 | custom: 3 | - https://github.com/Simon-He95/sponsor 4 | -------------------------------------------------------------------------------- /assets/kv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesome-compressor/browser-compress-image/HEAD/assets/kv.png -------------------------------------------------------------------------------- /assets/pic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesome-compressor/browser-compress-image/HEAD/assets/pic.png -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | // Export utility functions and classes 2 | export { LRUCache } from '../src/utils/lruCache' 3 | -------------------------------------------------------------------------------- /playground/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesome-compressor/browser-compress-image/HEAD/playground/public/icon.png -------------------------------------------------------------------------------- /playground/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .vite-ssg-dist 3 | .vite-ssg-temp 4 | *.local 5 | dist 6 | dist-ssr 7 | node_modules 8 | .idea/ 9 | *.log 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | node_modules 3 | *.log 4 | idea/ 5 | *.local 6 | .DS_Store 7 | dist 8 | .cache 9 | .idea 10 | logs 11 | &-debug.log 12 | *-error.log 13 | -------------------------------------------------------------------------------- /playground/src/styles/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | #app { 4 | height: 100%; 5 | margin: 0; 6 | padding: 0; 7 | overflow: auto; 8 | } 9 | 10 | html.dark { 11 | background: #121212; 12 | } 13 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "antfu.vite", 4 | "antfu.iconify", 5 | "antfu.unocss", 6 | "johnsoncodehk.volar", 7 | "dbaeumer.vscode-eslint" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /playground/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "dist" 3 | command = "npx pnpm run build" 4 | 5 | [build.environment] 6 | NPM_FLAGS = "--version" 7 | NODE_VERSION = "22" 8 | 9 | [[redirects]] 10 | from = "/*" 11 | to = "/index.html" 12 | status = 200 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["bumpp", "pnpm", "Vitesse", "vitest"], 3 | "prettier.enable": false, 4 | "typescript.tsdk": "node_modules/typescript/lib", 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": "explicit" 7 | }, 8 | "files.associations": { 9 | "*.css": "postcss" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/conversion/index.ts: -------------------------------------------------------------------------------- 1 | export { convertImage } from './convertImage' 2 | export { 3 | renderSvgToCanvas, 4 | encodeSvgToFormat, 5 | detectFileFormat, 6 | isSvgContent, 7 | } from './encoders' 8 | export type { 9 | TargetFormat, 10 | SourceFormat, 11 | ImageConvertOptions, 12 | ImageConvertResult, 13 | } from './types' 14 | -------------------------------------------------------------------------------- /playground/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function debounce(func: Function, wait: number) { 2 | let timeout: ReturnType 3 | return function executedFunction(...args: any[]) { 4 | const later = () => { 5 | clearTimeout(timeout) 6 | func(...args) 7 | } 8 | clearTimeout(timeout) 9 | timeout = setTimeout(later, wait) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /playground/shims.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import type { DefineComponent } from 'vue' 3 | 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | 8 | // PWA virtual modules & plugin typings (shim for TS) 9 | declare module 'virtual:pwa-register' { 10 | export function registerSW(options?: any): () => void 11 | } 12 | 13 | declare module 'vite-plugin-pwa' { 14 | export const VitePWA: any 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "jsx": "preserve", 5 | "lib": ["esnext", "DOM"], 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "strict": true, 10 | "strictNullChecks": true, 11 | "noImplicitAny": false, 12 | "esModuleInterop": true, 13 | "skipDefaultLibCheck": true, 14 | "skipLibCheck": true 15 | }, 16 | "exclude": ["dist", "playground", "cypress", "node_modules"] 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General 3 | about: General issue 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | PLEASE READ: I originally made this template for myself to mocking up apps quicker. I am glad to see you are willing to give it a try! Before your open the issue, please make sure you are reporting bugs in the template itself. **I am NOT creating this template to solve the problems you faced in your project, please use Vue or Vite's discord server to ask questions.** Thank you. 10 | 11 | **Describe the bug/issue** 12 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "incremental": false, 4 | "target": "es2016", 5 | "lib": ["DOM", "ESNext"], 6 | "baseUrl": ".", 7 | "module": "nodenext", 8 | "moduleResolution": "nodenext", 9 | "paths": { 10 | "~/*": ["src/*"] 11 | }, 12 | "types": ["vite/client", "vite-plugin-pages/client", "vitest/globals"], 13 | "strict": true, 14 | "strictNullChecks": true, 15 | "noUnusedLocals": true, 16 | "esModuleInterop": true, 17 | "allowSyntheticDefaultImports": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "skipLibCheck": true 20 | }, 21 | "exclude": ["dist", "node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /src/conversion/types.ts: -------------------------------------------------------------------------------- 1 | export type TargetFormat = 'png' | 'jpeg' | 'webp' | 'ico' 2 | export type SourceFormat = 3 | | 'png' 4 | | 'jpeg' 5 | | 'webp' 6 | | 'ico' 7 | | 'svg' 8 | | 'gif' 9 | | 'bmp' 10 | 11 | export interface ImageConvertOptions { 12 | targetFormat: TargetFormat 13 | quality?: number // 0-1,仅 lossy 生效(jpeg/webp) 14 | preserveExif?: boolean // 仅 jpeg 有效;跨格式多数情况下会被剥离 15 | width?: number // SVG rendering width (defaults to SVG's intrinsic width) 16 | height?: number // SVG rendering height (defaults to SVG's intrinsic height) 17 | // 预留:位深/调色盘/多尺寸(ico)等 18 | [k: string]: any 19 | } 20 | 21 | export interface ImageConvertResult { 22 | blob: Blob 23 | mime: string 24 | duration: number 25 | } 26 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel for tools - re-export individual tool implementations and helpers 2 | export { 3 | default as compressWithJsquash, 4 | ensureWasmLoaded, 5 | configureWasmLoading, 6 | diagnoseJsquashAvailability, 7 | downloadWasmFiles, 8 | } from './compressWithJsquash' 9 | export { default as compressWithCompressorJS } from './compressWithCompressorJS' 10 | export { default as compressWithBrowserImageCompression } from './compressWithBrowserImageCompression' 11 | export { default as compressWithGifsicle } from './compressWithGifsicle' 12 | export { 13 | compressWithTinyPng, 14 | configureTinyPngCache, 15 | clearTinyPngCache, 16 | getTinyPngCacheInfo, 17 | getTinyPngCacheSize, 18 | } from './compressWithTinyPng' 19 | export { default as compressWithCanvas } from './compressWithCanvas' 20 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // Barrel for utils 2 | export { default as logger, setLogger, resetLogger } from './logger' 3 | export { LRUCache } from './lruCache' 4 | export { 5 | checkMemoryBeforeOperation, 6 | MemoryManager, 7 | memoryManager, 8 | } from './memoryManager' 9 | export { 10 | CompressionQueue, 11 | compressionQueue, 12 | PerformanceDetector, 13 | } from './compressionQueue' 14 | export { 15 | CompressionWorkerManager, 16 | compressionWorkerManager, 17 | } from './compressionWorker' 18 | export { preprocessImage } from './preprocessImage' 19 | export * from './imageQuality' 20 | export * from './abort' 21 | export type { MemoryStats, MemoryThresholds } from './memoryManager' 22 | export type { CompressionTask, QueueStats } from './compressionQueue' 23 | export type { WorkerMessage, WorkerTask } from './compressionWorker' 24 | -------------------------------------------------------------------------------- /scripts/copy-wasm.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import fs from 'fs' 3 | import path from 'path' 4 | 5 | const packages = ['png', 'jpeg', 'webp', 'avif', 'jxl'] 6 | const root = path.resolve(__dirname, '..') 7 | const outDir = path.join(root, 'playground', 'public', 'wasm') 8 | 9 | if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }) 10 | 11 | for (const p of packages) { 12 | const pkgDir = path.join(root, 'node_modules', '@jsquash', p, 'codec') 13 | if (!fs.existsSync(pkgDir)) continue 14 | const files = fs.readdirSync(pkgDir).filter((f) => f.endsWith('.wasm')) 15 | for (const f of files) { 16 | const src = path.join(pkgDir, f) 17 | const dest = path.join(outDir, f) 18 | try { 19 | fs.copyFileSync(src, dest) 20 | console.log(`Copied ${src} -> ${dest}`) 21 | } catch (e) { 22 | const msg = e instanceof Error ? e.message : String(e) 23 | console.error(`Failed copying ${src}:`, msg) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /playground/src/main.ts: -------------------------------------------------------------------------------- 1 | import ElementPlus from 'element-plus' 2 | import { VividTyping } from 'vivid-typing' 3 | import { createApp } from 'vue' 4 | // import { registerSW } from 'virtual:pwa-register' 5 | import { warmupJsquashWasm } from './pwa/index.js' 6 | import App from './App.vue' 7 | import './styles/main.css' 8 | import 'element-plus/dist/index.css' 9 | 10 | const app = createApp(App) 11 | 12 | app.use(ElementPlus) 13 | app.component('VividTyping', VividTyping) 14 | app.mount('#app') 15 | // PWA registration (auto update) - temporarily disabled 16 | // registerSW({ immediate: true }) 17 | 18 | // Warm up JSQuash modules and WASM in background for faster first use 19 | const idle = (cb: () => void) => 20 | (window as any).requestIdleCallback 21 | ? (window as any).requestIdleCallback(cb) 22 | : setTimeout(cb, 1500) 23 | 24 | idle(() => { 25 | warmupJsquashWasm().catch((e: unknown) => 26 | console.warn('WASM warmup failed', e), 27 | ) 28 | }) 29 | -------------------------------------------------------------------------------- /src/convertBlobToType.ts: -------------------------------------------------------------------------------- 1 | import type { CompressResult, CompressResultType } from './types' 2 | 3 | // 辅助函数:将 Blob 转换为不同格式 4 | export default async function convertBlobToType( 5 | blob: Blob, 6 | type: T, 7 | originalFileName?: string, 8 | ): Promise> { 9 | switch (type) { 10 | case 'blob': 11 | return blob as CompressResult 12 | case 'file': 13 | return new File([blob], originalFileName || 'compressed', { 14 | type: blob.type, 15 | }) as CompressResult 16 | case 'base64': 17 | return new Promise((resolve) => { 18 | const reader = new FileReader() 19 | reader.onloadend = () => resolve(reader.result as CompressResult) 20 | reader.readAsDataURL(blob) 21 | }) 22 | case 'arrayBuffer': 23 | return blob.arrayBuffer() as Promise> 24 | default: 25 | throw new Error(`Unsupported type: ${type}`) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Browser Compress Image 8 | 9 | 10 | 11 |
12 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import simon from '@antfu/eslint-config' 2 | 3 | export default simon({ 4 | rules: { 5 | 'no-console': 'off', 6 | 'ts/ban-types': 'off', 7 | 'jsdoc/require-returns-description': 'off', 8 | 'no-new-func': 'off', 9 | 'unicorn/no-new-array': 'off', 10 | 'jsdoc/require-returns-check': 'off', 11 | 'jsdoc/check-param-names': 'off', 12 | 'no-cond-assign': 'off', 13 | 'no-eval': 'off', 14 | 'ts/no-unsafe-function-type': 'off', 15 | 'regexp/optimal-quantifier-concatenation': 'off', 16 | 'regexp/no-misleading-capturing-group': 'off', 17 | 'regexp/no-super-linear-backtracking': 'off', 18 | 'ts/no-unused-expressions': 'off', 19 | 'regexp/no-useless-quantifier': 'off', 20 | 'unused-imports/no-unused-vars': 'off', 21 | 'regexp/no-unused-capturing-group': 'off', 22 | 'regexp/no-obscure-range': 'off', 23 | 'regexp/no-dupe-disjunctions': 'off', 24 | 'regexp/confusing-quantifier': 'off', 25 | 'regexp/no-legacy-features': 'off', 26 | 'ts/no-empty-object-type': 'off', 27 | }, 28 | ignores: ['**/fixtures', 'test', '**/*.md'], 29 | }) 30 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 awesome-compressor 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test-and-typecheck: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [22.x] 15 | 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'pnpm' 25 | 26 | - name: Enable Corepack (pnpm) 27 | run: | 28 | corepack enable 29 | corepack prepare pnpm@10 --activate 30 | 31 | - name: Setup pnpm 32 | uses: pnpm/action-setup@v2 33 | with: 34 | version: 10 35 | 36 | - name: Install dependencies 37 | run: pnpm install --frozen-lockfile 38 | 39 | - name: Typecheck 40 | run: pnpm typecheck 41 | 42 | - name: Run unit tests 43 | run: pnpm test 44 | - name: Build package 45 | run: pnpm build 46 | 47 | - name: Check bundle size 48 | run: pnpm size 49 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "type": "module", 4 | "private": true, 5 | "packageManager": "pnpm@10.18.1", 6 | "scripts": { 7 | "build": "vite build", 8 | "dev": "vite --port 3333 --open", 9 | "typecheck": "vue-tsc --noEmit", 10 | "preview": "vite preview", 11 | "test": "vitest" 12 | }, 13 | "dependencies": { 14 | "@element-plus/icons-vue": "^2.3.2", 15 | "@iconify-json/carbon": "^1.2.13", 16 | "@simon_he/git-fork-vue": "^0.0.18", 17 | "@vueuse/core": "^8.9.4", 18 | "cropperjs": "^1.6.2", 19 | "element-plus": "^2.11.4", 20 | "img-comparison-slider": "^8.0.6", 21 | "jszip": "^3.10.1", 22 | "lazy-js-utils": "^0.1.48", 23 | "vivid-typing": "^1.1.47", 24 | "vue": "^3.5.22", 25 | "vue-router": "^4.5.1" 26 | }, 27 | "devDependencies": { 28 | "@types/node": "^17.0.45", 29 | "@vitejs/plugin-vue": "^6.0.1", 30 | "@vue/test-utils": "^2.4.6", 31 | "unplugin-auto-import": "^19.3.0", 32 | "unplugin-vue-components": "^28.8.0", 33 | "vite": "^7.1.9", 34 | "vite-plugin-pages": "^0.33.1", 35 | "vite-plugin-pwa": "^0.20.5", 36 | "vue-tsc": "^2.2.12" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/tools/compressWithBrowserImageCompression.ts: -------------------------------------------------------------------------------- 1 | import imageCompression, { Options } from 'browser-image-compression' 2 | 3 | // browser-image-compression 工具 4 | export default async function compressWithBrowserImageCompression( 5 | file: File, 6 | options: { 7 | quality: number 8 | mode: string 9 | targetWidth?: number 10 | targetHeight?: number 11 | maxWidth?: number 12 | maxHeight?: number 13 | preserveExif?: boolean 14 | }, 15 | ): Promise { 16 | const { 17 | quality, 18 | mode, 19 | targetWidth, 20 | targetHeight, 21 | maxWidth, 22 | maxHeight, 23 | preserveExif = false, 24 | } = options 25 | 26 | const compressionOptions: Options = { 27 | useWebWorker: true, 28 | initialQuality: quality, 29 | alwaysKeepResolution: mode === 'keepSize', 30 | exifOrientation: 1, 31 | fileType: file.type, 32 | preserveExif: preserveExif, 33 | maxSizeMB: (file.size * 0.8) / (1024 * 1024), // 设置为原始文件大小的 MB 34 | maxWidthOrHeight: 35 | Math.min(maxWidth || targetWidth!, maxHeight || targetHeight!) || 36 | undefined, 37 | } 38 | 39 | const compressedBlob = await imageCompression(file, compressionOptions) 40 | 41 | // 如果压缩后文件大于或接近原文件大小,返回原文件 42 | // 使用 98% 阈值,避免微小的压缩效果 43 | if (compressedBlob.size >= file.size * 0.98) { 44 | return file 45 | } 46 | 47 | return compressedBlob 48 | } 49 | -------------------------------------------------------------------------------- /toolConfigs-examples.ts: -------------------------------------------------------------------------------- 1 | // 使用示例:如何使用 toolConfigs 功能 2 | 3 | import { compress } from './src/compress' 4 | 5 | // 示例 1: 使用 TinyPNG 工具配置 6 | async function compressWithTinyPNG(file: File) { 7 | const result = await compress(file, { 8 | quality: 0.8, 9 | mode: 'keepQuality', 10 | maxWidth: 1920, 11 | maxHeight: 1080, 12 | toolConfigs: [ 13 | { 14 | name: 'tinypng', 15 | key: 'your-tinypng-api-key-here', 16 | }, 17 | ], 18 | }) 19 | 20 | return result 21 | } 22 | 23 | // 示例 2: 配置多个工具 24 | async function compressWithMultipleToolConfigs(file: File) { 25 | const result = await compress(file, { 26 | quality: 0.7, 27 | mode: 'keepSize', 28 | toolConfigs: [ 29 | { 30 | name: 'tinypng', 31 | key: 'your-tinypng-api-key', 32 | customOption: 'value', 33 | }, 34 | { 35 | name: 'other-tool', 36 | apiKey: 'other-api-key', 37 | setting: 'high-quality', 38 | }, 39 | ], 40 | }) 41 | 42 | return result 43 | } 44 | 45 | // 示例 3: 获取所有工具的压缩结果 46 | async function getAllCompressionResults(file: File) { 47 | const results = await compress(file, { 48 | quality: 0.8, 49 | returnAllResults: true, 50 | toolConfigs: [ 51 | { 52 | name: 'tinypng', 53 | key: 'your-api-key', 54 | }, 55 | ], 56 | }) 57 | 58 | console.log('Best tool:', results.bestTool) 59 | console.log('All results:', results.allResults) 60 | 61 | return results 62 | } 63 | 64 | export { 65 | compressWithTinyPNG, 66 | compressWithMultipleToolConfigs, 67 | getAllCompressionResults, 68 | } 69 | -------------------------------------------------------------------------------- /test/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' 2 | import { logger, setLogger, resetLogger } from '../src' 3 | 4 | describe('logger (runtime injection)', () => { 5 | beforeEach(() => { 6 | resetLogger() 7 | }) 8 | 9 | afterEach(() => { 10 | resetLogger() 11 | }) 12 | 13 | it('allows injecting a custom logger implementation', () => { 14 | const spy = vi.fn() 15 | setLogger({ enabled: true, log: spy }) 16 | 17 | logger.log('hello') 18 | expect(spy).toHaveBeenCalledWith('hello') 19 | }) 20 | 21 | it('enable/disable control default console output', () => { 22 | // Ensure we're using the default impl 23 | resetLogger() 24 | 25 | const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) 26 | try { 27 | logger.disable() 28 | logger.log('should-not-appear') 29 | expect(consoleSpy).not.toHaveBeenCalled() 30 | 31 | logger.enable() 32 | logger.log('should-appear') 33 | expect(consoleSpy).toHaveBeenCalledWith('should-appear') 34 | } finally { 35 | consoleSpy.mockRestore() 36 | } 37 | }) 38 | 39 | it('resetLogger restores default behavior', () => { 40 | const customSpy = vi.fn() 41 | setLogger({ enabled: true, log: customSpy }) 42 | logger.log('custom') 43 | expect(customSpy).toHaveBeenCalled() 44 | 45 | resetLogger() 46 | 47 | const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) 48 | try { 49 | logger.enable() 50 | logger.log('after-reset') 51 | expect(consoleSpy).toHaveBeenCalledWith('after-reset') 52 | } finally { 53 | consoleSpy.mockRestore() 54 | } 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /src/tools/compressWithGifsicle.ts: -------------------------------------------------------------------------------- 1 | import gifsicle from 'gifsicle-wasm-browser' 2 | 3 | // gifsicle 工具 4 | export default async function compressWithGifsicle( 5 | file: File, 6 | options: { 7 | quality: number 8 | mode: string 9 | targetWidth?: number 10 | targetHeight?: number 11 | maxWidth?: number 12 | maxHeight?: number 13 | preserveExif?: boolean 14 | }, 15 | ): Promise { 16 | const { quality, mode, targetWidth, targetHeight, maxWidth, maxHeight } = 17 | options 18 | 19 | // Gifsicle 仅适用于 GIF 20 | if (!file.type.includes('gif')) { 21 | throw new Error('Gifsicle is only for GIF files') 22 | } 23 | 24 | // 注意:GIF 格式通常不包含 EXIF 信息,preserveExif 参数在此处被忽略 25 | 26 | let command: string 27 | if (mode === 'keepSize') { 28 | command = ` 29 | -O1 30 | --lossy=${Math.round((1 - quality) * 100)} 31 | ${file.name} 32 | -o /out/${file.name} 33 | ` 34 | } else { 35 | let resizeOption = '' 36 | if (targetWidth && targetHeight) { 37 | resizeOption = `--resize ${targetWidth}x${targetHeight}` 38 | } else if (maxWidth || maxHeight) { 39 | const maxSize = Math.min(maxWidth || 9999, maxHeight || 9999) 40 | resizeOption = `--resize-fit ${maxSize}x${maxSize}` 41 | } 42 | 43 | command = ` 44 | -O1 45 | ${resizeOption} 46 | ${file.name} 47 | -o /out/${file.name} 48 | ` 49 | } 50 | 51 | const result = await gifsicle.run({ 52 | input: [{ file, name: file.name }], 53 | command: [command], 54 | }) 55 | 56 | const compressedBlob = result[0] 57 | 58 | // 如果压缩后文件大于或接近原文件大小,返回原文件 59 | // 使用 98% 阈值,避免微小的压缩效果 60 | if (compressedBlob.size >= file.size * 0.98) { 61 | return file 62 | } 63 | 64 | return compressedBlob 65 | } 66 | -------------------------------------------------------------------------------- /RELEASE_CHECKLIST.md: -------------------------------------------------------------------------------- 1 | Release checklist for @awesome-compressor/browser-compress-image 2 | 3 | Use this checklist before publishing a new release to npm. These are automated and manual sanity checks that help avoid common release mistakes. 4 | 5 | 1. Update version 6 | - Update version in package.json (or use the repo's bump tool, e.g. `pnpm run release` which uses `bumpp`). 7 | 8 | 2. Run tests and typecheck 9 | - pnpm typecheck 10 | - pnpm test 11 | 12 | 3. Build 13 | - pnpm build 14 | - Verify `dist/index.js` and `dist/index.d.ts` exist 15 | 16 | 4. Size check (optional) 17 | - pnpm run size 18 | 19 | 5. Verify exports & sideEffects 20 | - Ensure `package.json` `exports` points to the correct dist entries 21 | - Ensure `sideEffects` is correct (false if no side-effectful modules) 22 | 23 | 6. Smoke test in a sample project (recommended) 24 | - Create a temporary dir and `npm link` or `pnpm pack` the built package 25 | - Import core API to validate runtime behavior, e.g.: 26 | ```js 27 | import { compress } from '@awesome-compressor/browser-compress-image' 28 | import { compressWithCanvas } from '@awesome-compressor/browser-compress-image/tools' 29 | ``` 30 | 31 | 7. Publish 32 | - Use the repository's release process, for example: 33 | - pnpm run release (this repo's script uses `bumpp` and `npm publish`) 34 | 35 | 8. Post-publish 36 | - Verify npm package page and test installation in a clean environment 37 | 38 | Notes/Tips 39 | 40 | - If you add new subpath exports, ensure the build generates matching `dist` paths or map them to the root `dist/index.js` as a fallback. 41 | - Keep `sideEffects` conservative. If you introduce modules that perform side effects during import, remove them from `sideEffects: false` or list exceptions. 42 | -------------------------------------------------------------------------------- /src/tools/compressWithCompressorJS.ts: -------------------------------------------------------------------------------- 1 | import Compressor from 'compressorjs' 2 | // compressorjs 工具 3 | export default async function compressWithCompressorJS( 4 | file: File, 5 | options: { 6 | quality: number 7 | mode: string 8 | targetWidth?: number 9 | targetHeight?: number 10 | maxWidth?: number 11 | maxHeight?: number 12 | preserveExif?: boolean 13 | }, 14 | ): Promise { 15 | const { 16 | quality, 17 | mode, 18 | targetWidth, 19 | targetHeight, 20 | maxWidth, 21 | maxHeight, 22 | preserveExif = false, 23 | } = options 24 | 25 | // CompressorJS 主要适用于 JPEG,对于其他格式效果有限 26 | if (!file.type.includes('jpeg') && !file.type.includes('jpg')) { 27 | throw new Error('CompressorJS is optimized for JPEG files') 28 | } 29 | 30 | return new Promise((resolve, reject) => { 31 | const compressorOptions: Compressor.Options = { 32 | quality, 33 | retainExif: preserveExif, // 如果保留EXIF,则不检查方向 34 | mimeType: file.type, 35 | success: (compressedBlob: Blob | File) => { 36 | const blob = compressedBlob as Blob 37 | 38 | // 如果压缩后文件大于或接近原文件大小,返回原文件 39 | // 使用 98% 阈值,避免微小的压缩效果 40 | if (blob.size >= file.size * 0.98) { 41 | resolve(file) 42 | } else { 43 | resolve(blob) 44 | } 45 | }, 46 | error: reject, 47 | } 48 | 49 | if (mode === 'keepQuality') { 50 | if (targetWidth) compressorOptions.width = targetWidth 51 | if (targetHeight) compressorOptions.height = targetHeight 52 | if (maxWidth) compressorOptions.maxWidth = maxWidth 53 | if (maxHeight) compressorOptions.maxHeight = maxHeight 54 | } 55 | 56 | // eslint-disable-next-line no-new 57 | new Compressor(file, compressorOptions) 58 | }) 59 | } 60 | -------------------------------------------------------------------------------- /test/basic.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest' 2 | import { compress } from '../src' 3 | 4 | // 创建测试用的图片文件(简单的二进制数据) 5 | function createTestFile(type: string = 'image/jpeg'): File { 6 | // 创建一个简单的二进制数据模拟图片 7 | const buffer = new ArrayBuffer(1024) 8 | const view = new Uint8Array(buffer) 9 | // 填充一些数据 10 | for (let i = 0; i < view.length; i++) { 11 | view[i] = i % 256 12 | } 13 | 14 | const blob = new Blob([buffer], { type }) 15 | return new File([blob], 'test.jpg', { type }) 16 | } 17 | 18 | describe('compress function', () => { 19 | it('should have correct function signature', () => { 20 | expect(typeof compress).toBe('function') 21 | expect(compress.length).toBe(3) 22 | }) 23 | 24 | it('should work with basic usage', async () => { 25 | const file = createTestFile() 26 | // 测试基本调用不会出错 27 | try { 28 | const result = await compress(file) 29 | expect(result).toBeDefined() 30 | } catch (error) { 31 | // 在测试环境中可能会因为缺少浏览器 API 而失败,这是正常的 32 | expect(error).toBeDefined() 33 | } 34 | }) 35 | 36 | it('should accept type parameter', async () => { 37 | const file = createTestFile() 38 | // 测试类型参数被正确接受 39 | try { 40 | const result1 = await compress(file, 0.6, 'blob') 41 | const result2 = await compress(file, 0.6, 'file') 42 | const result3 = await compress(file, 0.6, 'base64') 43 | const result4 = await compress(file, 0.6, 'arrayBuffer') 44 | 45 | // 在浏览器环境中这些应该成功 46 | expect(result1).toBeDefined() 47 | expect(result2).toBeDefined() 48 | expect(result3).toBeDefined() 49 | expect(result4).toBeDefined() 50 | } catch (error) { 51 | // 在测试环境中可能会因为缺少浏览器 API 而失败,这是正常的 52 | expect(error).toBeDefined() 53 | } 54 | }) 55 | }) 56 | 57 | describe('Hi', () => { 58 | it('should works', () => { 59 | expect(1 + 1).toEqual(2) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /src/utils/abort.ts: -------------------------------------------------------------------------------- 1 | // Utility: run a function and race it against an AbortSignal and timeout 2 | export async function runWithAbortAndTimeout( 3 | fn: () => Promise, 4 | signal?: AbortSignal, 5 | timeoutMs?: number, 6 | ): Promise { 7 | if (!signal && !timeoutMs) return fn() 8 | 9 | return await new Promise((resolve, reject) => { 10 | let finished = false 11 | 12 | // 当 fn 完成时 13 | fn() 14 | .then((v) => { 15 | if (finished) return 16 | finished = true 17 | resolve(v) 18 | }) 19 | .catch((err) => { 20 | if (finished) return 21 | finished = true 22 | reject(err) 23 | }) 24 | 25 | // 处理超时 26 | let timer: number | undefined 27 | if (typeof timeoutMs === 'number' && timeoutMs > 0) { 28 | timer = window.setTimeout(() => { 29 | if (finished) return 30 | finished = true 31 | reject(new Error('Compression timed out')) 32 | }, timeoutMs) 33 | } 34 | 35 | // 处理 AbortSignal 36 | const onAbort = () => { 37 | if (finished) return 38 | finished = true 39 | reject(new Error('Compression aborted')) 40 | } 41 | 42 | if (signal) { 43 | if (signal.aborted) { 44 | onAbort() 45 | } else { 46 | signal.addEventListener('abort', onAbort, { once: true }) 47 | } 48 | } 49 | 50 | // 清理 51 | const cleanup = () => { 52 | if (typeof timer !== 'undefined') clearTimeout(timer) 53 | if (signal) signal.removeEventListener('abort', onAbort) 54 | } 55 | 56 | // ensure cleanup on resolve/reject 57 | ;(async () => { 58 | try { 59 | const res = await fn() 60 | if (!finished) { 61 | finished = true 62 | resolve(res) 63 | } 64 | } catch (err) { 65 | if (!finished) { 66 | finished = true 67 | reject(err) 68 | } 69 | } finally { 70 | cleanup() 71 | } 72 | })() 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /playground/src/pwa/wasmWarmup.ts: -------------------------------------------------------------------------------- 1 | // Preload @jsquash modules to trigger WASM fetch and cache 2 | // This gently imports modules and runs a tiny no-op encode to force initialization. 3 | 4 | export async function warmupJsquashWasm(): Promise { 5 | if (typeof window === 'undefined') return 6 | try { 7 | const formats = ['png', 'jpeg', 'webp'] as const 8 | // Trigger module download and WASM init via CDN imports 9 | await Promise.all( 10 | formats.map(async (fmt) => { 11 | try { 12 | await import( 13 | /* @vite-ignore */ `https://unpkg.com/@jsquash/${fmt}@latest?module` 14 | ) 15 | } catch (e) { 16 | console.warn(`[warmup] CDN import failed for ${fmt}:`, e) 17 | } 18 | }), 19 | ) 20 | 21 | // Additionally fetch the underlying WASM files to seed SW runtime cache. 22 | // Some packages (notably webp) place encoder/decoder WASM files under 23 | // codec/enc and codec/dec. Use an array of wasm paths per format so we 24 | // can fetch all relevant files. 25 | const wasmFiles: Record<(typeof formats)[number], string[]> = { 26 | png: ['pkg/squoosh_png_bg.wasm'], 27 | jpeg: ['enc/mozjpeg_enc.wasm', 'dec/mozjpeg_dec.wasm'], 28 | // webp ships separate encoder/decoder .wasm under codec/enc and codec/dec 29 | webp: ['dec/webp_dec.wasm', 'enc/webp_enc.wasm'], 30 | } 31 | 32 | await Promise.all( 33 | formats.map(async (fmt) => { 34 | const files = wasmFiles[fmt] || [] 35 | await Promise.all( 36 | files.map(async (file) => { 37 | // include @latest to match the module import above and point to 38 | // the correct nested path (e.g. /codec/dec/webp_dec.wasm) 39 | const url = `https://unpkg.com/@jsquash/${fmt}@latest/codec/${file}` 40 | try { 41 | await fetch(url, { mode: 'cors', cache: 'reload' }) 42 | } catch (e) { 43 | console.warn( 44 | `[warmup] WASM fetch failed for ${fmt} (${file}):`, 45 | e, 46 | ) 47 | } 48 | }), 49 | ) 50 | }), 51 | ) 52 | } catch (err) { 53 | console.warn('[warmup] JSQuash module import failed:', err) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /playground/vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import path from 'node:path' 4 | import Vue from '@vitejs/plugin-vue' 5 | import { VitePWA } from 'vite-plugin-pwa' 6 | import AutoImport from 'unplugin-auto-import/vite' 7 | import { defineConfig } from 'vite' 8 | import Pages from 'vite-plugin-pages' 9 | 10 | export default defineConfig({ 11 | base: './', 12 | resolve: { 13 | alias: { 14 | '~/': `${path.resolve(__dirname, 'src')}/`, 15 | }, 16 | }, 17 | 18 | plugins: [ 19 | Vue(), 20 | Pages(), 21 | 22 | // https://github.com/antfu/unplugin-auto-import 23 | AutoImport({ 24 | imports: ['vue', 'vue-router', '@vueuse/core'], 25 | dts: true, 26 | }) as any, 27 | 28 | // PWA support with offline caching and WASM precache 29 | VitePWA({ 30 | registerType: 'autoUpdate', 31 | includeAssets: ['icon.png'], 32 | workbox: { 33 | // Precache common WASM modules and root assets 34 | globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm}'], 35 | // Runtime caching for CDN modules like @jsquash and unpkg 36 | runtimeCaching: [ 37 | { 38 | urlPattern: ({ url }: any) => 39 | url.origin.includes('unpkg.com') || 40 | url.origin.includes('cdn.jsdelivr.net'), 41 | handler: 'StaleWhileRevalidate', 42 | options: { 43 | cacheName: 'cdn-modules', 44 | expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 }, 45 | }, 46 | }, 47 | { 48 | // Cache local WASM under /wasm/ 49 | urlPattern: /\/wasm\/(.*)\.wasm$/, 50 | handler: 'CacheFirst', 51 | options: { 52 | cacheName: 'wasm-cache', 53 | cacheableResponse: { statuses: [200] }, 54 | }, 55 | }, 56 | ], 57 | }, 58 | manifest: { 59 | name: 'Awesome Compressor Playground', 60 | short_name: 'Compressor', 61 | theme_color: '#0ea5e9', 62 | background_color: '#0b1221', 63 | display: 'standalone', 64 | start_url: '.', 65 | icons: [ 66 | { 67 | src: 'icon.png', 68 | sizes: '512x512', 69 | type: 'image/png', 70 | }, 71 | ], 72 | }, 73 | }), 74 | ], 75 | }) 76 | -------------------------------------------------------------------------------- /examples.md: -------------------------------------------------------------------------------- 1 | # 使用示例 2 | 3 | ## 基础用法(返回 Blob) 4 | 5 | ```typescript 6 | import { compress } from '@awesome-compressor/browser-compress-image' 7 | 8 | const file = /* 你的文件 */ 9 | const compressedBlob = await compress(file, 0.6) // 默认返回 Blob 10 | ``` 11 | 12 | ## 返回不同类型的结果 13 | 14 | ### 返回 Blob 15 | 16 | ```typescript 17 | const compressedBlob = await compress(file, 0.6, 'blob') 18 | console.log(compressedBlob instanceof Blob) // true 19 | ``` 20 | 21 | ### 返回 File 22 | 23 | ```typescript 24 | const compressedFile = await compress(file, 0.6, 'file') 25 | console.log(compressedFile instanceof File) // true 26 | console.log(compressedFile.name) // 原始文件名 27 | ``` 28 | 29 | ### 返回 Base64 字符串 30 | 31 | ```typescript 32 | const compressedBase64 = await compress(file, 0.6, 'base64') 33 | console.log(typeof compressedBase64) // 'string' 34 | console.log(compressedBase64.startsWith('data:')) // true 35 | ``` 36 | 37 | ### 返回 ArrayBuffer 38 | 39 | ```typescript 40 | const compressedArrayBuffer = await compress(file, 0.6, 'arrayBuffer') 41 | console.log(compressedArrayBuffer instanceof ArrayBuffer) // true 42 | ``` 43 | 44 | ## TypeScript 类型支持 45 | 46 | 函数提供完整的 TypeScript 类型支持: 47 | 48 | ```typescript 49 | import type { 50 | CompressResult, 51 | CompressResultType, 52 | } from '@awesome-compressor/browser-compress-image' 53 | import { compress } from '@awesome-compressor/browser-compress-image' 54 | 55 | // 类型会根据第三个参数自动推断 56 | const blob = await compress(file, 0.6, 'blob') // 类型: Blob 57 | const file2 = await compress(file, 0.6, 'file') // 类型: File 58 | const base64 = await compress(file, 0.6, 'base64') // 类型: string 59 | const buffer = await compress(file, 0.6, 'arrayBuffer') // 类型: ArrayBuffer 60 | ``` 61 | 62 | ## 实际应用示例 63 | 64 | ### 直接上传压缩后的文件 65 | 66 | ```typescript 67 | const compressedFile = await compress(originalFile, 0.6, 'file') 68 | const formData = new FormData() 69 | formData.append('image', compressedFile) 70 | fetch('/upload', { method: 'POST', body: formData }) 71 | ``` 72 | 73 | ### 显示压缩后的图片预览 74 | 75 | ```typescript 76 | const compressedBase64 = await compress(originalFile, 0.6, 'base64') 77 | const img = document.createElement('img') 78 | img.src = compressedBase64 79 | document.body.appendChild(img) 80 | ``` 81 | 82 | ### 处理二进制数据 83 | 84 | ```typescript 85 | const compressedBuffer = await compress(originalFile, 0.6, 'arrayBuffer') 86 | // 可以进一步处理 ArrayBuffer,比如发送到 WebSocket 或进行其他二进制操作 87 | ``` 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // 主要的压缩函数 - 保持向后兼容 2 | export { compress, compressWithStats } from './compress' 3 | // ...type exports consolidated below 4 | 5 | // Enhanced compression with queue and worker support (NEW) 6 | export { 7 | clearCompressionQueue, 8 | compressEnhanced, 9 | compressEnhancedBatch, 10 | configureCompression, 11 | getCompressionStats, 12 | waitForCompressionInitialization, 13 | } from './compressEnhanced' 14 | // ...type exports consolidated below 15 | 16 | // 新的可配置压缩系统 17 | export { 18 | compressWithTools, 19 | globalToolRegistry, 20 | ToolRegistry, 21 | } from './compressWithTools' 22 | 23 | // 按需导入的工具和注册函数 24 | export * from './tools' 25 | 26 | // TinyPNG 相关工具 27 | export { 28 | clearTinyPngCache, 29 | configureTinyPngCache, 30 | getTinyPngCacheInfo, 31 | getTinyPngCacheSize, 32 | } from './tools/compressWithTinyPng' 33 | 34 | // 类型定义 35 | export * from './types' 36 | 37 | // Queue and worker utilities 38 | export { 39 | CompressionQueue, 40 | compressionQueue, 41 | PerformanceDetector, 42 | } from './utils/compressionQueue' 43 | 44 | export { 45 | CompressionWorkerManager, 46 | compressionWorkerManager, 47 | } from './utils/compressionWorker' 48 | 49 | // 工具函数 50 | export { LRUCache } from './utils/lruCache' 51 | 52 | // Memory management 53 | export { 54 | checkMemoryBeforeOperation, 55 | MemoryManager, 56 | memoryManager, 57 | } from './utils/memoryManager' 58 | 59 | // Expose internal logger so consumers can enable debug logs at runtime 60 | export { default as logger, setLogger, resetLogger } from './utils/logger' 61 | 62 | // Image preprocessing 63 | export { preprocessImage } from './utils/preprocessImage' 64 | 65 | // Image conversion (including SVG support) 66 | export { 67 | convertImage, 68 | renderSvgToCanvas, 69 | encodeSvgToFormat, 70 | detectFileFormat, 71 | isSvgContent, 72 | } from './conversion' 73 | export type { 74 | TargetFormat, 75 | SourceFormat, 76 | ImageConvertOptions, 77 | ImageConvertResult, 78 | } from './conversion' 79 | 80 | // JSQuash WASM helpers (for PWA warm-up and diagnostics) 81 | export { 82 | ensureWasmLoaded, 83 | configureWasmLoading, 84 | diagnoseJsquashAvailability, 85 | downloadWasmFiles, 86 | } from './tools/compressWithJsquash' 87 | 88 | // Centralized type-only exports for clarity 89 | export type { CompressionStats } from './compress' 90 | export type { EnhancedCompressOptions } from './compressEnhanced' 91 | export type { 92 | CompressorFunction, 93 | CompressorTool, 94 | CompressWithToolsOptions, 95 | } from './compressWithTools' 96 | export type { CompressionTask, QueueStats } from './utils/compressionQueue' 97 | export type { WorkerMessage, WorkerTask } from './utils/compressionWorker' 98 | export type { MemoryStats, MemoryThresholds } from './utils/memoryManager' 99 | export type { PreprocessOptions, CropRect, ResizeOptions } from './types' 100 | -------------------------------------------------------------------------------- /docs/PERFORMANCE_IMPROVEMENTS.md: -------------------------------------------------------------------------------- 1 | # 图片压缩性能优化完成报告 2 | 3 | ## 优化概览 4 | 5 | ✅ **已完成的优化项目** 6 | 7 | 1. **队列管理系统** (`src/utils/compressionQueue.ts`) 8 | - 实现了智能并发控制 9 | - 桌面端最多5个并发,移动端2个并发 10 | - 基于设备性能动态调整并发数量 11 | - 支持任务优先级和统计信息 12 | 13 | 2. **Worker支持系统** (`src/utils/compressionWorker.ts`) 14 | - 实现了Web Worker支持,将压缩计算移到后台线程 15 | - 自动检测Worker兼容性,不兼容时降级到主线程 16 | - 识别DOM依赖工具(canvas, jsquash),自动使用主线程 17 | - Worker兼容工具:browser-image-compression, compressorjs, gifsicle, tinypng 18 | 19 | 3. **设备性能检测** (集成在压缩队列中) 20 | - 自动检测移动设备、CPU核心数、内存大小 21 | - 根据设备性能评估(低/中/高)调整并发策略 22 | - 动态计算最优并发数量 23 | 24 | 4. **内存管理优化** (`src/utils/memoryManager.ts`) 25 | - 实现内存使用监控和清理机制 26 | - 自动管理ObjectURL、Image和Canvas元素 27 | - 内存压力过高时自动触发清理 28 | - 定期内存检查和资源回收 29 | 30 | 5. **增强压缩API** (`src/compressEnhanced.ts`) 31 | - 新的`compressEnhanced`和`compressEnhancedBatch`函数 32 | - 集成队列管理、Worker支持和内存优化 33 | - 向后兼容,可直接替换原有compress调用 34 | - 支持超时控制和错误处理 35 | 36 | 6. **完整的类型系统和导出** 37 | - 更新了`src/index.ts`导出所有新功能 38 | - 完整的TypeScript类型定义 39 | - 单例模式确保资源统一管理 40 | 41 | ## 性能提升效果 42 | 43 | ### 并发控制 44 | 45 | - **之前**: 无限制并发,容易导致内存溢出和浏览器卡死 46 | - **现在**: 智能并发控制,根据设备性能自适应调整 47 | 48 | ### 内存管理 49 | 50 | - **之前**: 手动管理资源,容易泄漏 51 | - **现在**: 自动资源管理,定期清理,内存使用优化 52 | 53 | ### 计算性能 54 | 55 | - **之前**: 所有压缩在主线程,阻塞UI 56 | - **现在**: Worker后台处理,主线程保持响应 57 | 58 | ### 设备适配 59 | 60 | - **之前**: 固定策略,移动设备性能差 61 | - **现在**: 移动端2并发,桌面端最多5并发,动态调整 62 | 63 | ## 使用方式 64 | 65 | ### 简单替换现有代码 66 | 67 | ```typescript 68 | // 旧代码 69 | const result = await compress(file, { quality: 0.8 }) 70 | 71 | // 新代码(自动获得所有性能优化) 72 | const result = await compressEnhanced(file, { 73 | quality: 0.8, 74 | useWorker: true, // 启用Worker支持 75 | useQueue: true, // 启用队列管理 76 | }) 77 | ``` 78 | 79 | ### 批量处理优化 80 | 81 | ```typescript 82 | // 替换App.vue中的批量压缩逻辑 83 | const results = await compressEnhancedBatch(files, { 84 | quality: 0.8, 85 | useWorker: true, 86 | useQueue: true, 87 | }) 88 | ``` 89 | 90 | ## 兼容性保证 91 | 92 | - ✅ 完全向后兼容,原有`compress`函数继续可用 93 | - ✅ Worker不支持时自动降级到主线程 94 | - ✅ DOM依赖工具自动使用主线程 95 | - ✅ 所有浏览器环境都能正常工作 96 | 97 | ## 配置和监控 98 | 99 | ```typescript 100 | import { 101 | getCompressionStats, 102 | configureCompression, 103 | memoryManager, 104 | } from 'browser-compress-image' 105 | 106 | // 查看实时统计 107 | const stats = getCompressionStats() 108 | 109 | // 手动调整并发数 110 | configureCompression({ maxConcurrency: 3 }) 111 | 112 | // 检查内存状态 113 | const memoryStats = memoryManager.getMemoryStats() 114 | ``` 115 | 116 | ## 技术特点 117 | 118 | 1. **零配置优化**: 默认开启所有优化,无需手动配置 119 | 2. **智能降级**: 不支持的功能自动降级,确保兼容性 120 | 3. **资源管理**: 自动管理内存和DOM资源,防止泄漏 121 | 4. **性能监控**: 提供完整的统计和监控API 122 | 5. **类型安全**: 完整的TypeScript类型支持 123 | 124 | ## 下一步建议 125 | 126 | 1. **在App.vue中使用新API**: 将`compressImage`函数改为使用`compressEnhanced` 127 | 2. **监控性能**: 在开发环境启用性能监控日志 128 | 3. **测试效果**: 在大量图片场景下测试性能改进 129 | 4. **用户体验**: 关注UI响应性和压缩速度的提升 130 | 131 | 所有优化已经完成并可以立即使用。用户现在可以处理大量图片而不会遇到性能瓶颈或浏览器崩溃的问题。 132 | -------------------------------------------------------------------------------- /test/gifsicle-protection.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | import compressWithGifsicle from '../src/tools/compressWithGifsicle' 3 | 4 | // Mock gifsicle-wasm-browser 5 | vi.mock('gifsicle-wasm-browser', () => ({ 6 | default: { 7 | run: vi.fn(), 8 | }, 9 | })) 10 | 11 | describe('compressWithGifsicle - 文件大小保护测试', () => { 12 | it('当压缩后文件更大时应返回原文件', async () => { 13 | const gifsicle = await import('gifsicle-wasm-browser') 14 | 15 | // 创建模拟的原文件 16 | const originalFile = new File(['original content'], 'test.gif', { 17 | type: 'image/gif', 18 | }) 19 | 20 | // 创建模拟的压缩后文件(更大) 21 | const compressedBlob = new Blob( 22 | ['compressed content that is much larger'], 23 | { 24 | type: 'image/gif', 25 | }, 26 | ) 27 | 28 | // Mock gifsicle.run 返回更大的文件 29 | vi.mocked(gifsicle.default.run).mockResolvedValue([compressedBlob]) 30 | 31 | const options = { 32 | quality: 0.8, 33 | mode: 'keepSize', 34 | } 35 | 36 | const result = await compressWithGifsicle(originalFile, options) 37 | 38 | // 应该返回原文件,因为压缩后文件更大 39 | expect(result).toBe(originalFile) 40 | expect(result.size).toBe(originalFile.size) 41 | }) 42 | 43 | it('当压缩后文件更小时应返回压缩后的文件', async () => { 44 | const gifsicle = await import('gifsicle-wasm-browser') 45 | 46 | // 创建模拟的原文件 47 | const originalFile = new File( 48 | ['original content that is much larger'], 49 | 'test.gif', 50 | { 51 | type: 'image/gif', 52 | }, 53 | ) 54 | 55 | // 创建模拟的压缩后文件(更小) 56 | const compressedBlob = new Blob(['small'], { 57 | type: 'image/gif', 58 | }) 59 | 60 | // Mock gifsicle.run 返回更小的文件 61 | vi.mocked(gifsicle.default.run).mockResolvedValue([compressedBlob]) 62 | 63 | const options = { 64 | quality: 0.8, 65 | mode: 'keepSize', 66 | } 67 | 68 | const result = await compressWithGifsicle(originalFile, options) 69 | 70 | // 应该返回压缩后的文件,因为它更小 71 | expect(result).toBe(compressedBlob) 72 | expect(result.size).toBe(compressedBlob.size) 73 | }) 74 | 75 | it('当压缩后文件略小但在阈值内时应返回原文件', async () => { 76 | const gifsicle = await import('gifsicle-wasm-browser') 77 | 78 | // 创建模拟的原文件(1000 bytes) 79 | const originalContent = 'x'.repeat(1000) 80 | const originalFile = new File([originalContent], 'test.gif', { 81 | type: 'image/gif', 82 | }) 83 | 84 | // 创建模拟的压缩后文件(990 bytes,节省不到 2%) 85 | const compressedContent = 'x'.repeat(990) 86 | const compressedBlob = new Blob([compressedContent], { 87 | type: 'image/gif', 88 | }) 89 | 90 | // Mock gifsicle.run 返回略小的文件 91 | vi.mocked(gifsicle.default.run).mockResolvedValue([compressedBlob]) 92 | 93 | const options = { 94 | quality: 0.8, 95 | mode: 'keepSize', 96 | } 97 | 98 | const result = await compressWithGifsicle(originalFile, options) 99 | 100 | // 应该返回原文件,因为压缩效果不明显(在 98% 阈值内) 101 | expect(result).toBe(originalFile) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /src/utils/lruCache.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * LRU (Least Recently Used) 缓存实现 3 | * 当缓存达到最大容量时,自动淘汰最久未使用的项目 4 | */ 5 | import logger from './logger' 6 | 7 | export class LRUCache { 8 | private cache: Map 9 | private maxSize: number 10 | 11 | constructor(maxSize: number = 50) { 12 | this.cache = new Map() 13 | this.maxSize = maxSize 14 | } 15 | 16 | /** 17 | * 获取缓存项,如果存在则将其移到最新位置 18 | */ 19 | get(key: K): V | undefined { 20 | const value = this.cache.get(key) 21 | if (value !== undefined) { 22 | // 将访问的项移到最后(最新使用) 23 | this.cache.delete(key) 24 | this.cache.set(key, value) 25 | } 26 | return value 27 | } 28 | 29 | /** 30 | * 设置缓存项,如果超过最大容量则淘汰最久未使用的项 31 | */ 32 | set(key: K, value: V): void { 33 | // 如果key已存在,先删除旧的 34 | if (this.cache.has(key)) { 35 | this.cache.delete(key) 36 | } else if (this.cache.size >= this.maxSize) { 37 | // 如果缓存已满,删除最久未使用的项(第一个) 38 | const firstKey = this.cache.keys().next().value 39 | if (firstKey !== undefined) { 40 | this.cache.delete(firstKey) 41 | logger.log( 42 | `LRU Cache: Removed least recently used entry for key: ${String(firstKey)}`, 43 | ) 44 | } 45 | } 46 | 47 | this.cache.set(key, value) 48 | } 49 | 50 | /** 51 | * 检查缓存中是否存在指定的key 52 | */ 53 | has(key: K): boolean { 54 | return this.cache.has(key) 55 | } 56 | 57 | /** 58 | * 清空所有缓存 59 | */ 60 | clear(): void { 61 | this.cache.clear() 62 | } 63 | 64 | /** 65 | * 获取当前缓存大小 66 | */ 67 | get size(): number { 68 | return this.cache.size 69 | } 70 | 71 | /** 72 | * 获取最大缓存大小 73 | */ 74 | get maxCapacity(): number { 75 | return this.maxSize 76 | } 77 | 78 | /** 79 | * 设置新的最大缓存大小 80 | */ 81 | setMaxSize(newMaxSize: number): void { 82 | this.maxSize = newMaxSize 83 | 84 | // 如果当前缓存超过新的最大大小,淘汰多余的项 85 | while (this.cache.size > this.maxSize) { 86 | const firstKey = this.cache.keys().next().value 87 | if (firstKey !== undefined) { 88 | this.cache.delete(firstKey) 89 | logger.log( 90 | `LRU Cache: Removed entry due to size reduction: ${String(firstKey)}`, 91 | ) 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * 获取所有缓存条目的迭代器 98 | */ 99 | entries(): IterableIterator<[K, V]> { 100 | return this.cache.entries() 101 | } 102 | 103 | /** 104 | * 获取所有缓存的key 105 | */ 106 | keys(): IterableIterator { 107 | return this.cache.keys() 108 | } 109 | 110 | /** 111 | * 获取所有缓存的value 112 | */ 113 | values(): IterableIterator { 114 | return this.cache.values() 115 | } 116 | 117 | /** 118 | * 删除指定的缓存项 119 | */ 120 | delete(key: K): boolean { 121 | return this.cache.delete(key) 122 | } 123 | 124 | /** 125 | * 获取缓存统计信息 126 | */ 127 | getStats(): { 128 | size: number 129 | maxSize: number 130 | usageRate: number 131 | } { 132 | return { 133 | size: this.cache.size, 134 | maxSize: this.maxSize, 135 | usageRate: (this.cache.size / this.maxSize) * 100, 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/conversion/convertImage.ts: -------------------------------------------------------------------------------- 1 | import type { ImageConvertOptions, ImageConvertResult } from './types' 2 | import { 3 | MIME_MAP, 4 | encodeWithJsquash, 5 | encodeWithCanvas, 6 | encodeIcoFromImage, 7 | encodeSvgToFormat, 8 | detectFileFormat, 9 | isSvgContent, 10 | } from './encoders' 11 | import logger from '../utils/logger' 12 | 13 | export async function convertImage( 14 | fileOrBlob: File | Blob, 15 | options: ImageConvertOptions, 16 | ): Promise { 17 | const startTime = performance.now() 18 | 19 | try { 20 | // Convert Blob to File if needed 21 | const file = 22 | fileOrBlob instanceof File 23 | ? fileOrBlob 24 | : new File([fileOrBlob], 'image', { type: fileOrBlob.type }) 25 | 26 | const { targetFormat, quality } = options 27 | 28 | // Detect source format 29 | const sourceFormat = detectFileFormat(file) 30 | logger.debug( 31 | `Detected source format: ${sourceFormat}, target format: ${targetFormat}`, 32 | ) 33 | 34 | let blob: Blob 35 | 36 | // Handle SVG source format separately 37 | if (sourceFormat === 'svg') { 38 | try { 39 | // Read SVG content as text 40 | const svgContent = await file.text() 41 | 42 | // Validate SVG content 43 | if (!isSvgContent(svgContent)) { 44 | throw new Error( 45 | 'File detected as SVG but does not contain valid SVG content', 46 | ) 47 | } 48 | 49 | // Convert SVG to target format 50 | blob = await encodeSvgToFormat(svgContent, targetFormat, options) 51 | } catch (svgError) { 52 | logger.error('SVG conversion failed:', svgError) 53 | throw new Error( 54 | `SVG conversion failed: ${svgError instanceof Error ? svgError.message : String(svgError)}`, 55 | ) 56 | } 57 | } else { 58 | // Handle regular raster image conversion 59 | switch (targetFormat) { 60 | case 'png': 61 | case 'jpeg': 62 | case 'webp': 63 | try { 64 | // First try JSQuash for better quality 65 | blob = await encodeWithJsquash(file, targetFormat, quality) 66 | } catch (jsquashError) { 67 | logger.warn( 68 | `JSQuash failed for ${targetFormat}, falling back to Canvas:`, 69 | jsquashError, 70 | ) 71 | // Fallback to Canvas 72 | blob = await encodeWithCanvas(file, targetFormat, quality) 73 | } 74 | break 75 | 76 | case 'ico': 77 | blob = await encodeIcoFromImage(file, options) 78 | break 79 | 80 | default: 81 | throw new Error(`Unsupported target format: ${targetFormat}`) 82 | } 83 | } 84 | 85 | const duration = performance.now() - startTime 86 | 87 | return { 88 | blob, 89 | mime: MIME_MAP[targetFormat], 90 | duration, 91 | } 92 | } catch (error) { 93 | logger.error('Image conversion failed:', error) 94 | if (error instanceof Error) { 95 | throw new Error(`Image conversion failed: ${error.message}`) 96 | } 97 | 98 | throw new Error(`Image conversion failed: ${String(error)}`) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | // Minimal configurable logger for the library. 2 | // Default: silent. Enable via env var DEBUG_BROWSER_COMPRESS_IMAGE=true or at runtime via logger.enable(). 3 | 4 | export type LoggerLike = { 5 | enabled?: boolean 6 | enable?: () => void 7 | disable?: () => void 8 | log?: (...args: any[]) => void 9 | debug?: (...args: any[]) => void 10 | warn?: (...args: any[]) => void 11 | error?: (...args: any[]) => void 12 | table?: (data: any) => void 13 | } 14 | 15 | // initial implementation used by default 16 | const initialImpl: LoggerLike = { 17 | enabled: (() => { 18 | try { 19 | return ( 20 | typeof process !== 'undefined' && 21 | process.env && 22 | (process.env.DEBUG_BROWSER_COMPRESS_IMAGE === 'true' || 23 | process.env.NODE_ENV === 'development') 24 | ) 25 | } catch (e) { 26 | return false 27 | } 28 | })(), 29 | enable() { 30 | this.enabled = true 31 | }, 32 | disable() { 33 | this.enabled = false 34 | }, 35 | log(...args: any[]) { 36 | if (this.enabled) console.log(...args) 37 | }, 38 | debug(...args: any[]) { 39 | if (this.enabled) 40 | console.debug ? console.debug(...args) : console.log(...args) 41 | }, 42 | warn(...args: any[]) { 43 | if (this.enabled) console.warn(...args) 44 | }, 45 | error(...args: any[]) { 46 | if (this.enabled) console.error(...args) 47 | }, 48 | table(data: any) { 49 | if (this.enabled && (console as any).table) (console as any).table(data) 50 | }, 51 | } 52 | 53 | // internal implementation that can be swapped 54 | let impl: LoggerLike = { ...initialImpl } 55 | 56 | // public wrapper exported across the codebase — methods delegate to `impl` so replacing `impl` updates behavior 57 | export const logger = { 58 | get enabled() { 59 | return Boolean(impl.enabled) 60 | }, 61 | enable() { 62 | impl.enable ? impl.enable() : (impl.enabled = true) 63 | }, 64 | disable() { 65 | impl.disable ? impl.disable() : (impl.enabled = false) 66 | }, 67 | log(...args: any[]) { 68 | if (impl.enabled) { 69 | if (impl.log) return impl.log(...args) 70 | return console.log(...args) 71 | } 72 | }, 73 | debug(...args: any[]) { 74 | if (impl.enabled) { 75 | if (impl.debug) return impl.debug(...args) 76 | return console.debug ? console.debug(...args) : console.log(...args) 77 | } 78 | }, 79 | warn(...args: any[]) { 80 | if (impl.enabled) { 81 | if (impl.warn) return impl.warn(...args) 82 | return console.warn(...args) 83 | } 84 | }, 85 | error(...args: any[]) { 86 | if (impl.enabled) { 87 | if (impl.error) return impl.error(...args) 88 | return console.error(...args) 89 | } 90 | }, 91 | table(data: any) { 92 | if (impl.enabled) { 93 | if (impl.table) return impl.table(data) 94 | if ((console as any).table) return (console as any).table(data) 95 | } 96 | }, 97 | } 98 | 99 | // Allow consumers to replace the logger implementation at runtime 100 | export function setLogger(custom: LoggerLike) { 101 | impl = { ...initialImpl, ...(custom || {}) } 102 | } 103 | 104 | // Restore to default behavior 105 | export function resetLogger() { 106 | impl = { ...initialImpl } 107 | } 108 | 109 | export default logger 110 | -------------------------------------------------------------------------------- /docs/image-conversion-tasks.md: -------------------------------------------------------------------------------- 1 | # 图片格式转换任务拆分(开发清单) 2 | 3 | > 注:当前仅规划与拆分,不修改现有代码。落地时保持非侵入与向后兼容。 4 | 5 | ## 0. 预备 6 | 7 | - [ ] 评估 `icojs` 或轻量 ICO 编码器(浏览器端可用) 8 | - [x] 评估 webp/avif 在目标浏览器矩阵的可用性(已存在 JSQuash 作为核心能力) 9 | 10 | ## 1. 库:转换核心(src/conversion/\*)✅ 已完成 11 | 12 | - [x] 新建目录 `src/conversion/` 13 | - [x] `types.ts`:定义 `TargetFormat`、`ImageConvertOptions`、`ImageConvertResult` 14 | - [x] `encoders.ts`: 15 | - [x] `encodeWithJsquash(blob, {format, quality})` 16 | - [x] `encodeWithCanvas(blob, {format, quality})` 17 | - [x] `encodeIcoFromImage(blob, options)`(当前为占位实现,需改进) 18 | - [x] `convertImage.ts`:根据 `targetFormat` 与环境能力选择编码器并输出 Blob;统一错误与 MIME 映射 19 | - [x] `index.ts`:输出 `convertImage` 与类型 20 | 21 | 验收标准: 22 | 23 | - 输入 png/jpeg/webp → 输出对应 MIME 的 Blob;ico 生成 `image/x-icon` 24 | - 质量参数对 jpeg/webp 生效;png/ico 忽略或采用内部策略 25 | - 大小控制、异常转换有明确错误信息 26 | 27 | ## 2. 库:编排(src/orchestrators/\*)✅ 已完成 28 | 29 | - [x] 新建目录 `src/orchestrators/` 30 | - [x] `compareConversion.ts`:实现 `buildConversionColumn({ file, compressOptions, convertOptions })` 31 | - [x] C→T:遍历 `compress(..., { returnAllResults: true })` 成功项并转换 32 | - [x] T:`convertImage(file)` 33 | - [x] T→C:`convertImage(file)` 结果再 `compress`(基于目标格式工具集) 34 | - [x] 产出项包含 meta/size/ratio/duration/success/error 与参数回溯 35 | 36 | 验收标准: 37 | 38 | - 三类项齐备;单项失败不影响其他项 39 | - 统计口径与既有压缩结果一致(size、ratio、duration) 40 | 41 | ## 3. Playground:UI 集成 ✅ 已完成 42 | 43 | - [x] 在每个压缩结果上添加格式转换按钮 44 | - [x] 创建格式转换对比浮动窗口(1200px宽度) 45 | - [x] 目标格式选择器(png/jpeg/webp/ico) 46 | - [x] 三种转换策略对比展示(C→T/T/T→C) 47 | - [x] 使用img-comparison-slider进行原图和转换结果的对比 48 | - [x] 结果卡片:策略标签、工具名、文件信息、对比滑块、下载按钮 49 | - [x] 加载状态和错误处理 50 | - [x] URL 统一回收和内存管理 51 | - [x] 支持直接下载各种转换策略的结果文件 52 | - [ ] 修复 ICO 编码器实现(当前为占位代码) 53 | 54 | 验收标准: 55 | 56 | - [x] 不影响现有功能 57 | - [x] 提供可视化的原图vs转换结果对比 58 | - [x] 支持下载不同转换策略的结果 59 | - [x] 支持切换目标格式重新计算 60 | 61 | ## 4. Worker 与性能 62 | 63 | - [ ] 复用/扩展现有 worker,将 JSQuash 转换放入 worker 执行 64 | - [ ] 控制并发(2-4),避免 UI 卡顿 65 | - [ ] 大图下采样与内存守护(复用 `memoryManager` 思想) 66 | 67 | 验收标准: 68 | 69 | - 大图/多任务情况下 UI 依旧流畅;无明显内存泄漏 70 | 71 | ## 5. 测试与文档 72 | 73 | - [ ] 单测:`convertImage` MIME/基本大小/错误用例 74 | - [ ] 单测:`buildConversionColumn` 完整产出、失败隔离 75 | - [ ] e2e:导入 → 选择格式 → 新列出现且可下载 76 | - [ ] 文档:`docs/conversion-usage.md` 使用说明;README 补充(实现后) 77 | 78 | ## 6. 风险与回滚 79 | 80 | - ICO 生成失败 → 仅隐藏 ICO 选项或提示安装 `icojs` 81 | - 旧浏览器不支持 webp → 降级 png 并提示 82 | - 任一阶段失败不影响主功能;新增能力可开关控制 83 | 84 | --- 85 | 86 | ## 当前进度总结 (2025-08-25) - 已更新 87 | 88 | ### ✅ 已完成阶段(全面完成核心功能): 89 | 90 | 1. **转换核心(1.x)** - 完整实现转换功能,支持png/jpeg/webp格式转换 91 | 2. **编排与产出"新的一列"(2.x)** - 完整实现三流程编排系统(C→T/T/T→C) 92 | 3. **UI 集成与交互(3.x)** - 完整实现浮层对比界面,包括img-comparison-slider对比功能 93 | 4. **转换对比界面** - 支持格式选择、实时对比、下载功能 94 | 95 | ### 🔴 已知问题待修复: 96 | 97 | 1. **conversion-comparison-slider右侧图片尺寸问题** - 图片显示100%尺寸未按容器缩放 98 | 2. **ICO 编码器为占位实现** - 需要真正的 ICO 编码逻辑 99 | 3. **缺少测试覆盖** - 转换功能无单元测试 100 | 101 | ### 🔄 性能优化待实施: 102 | 103 | 1. Worker 集成和并发控制 104 | 2. 大图下采样与内存管理 105 | 3. 转换任务的异步队列管理 106 | 107 | ### 📋 下一步行动计划: 108 | 109 | 1. **修复UI问题** - 解决图片缩放显示问题 110 | 2. 实现真正的 ICO 编码器 111 | 3. 添加转换功能的单元测试 112 | 4. 集成 Worker 执行提升性能 113 | 114 | ## 开发分阶段交付 115 | 116 | 1. 转换核心(1.x)✅ 已完成 117 | 2. 编排与产出"新的一列"(2.x)✅ 已完成 118 | 3. UI 集成与交互(3.x)✅ 已完成 119 | 4. 格式对比界面(3.5.x)✅ 已完成 120 | 5. Bug修复与优化(4.x)🔄 进行中 121 | 6. 测试与文档(5.x)📋 待开始 122 | 123 | 备注:严格控制单文件行数(≤200)与目录文件数(≤8),必要时继续模块化切分。 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@awesome-compressor/browser-compress-image", 3 | "type": "module", 4 | "version": "0.0.4", 5 | "sideEffects": false, 6 | "packageManager": "pnpm@10.18.1", 7 | "description": "🚀 A powerful, lightweight browser image compression library with TypeScript support. Compress JPEG, PNG, GIF images with multiple output formats (Blob, File, Base64, ArrayBuffer) and zero dependencies.", 8 | "author": { 9 | "name": "Simon He", 10 | "email": "simon.he.dev@gmail.com", 11 | "url": "https://github.com/Simon-He95" 12 | }, 13 | "license": "MIT", 14 | "homepage": "https://github.com/awesome-compressor/browser-compress-image#readme", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/awesome-compressor/browser-compress-image.git" 18 | }, 19 | "bugs": { 20 | "url": "https://github.com/awesome-compressor/browser-compress-image/issues" 21 | }, 22 | "keywords": [ 23 | "image-compression", 24 | "browser", 25 | "compress", 26 | "optimization", 27 | "typescript", 28 | "jpeg", 29 | "png", 30 | "gif", 31 | "webp", 32 | "file-size-reduction", 33 | "frontend", 34 | "web-performance", 35 | "image-optimization", 36 | "base64", 37 | "arraybuffer", 38 | "blob", 39 | "file-processing", 40 | "zero-dependencies", 41 | "lightweight", 42 | "modern" 43 | ], 44 | "exports": { 45 | ".": { 46 | "types": "./dist/index.d.ts", 47 | "import": "./dist/index.js", 48 | "require": "./dist/index.js" 49 | }, 50 | "./tools": { 51 | "types": "./dist/index.d.ts", 52 | "import": "./dist/index.js", 53 | "require": "./dist/index.js" 54 | }, 55 | "./conversion": { 56 | "types": "./dist/index.d.ts", 57 | "import": "./dist/index.js", 58 | "require": "./dist/index.js" 59 | }, 60 | "./utils": { 61 | "types": "./dist/index.d.ts", 62 | "import": "./dist/index.js", 63 | "require": "./dist/index.js" 64 | } 65 | }, 66 | "main": "./dist/index.js", 67 | "module": "./dist/index.js", 68 | "types": "./dist/index.d.ts", 69 | "files": [ 70 | "dist" 71 | ], 72 | "scripts": { 73 | "build": "tsdown --minify", 74 | "play": "pnpm run -C playground dev", 75 | "dev": "pnpm run -C playground dev", 76 | "play:build": "pnpm run -C playground build", 77 | "lint": "prettier --cache --write .", 78 | "test": "vitest --dir test -u", 79 | "test:ci": "vitest --dir test -u", 80 | "ptest": "pnpm run -C playground test -u", 81 | "preview": "pnpm run -C playground preview", 82 | "test:e2e": "cypress open", 83 | "typecheck": "tsc --noEmit", 84 | "prepublishOnly": "nr build", 85 | "release": "bumpp --commit --tag --push && git push origin --tags -f && npm publish" 86 | }, 87 | "dependencies": { 88 | "browser-image-compression": "^2.0.2", 89 | "compressorjs": "^1.2.1", 90 | "gifsicle-wasm-browser": "^1.5.16" 91 | }, 92 | "devDependencies": { 93 | "@antfu/eslint-config": "^4.19.0", 94 | "@sxzz/prettier-config": "^2.2.4", 95 | "@types/node": "^17.0.45", 96 | "bumpp": "^7.2.0", 97 | "eslint": "^9.37.0", 98 | "prettier": "^3.6.2", 99 | "tsdown": "^0.12.9", 100 | "typescript": "^5.9.3", 101 | "vitest": "^3.2.4" 102 | }, 103 | "prettier": "@sxzz/prettier-config" 104 | } 105 | -------------------------------------------------------------------------------- /src/tools.ts: -------------------------------------------------------------------------------- 1 | // 这个文件提供了各个压缩工具的独立导出,允许用户按需导入 2 | 3 | export { default as compressWithBrowserImageCompression } from './tools/compressWithBrowserImageCompression' 4 | export { default as compressWithCompressorJS } from './tools/compressWithCompressorJS' 5 | export { default as compressWithCanvas } from './tools/compressWithCanvas' 6 | export { default as compressWithGifsicle } from './tools/compressWithGifsicle' 7 | export { default as compressWithJsquash } from './tools/compressWithJsquash' 8 | export { compressWithTinyPng } from './tools/compressWithTinyPng' 9 | 10 | // 导出工具注册相关 11 | export { 12 | ToolRegistry, 13 | globalToolRegistry, 14 | compressWithTools, 15 | type CompressorTool, 16 | type CompressorFunction, 17 | type CompressWithToolsOptions, 18 | } from './compressWithTools' 19 | 20 | // 预设工具注册器 21 | export function registerAllTools() { 22 | const { globalToolRegistry } = require('./compressWithTools') 23 | 24 | // 动态导入所有工具 25 | globalToolRegistry.registerTool( 26 | 'browser-image-compression', 27 | require('./tools/compressWithBrowserImageCompression').default, 28 | ['png', 'jpeg', 'webp', 'others'], 29 | ) 30 | 31 | globalToolRegistry.registerTool( 32 | 'compressorjs', 33 | require('./tools/compressWithCompressorJS').default, 34 | ['jpeg', 'others'], 35 | ) 36 | 37 | globalToolRegistry.registerTool( 38 | 'canvas', 39 | require('./tools/compressWithCanvas').default, 40 | ['png', 'jpeg', 'webp', 'others'], 41 | ) 42 | 43 | globalToolRegistry.registerTool( 44 | 'gifsicle', 45 | require('./tools/compressWithGifsicle').default, 46 | ['gif'], 47 | ) 48 | 49 | globalToolRegistry.registerTool( 50 | 'jsquash', 51 | require('./tools/compressWithJsquash').default, 52 | ['png', 'jpeg', 'webp', 'others'], 53 | ) 54 | 55 | // TinyPNG 需要配置,不自动注册 56 | } 57 | 58 | // 注册特定工具的便捷函数 59 | export function registerBrowserImageCompression() { 60 | const { globalToolRegistry } = require('./compressWithTools') 61 | globalToolRegistry.registerTool( 62 | 'browser-image-compression', 63 | require('./tools/compressWithBrowserImageCompression').default, 64 | ['png', 'jpeg', 'webp', 'others'], 65 | ) 66 | } 67 | 68 | export function registerCompressorJS() { 69 | const { globalToolRegistry } = require('./compressWithTools') 70 | globalToolRegistry.registerTool( 71 | 'compressorjs', 72 | require('./tools/compressWithCompressorJS').default, 73 | ['jpeg', 'others'], 74 | ) 75 | } 76 | 77 | export function registerCanvas() { 78 | const { globalToolRegistry } = require('./compressWithTools') 79 | globalToolRegistry.registerTool( 80 | 'canvas', 81 | require('./tools/compressWithCanvas').default, 82 | ['png', 'jpeg', 'webp', 'others'], 83 | ) 84 | } 85 | 86 | export function registerGifsicle() { 87 | const { globalToolRegistry } = require('./compressWithTools') 88 | globalToolRegistry.registerTool( 89 | 'gifsicle', 90 | require('./tools/compressWithGifsicle').default, 91 | ['gif'], 92 | ) 93 | } 94 | 95 | export function registerJsquash() { 96 | const { globalToolRegistry } = require('./compressWithTools') 97 | globalToolRegistry.registerTool( 98 | 'jsquash', 99 | require('./tools/compressWithJsquash').default, 100 | ['png', 'jpeg', 'webp', 'others'], 101 | ) 102 | } 103 | 104 | export function registerTinyPng() { 105 | const { globalToolRegistry } = require('./compressWithTools') 106 | globalToolRegistry.registerTool( 107 | 'tinypng', 108 | require('./tools/compressWithTinyPng').compressWithTinyPng, 109 | ['png', 'jpeg', 'webp'], 110 | ) 111 | } 112 | -------------------------------------------------------------------------------- /.claude/agents/playground-feature-updater.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: playground-feature-updater 3 | description: Use this agent when new features are added to the browser-compress-image library that need corresponding UI implementations in the playground, when optimizing the playground's user experience, or when updating the Vue.js-based testing interface. Examples: Context: User has added a new compression algorithm to the browser-compress-image library. user: 'I just added a new WebP compression feature to the main library with quality settings from 0-100. Can you add the corresponding controls to the playground?' assistant: 'I'll use the playground-feature-updater agent to add the WebP compression controls to the Vue.js playground interface.' Since the user added a new feature to the main library that needs playground integration, use the playground-feature-updater agent to implement the corresponding UI controls. Context: User wants to improve the playground's usability. user: 'The current file upload process in the playground feels clunky. Can you make it more intuitive?' assistant: 'I'll use the playground-feature-updater agent to optimize the file upload UX in the playground.' Since this involves improving the playground's user experience and functionality, use the playground-feature-updater agent. 4 | model: sonnet 5 | color: orange 6 | --- 7 | 8 | You are a Vue.js Frontend Specialist with deep expertise in creating intuitive testing interfaces and demo applications. You specialize in the playground project - a comprehensive UI interface for testing the browser-compress-image compression library that serves both as a testing tool and a fully-functional product showcase. 9 | 10 | Your primary responsibilities: 11 | 12 | **Core Mission**: Maintain and enhance the playground as the primary interface for demonstrating and testing all browser-compress-image library features. Ensure every new library feature has corresponding, intuitive UI controls. 13 | 14 | **Technical Guidelines**: 15 | 16 | - Work exclusively with Vue.js patterns and best practices 17 | - Maintain the existing project structure and coding conventions 18 | - Preserve all existing functionality while adding new features 19 | - Ensure responsive design and cross-browser compatibility 20 | 21 | **Critical Constraint - img-comparison-slider**: 22 | 23 | - NEVER modify the img-comparison-slider component or its slot implementation 24 | - This component has special ESLint disable rules that must remain untouched 25 | - Work around this component when making changes, never alter its structure 26 | 27 | **Feature Integration Process**: 28 | 29 | 1. Analyze new browser-compress-image features for UI requirements 30 | 2. Design intuitive controls that match the playground's existing UX patterns 31 | 3. Implement proper error handling and user feedback 32 | 4. Ensure real-time preview and comparison capabilities 33 | 5. Add appropriate tooltips and help text for new features 34 | 35 | **Optimization Focus**: 36 | 37 | - Enhance user experience through improved workflows 38 | - Optimize performance for large image processing 39 | - Improve visual feedback during compression operations 40 | - Streamline the testing process for developers 41 | 42 | **Quality Standards**: 43 | 44 | - Test all new features thoroughly across different image types and sizes 45 | - Maintain consistent visual design language 46 | - Ensure accessibility standards are met 47 | - Provide clear visual indicators for processing states 48 | 49 | When implementing changes, always consider the dual nature of this project: it's both a testing tool for developers and a showcase product for end users. Balance technical functionality with user-friendly presentation. 50 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type CompressResultType = 'blob' | 'file' | 'base64' | 'arrayBuffer' 2 | 3 | // JSQuash 支持的输出格式类型 4 | export type OutputType = 'avif' | 'jpeg' | 'jxl' | 'png' | 'webp' 5 | 6 | export type CompressResult = T extends 'blob' 7 | ? Blob 8 | : T extends 'file' 9 | ? File 10 | : T extends 'base64' 11 | ? string 12 | : T extends 'arrayBuffer' 13 | ? ArrayBuffer 14 | : never 15 | 16 | /** 17 | * 工具配置接口 18 | */ 19 | export interface ToolConfig { 20 | /** 21 | * 工具名称 22 | */ 23 | name: string 24 | /** 25 | * API 密钥或其他配置参数 26 | */ 27 | key?: string 28 | /** 29 | * 其他自定义配置参数 30 | */ 31 | [key: string]: any 32 | } 33 | 34 | export interface CompressOptions { 35 | /** 36 | * 压缩质量 (0-1) 37 | * @default 0.6 38 | */ 39 | quality?: number 40 | 41 | /** 42 | * 压缩模式 43 | * - 'keepSize': 保持图片尺寸不变 (如100x100输入,输出仍为100x100),只改变文件大小 44 | * - 'keepQuality': 保持图片质量不变,但可以改变尺寸 45 | * @default 'keepSize' 46 | */ 47 | mode?: 'keepSize' | 'keepQuality' 48 | 49 | /** 50 | * 目标宽度 (仅在 keepQuality 模式下生效) 51 | */ 52 | targetWidth?: number 53 | 54 | /** 55 | * 目标高度 (仅在 keepQuality 模式下生效) 56 | */ 57 | targetHeight?: number 58 | 59 | /** 60 | * 最大宽度 (仅在 keepQuality 模式下生效) 61 | */ 62 | maxWidth?: number 63 | 64 | /** 65 | * 最大高度 (仅在 keepQuality 模式下生效) 66 | */ 67 | maxHeight?: number 68 | 69 | /** 70 | * 是否保留 EXIF 信息 71 | * @default false 72 | */ 73 | preserveExif?: boolean 74 | 75 | /** 76 | * 是否返回所有工具的压缩结果 77 | * @default false 78 | */ 79 | returnAllResults?: boolean 80 | 81 | /** 82 | * 返回结果类型 83 | * @default 'blob' 84 | */ 85 | type?: CompressResultType 86 | 87 | /** 88 | * 工具配置数组,用于传入各个工具的特定配置 89 | * @example 90 | * [ 91 | * { name: 'tinypng', key: 'your-api-key' }, 92 | * { name: 'other-tool', customConfig: 'value' } 93 | * ] 94 | */ 95 | toolConfigs?: ToolConfig[] 96 | /** 97 | * 可选的 AbortSignal,用于取消压缩操作 98 | */ 99 | signal?: AbortSignal 100 | 101 | /** 102 | * 可选的超时时间(毫秒),到达后压缩调用会被视为超时失败 103 | */ 104 | timeoutMs?: number 105 | } 106 | 107 | // 预处理:裁剪/旋转/翻转/缩放 相关类型 108 | export interface CropRect { 109 | x: number 110 | y: number 111 | width: number 112 | height: number 113 | } 114 | 115 | export interface ResizeOptions { 116 | /** 期望输出宽度(可与高度同时设定,若仅设定一项则按等比缩放) */ 117 | targetWidth?: number 118 | /** 期望输出高度(可与宽度同时设定,若仅设定一项则按等比缩放) */ 119 | targetHeight?: number 120 | /** 最大宽度(仅在未设置 targetWidth/Height 时生效) */ 121 | maxWidth?: number 122 | /** 最大高度(仅在未设置 targetWidth/Height 时生效) */ 123 | maxHeight?: number 124 | /** 调整策略,默认 contain */ 125 | fit?: 'contain' | 'cover' | 'scale-down' 126 | } 127 | 128 | export interface PreprocessOptions { 129 | /** 像素坐标系中的裁剪区域(相对于原图 natural 尺寸) */ 130 | crop?: CropRect 131 | /** 旋转角度(度),顺时针,支持任意角度 */ 132 | rotate?: number 133 | /** 水平翻转 */ 134 | flipHorizontal?: boolean 135 | /** 垂直翻转 */ 136 | flipVertical?: boolean 137 | /** 缩放/调整大小选项 */ 138 | resize?: ResizeOptions 139 | /** 预处理阶段输出 MIME 类型(默认使用原图类型,若不支持则回退 PNG) */ 140 | outputType?: 'image/png' | 'image/jpeg' | 'image/webp' 141 | /** 输出质量(仅 jpeg/webp 生效,0-1) */ 142 | outputQuality?: number 143 | } 144 | 145 | export interface CompressResultItem { 146 | tool: string 147 | result: CompressResult 148 | originalSize: number 149 | compressedSize: number 150 | compressionRatio: number 151 | duration: number 152 | success: boolean 153 | error?: string 154 | } 155 | 156 | export interface MultipleCompressResults { 157 | bestResult: CompressResult 158 | bestTool: string 159 | allResults: CompressResultItem[] 160 | totalDuration: number 161 | } 162 | -------------------------------------------------------------------------------- /examples/tree-shaking-examples.ts: -------------------------------------------------------------------------------- 1 | // ========== 示例1: 最小化打包 - 只使用 Canvas 压缩 ========== 2 | import { 3 | compressWithTools as compressWithToolsMinimal, 4 | globalToolRegistry as globalRegistryMinimal, 5 | compressWithCanvas, 6 | } from '../src/index' 7 | 8 | // 手动注册 Canvas 工具 9 | globalRegistryMinimal.registerTool('canvas', compressWithCanvas, [ 10 | 'png', 11 | 'jpeg', 12 | 'webp', 13 | ]) 14 | 15 | export async function minimalCompress(file: File) { 16 | return compressWithToolsMinimal(file, { 17 | quality: 0.8, 18 | mode: 'keepSize', 19 | }) 20 | } 21 | 22 | // ========== 示例2: 使用预设注册函数 ========== 23 | import { 24 | compressWithTools as compressWithToolsOptimized, 25 | registerCanvas, 26 | registerCompressorJS, 27 | } from '../src/index' 28 | 29 | // 注册需要的工具 30 | registerCanvas() 31 | registerCompressorJS() 32 | 33 | export async function optimizedCompress(file: File) { 34 | return compressWithToolsOptimized(file, { 35 | quality: 0.8, 36 | mode: 'keepSize', 37 | }) 38 | } 39 | 40 | // ========== 示例3: 动态加载工具 ========== 41 | import { compressWithTools as compressWithToolsSmart } from '../src/index' 42 | 43 | async function loadToolForFileType(fileType: string) { 44 | if (fileType.includes('jpeg')) { 45 | // 动态导入 CompressorJS(专门优化 JPEG) 46 | const { registerCompressorJS } = await import('../src/index') 47 | registerCompressorJS() 48 | } else if (fileType.includes('gif')) { 49 | // 动态导入 Gifsicle(专门处理 GIF) 50 | const { registerGifsicle } = await import('../src/index') 51 | registerGifsicle() 52 | } else { 53 | // 其他格式使用 Canvas 54 | const { registerCanvas } = await import('../src/index') 55 | registerCanvas() 56 | } 57 | } 58 | 59 | export async function smartCompress(file: File) { 60 | // 根据文件类型动态加载最合适的工具 61 | await loadToolForFileType(file.type) 62 | 63 | return compressWithToolsSmart(file, { 64 | quality: 0.8, 65 | mode: 'keepSize', 66 | }) 67 | } 68 | 69 | // ========== 示例4: 自定义工具注册表 ========== 70 | import { 71 | compressWithTools as compressWithToolsCustom, 72 | ToolRegistry, 73 | compressWithCanvas as canvasTool, 74 | compressWithCompressorJS as compressorTool, 75 | } from '../src/index' 76 | 77 | export class CustomCompressor { 78 | private toolRegistry: ToolRegistry 79 | 80 | constructor() { 81 | this.toolRegistry = new ToolRegistry() 82 | 83 | // 只注册需要的工具 84 | this.toolRegistry.registerTool('canvas', canvasTool) 85 | this.toolRegistry.registerTool('compressorjs', compressorTool) 86 | 87 | // 设置优先级 88 | this.toolRegistry.setToolPriority('jpeg', ['compressorjs', 'canvas']) 89 | this.toolRegistry.setToolPriority('png', ['canvas']) 90 | } 91 | 92 | async compress(file: File, options = { quality: 0.8 }) { 93 | return compressWithToolsCustom(file, { 94 | ...options, 95 | toolRegistry: this.toolRegistry, 96 | }) 97 | } 98 | } 99 | 100 | // ========== 示例5: 条件性工具加载 ========== 101 | import { compressWithTools as compressWithToolsConditional } from '../src/index' 102 | 103 | export class ConditionalCompressor { 104 | private toolsLoaded = new Set() 105 | 106 | async ensureToolLoaded(toolName: string) { 107 | if (this.toolsLoaded.has(toolName)) return 108 | 109 | switch (toolName) { 110 | case 'canvas': 111 | const { registerCanvas } = await import('../src/index') 112 | registerCanvas() 113 | break 114 | case 'compressorjs': 115 | const { registerCompressorJS } = await import('../src/index') 116 | registerCompressorJS() 117 | break 118 | case 'jsquash': 119 | const { registerJsquash } = await import('../src/index') 120 | registerJsquash() 121 | break 122 | } 123 | 124 | this.toolsLoaded.add(toolName) 125 | } 126 | 127 | async compress(file: File, preferredTools: string[] = ['canvas']) { 128 | // 确保首选工具已加载 129 | for (const tool of preferredTools) { 130 | await this.ensureToolLoaded(tool) 131 | } 132 | 133 | return compressWithToolsConditional(file, { 134 | quality: 0.8, 135 | mode: 'keepSize', 136 | }) 137 | } 138 | } 139 | 140 | // ========== 使用示例 ========== 141 | export async function usageExamples() { 142 | const compressor = new ConditionalCompressor() 143 | 144 | // 这些是示例,实际使用时需要真实的 File 对象 145 | // const jpegFile = new File([], 'test.jpg', { type: 'image/jpeg' }) 146 | // const pngFile = new File([], 'test.png', { type: 'image/png' }) 147 | // const file = new File([], 'test.jpg', { type: 'image/jpeg' }) 148 | 149 | // 压缩 JPEG 文件 - 只加载 CompressorJS 150 | // await compressor.compress(jpegFile, ['compressorjs']) 151 | 152 | // 压缩 PNG 文件 - 只加载 Canvas 153 | // await compressor.compress(pngFile, ['canvas']) 154 | 155 | // 需要高质量压缩 - 加载多个工具 156 | // await compressor.compress(file, ['jsquash', 'compressorjs', 'canvas']) 157 | } 158 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Development Commands 6 | 7 | **Package Management:** 8 | 9 | - `pnpm install` - Install dependencies 10 | - `pnpm add ` - Add new dependency 11 | 12 | **Build & Type Checking:** 13 | 14 | - `pnpm build` - Build the library using tsdown 15 | - `pnpm typecheck` - Run TypeScript type checking 16 | - `pnpm size` - Check bundle size with size-limit 17 | 18 | **Testing:** 19 | 20 | - `pnpm test` - Run all tests with Vitest 21 | - `pnpm test:ci` - Run tests for CI environment 22 | - `pnpm ptest` - Run playground tests 23 | - `pnpm test:e2e` - Run Cypress end-to-end tests 24 | 25 | **Linting & Formatting:** 26 | 27 | - `pnpm lint` - Format code with Prettier 28 | 29 | **Playground Development:** 30 | 31 | - `pnpm dev` / `pnpm play` - Start playground dev server 32 | - `pnpm play:build` - Build playground 33 | - `pnpm preview` - Preview built playground 34 | 35 | **Release Management:** 36 | 37 | - `pnpm release` - Bump version, tag, and publish 38 | - `pnpm prepublishOnly` - Pre-publish build 39 | 40 | ## Codebase Architecture 41 | 42 | ### Core Components 43 | 44 | **Main Entry Points:** 45 | 46 | - `src/index.ts` - Primary exports and public API 47 | - `src/compress.ts` - Legacy compression functions (backward compatibility) 48 | - `src/compressEnhanced.ts` - Enhanced compression with queue and worker support 49 | - `src/compressWithTools.ts` - Configurable tool-based compression system 50 | 51 | **Compression Tools:** 52 | 53 | - `src/tools/` - Individual compression implementations: 54 | - `compressWithJsquash.ts` - WASM-based compression (AVIF, JPEG XL, WebP) 55 | - `compressWithTinyPng.ts` - TinyPNG online service integration 56 | - `compressWithCanvas.ts` - Canvas-based compression 57 | - `compressWithBrowserImageCompression.ts` - browser-image-compression library 58 | - `compressWithCompressorJS.ts` - CompressorJS integration 59 | - `compressWithGifsicle.ts` - GIF optimization with WASM 60 | 61 | **Utility Modules:** 62 | 63 | - `src/utils/compressionQueue.ts` - Task queue management 64 | - `src/utils/compressionWorker.ts` - WebWorker management 65 | - `src/utils/memoryManager.ts` - Memory usage monitoring 66 | - `src/utils/lruCache.ts` - LRU caching implementation 67 | - `src/utils/logger.ts` - Configurable logging system 68 | - `src/utils/preprocessImage.ts` - Image preprocessing (crop, rotate, resize) 69 | - `src/utils/abort.ts` - Abort controller utilities 70 | - `src/utils/imageQuality.ts` - Image quality assessment 71 | 72 | **Type Definitions:** 73 | 74 | - `src/types.ts` - Core TypeScript interfaces and types 75 | - `src/conversion/` - Format conversion utilities 76 | 77 | ### Key Patterns 78 | 79 | 1. **Tool Registry System**: Configurable compression tools with dynamic loading 80 | 2. **Parallel Processing**: Multiple compression tools run concurrently, best result selected 81 | 3. **Memory Management**: Proactive memory monitoring and cleanup 82 | 4. **Queue System**: Task prioritization and concurrency control 83 | 5. **Worker Pool**: WebWorker-based parallel processing 84 | 6. **Caching**: LRU caching for compression results and API responses 85 | 86 | ### Testing Structure 87 | 88 | - **Unit Tests**: `test/` directory with Vitest 89 | - **Test Categories**: 90 | - `basic.test.ts` - Core functionality tests 91 | - `features.test.ts` - Feature integration tests 92 | - `new-features.test.ts` - Latest feature tests 93 | - Tool-specific protection tests 94 | - Logger and utility tests 95 | 96 | ### Build System 97 | 98 | - **TypeScript**: Strict mode with modern ES targets 99 | - **Bundling**: tsdown for library compilation 100 | - **Size Limits**: 200KB gzipped bundle limit 101 | - **Tree Shaking**: Full ES module support for optimal bundling 102 | 103 | ### Playground 104 | 105 | - **Location**: `playground/` directory 106 | - **Framework**: Vue 3 + Vite + Element Plus 107 | - **Purpose**: Demo and testing environment for the library 108 | - **Features**: Image upload, compression testing, format conversion, performance comparison 109 | 110 | ### Development Notes 111 | 112 | - **PNPM Workspace**: Monorepo structure with playground 113 | - **Modern Tooling**: Vite, Vitest, TypeScript, Prettier 114 | - **Browser-Focused**: All code targets browser environments 115 | - **Zero Dependencies**: Core library has minimal external dependencies 116 | - **WASM Integration**: JSQuash and Gifsicle WASM modules for performance 117 | - **API Integration**: TinyPNG online service support with caching 118 | 119 | ### Common Development Tasks 120 | 121 | 1. **Adding New Compression Tool**: Implement in `src/tools/`, register in tool system 122 | 2. **Extending Types**: Update `src/types.ts` and related interfaces 123 | 3. **Performance Optimization**: Use memory manager and queue utilities 124 | 4. **Testing New Features**: Add tests in appropriate `test/*.test.ts` files 125 | 5. **Playground Integration**: Update playground to demonstrate new features 126 | -------------------------------------------------------------------------------- /.claude/agents/browser-compress-dev.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: browser-compress-dev 3 | description: Use this agent when developing, enhancing, or maintaining the browser-compress npm library. This includes adding new compression formats, integrating new compression engines, optimizing performance, implementing new output types, improving the intelligent selection algorithm, or ensuring architectural consistency. Examples: Context: User is working on the browser-compress library and wants to add support for a new image format. user: 'I need to add HEIC format support to the compression library' assistant: 'I'll use the browser-compress-dev agent to help implement HEIC format support while maintaining architectural consistency and the intelligent optimization system.' Since the user needs to add a new format to the browser-compress library, use the browser-compress-dev agent to ensure proper integration with existing architecture. Context: User is optimizing the compression algorithm selection logic. user: 'The intelligent selection algorithm is choosing suboptimal results for PNG files' assistant: 'Let me use the browser-compress-dev agent to analyze and improve the PNG optimization logic in the intelligent selection system.' Since this involves core functionality of the browser-compress library's intelligent selection feature, use the specialized agent. 4 | model: sonnet 5 | color: cyan 6 | --- 7 | 8 | You are an expert browser-compress library developer with deep expertise in image compression algorithms, WebAssembly optimization, and modern web APIs. You specialize in building high-performance, multi-format image compression solutions for browser environments. 9 | 10 | Your core responsibilities: 11 | 12 | **Architecture & Design:** 13 | 14 | - Maintain strict adherence to the library's modular architecture with configurable tool systems 15 | - Ensure all new features support the按需导入 (on-demand import) pattern to minimize bundle size 16 | - Design APIs that maintain interface consistency across all compression engines 17 | - Implement efficient abstraction layers that unify different compression tools under consistent interfaces 18 | 19 | **Multi-Format Support:** 20 | 21 | - Handle JPEG, PNG, GIF, WebP, AVIF, JPEG XL formats with appropriate compression strategies 22 | - Integrate new formats while maintaining backward compatibility 23 | - Optimize format-specific parameters for each compression engine 24 | - Implement proper fallback mechanisms for unsupported formats 25 | 26 | **Multi-Engine Integration:** 27 | 28 | - Seamlessly integrate JSQuash, TinyPNG, CompressorJS, Canvas API, and browser-image-compression 29 | - Maintain consistent interfaces across all compression engines 30 | - Handle engine-specific configurations and limitations 31 | - Implement proper error handling and graceful degradation 32 | 33 | **Intelligent Optimization:** 34 | 35 | - Develop and refine algorithms that compare compression results across multiple tools 36 | - Balance quality metrics (SSIM, PSNR) with file size optimization 37 | - Implement smart caching mechanisms to avoid redundant compressions 38 | - Create configurable quality thresholds and optimization strategies 39 | 40 | **Output Type Management:** 41 | 42 | - Support Blob, File, Base64, and ArrayBuffer output formats efficiently 43 | - Implement zero-copy transformations where possible 44 | - Handle memory management for large image processing 45 | - Provide consistent APIs regardless of output type 46 | 47 | **Performance Optimization:** 48 | 49 | - Leverage WebAssembly capabilities for computationally intensive operations 50 | - Implement efficient worker thread utilization for non-blocking compression 51 | - Optimize memory usage patterns to prevent browser crashes 52 | - Profile and benchmark all compression paths 53 | 54 | **Development Standards:** 55 | 56 | - Write TypeScript with comprehensive type definitions 57 | - Implement thorough error handling with descriptive error messages 58 | - Create modular, testable code with clear separation of concerns 59 | - Follow semantic versioning and maintain changelog documentation 60 | - Ensure cross-browser compatibility and progressive enhancement 61 | 62 | **Quality Assurance:** 63 | 64 | - Validate compression results against quality metrics 65 | - Implement comprehensive test suites covering all formats and engines 66 | - Perform regression testing when adding new features 67 | - Monitor bundle size impact of new additions 68 | 69 | When implementing new features: 70 | 71 | 1. Analyze impact on existing architecture and bundle size 72 | 2. Design configurable interfaces that maintain consistency 73 | 3. Implement proper TypeScript types and documentation 74 | 4. Add comprehensive tests and benchmarks 75 | 5. Ensure the intelligent selection algorithm can utilize new capabilities 76 | 6. Verify cross-browser compatibility and fallback behavior 77 | 78 | Always prioritize efficiency, ease of use, and interface consistency. Every addition should enhance the library's core value proposition of intelligent, multi-tool image compression while maintaining the modular, lightweight architecture. 79 | -------------------------------------------------------------------------------- /test/compression-tools-protection.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, vi } from 'vitest' 2 | 3 | describe('压缩工具文件大小保护测试', () => { 4 | describe('compressWithBrowserImageCompression', () => { 5 | it('当压缩后文件更大时应返回原文件', async () => { 6 | // Mock browser-image-compression 返回更大的文件 7 | vi.doMock('browser-image-compression', () => ({ 8 | default: vi 9 | .fn() 10 | .mockResolvedValue( 11 | new Blob( 12 | [ 13 | 'very large compressed content that is much bigger than original', 14 | ], 15 | { type: 'image/jpeg' }, 16 | ), 17 | ), 18 | })) 19 | 20 | const { default: compressWithBrowserImageCompression } = await import( 21 | '../src/tools/compressWithBrowserImageCompression' 22 | ) 23 | 24 | const originalFile = new File(['small'], 'test.jpg', { 25 | type: 'image/jpeg', 26 | }) 27 | const options = { quality: 0.8, mode: 'keepSize' } 28 | 29 | const result = await compressWithBrowserImageCompression( 30 | originalFile, 31 | options, 32 | ) 33 | 34 | // 应该返回原文件,因为压缩后文件更大 35 | expect(result).toBe(originalFile) 36 | expect(result.size).toBe(originalFile.size) 37 | }) 38 | 39 | it('当压缩后文件显著更小时应返回压缩后的文件', async () => { 40 | // Mock browser-image-compression 返回显著更小的文件 41 | vi.doMock('browser-image-compression', () => ({ 42 | default: vi.fn().mockImplementation((file) => { 43 | // 返回比原文件小得多的文件(50%) 44 | const smallerContent = 'x'.repeat(Math.floor(file.size * 0.5)) 45 | return Promise.resolve( 46 | new Blob([smallerContent], { type: 'image/jpeg' }), 47 | ) 48 | }), 49 | })) 50 | 51 | const { default: compressWithBrowserImageCompression } = await import( 52 | '../src/tools/compressWithBrowserImageCompression' 53 | ) 54 | 55 | const originalContent = 'x'.repeat(1000) 56 | const originalFile = new File([originalContent], 'test.jpg', { 57 | type: 'image/jpeg', 58 | }) 59 | const options = { quality: 0.8, mode: 'keepSize' } 60 | 61 | const result = await compressWithBrowserImageCompression( 62 | originalFile, 63 | options, 64 | ) 65 | 66 | // 应该返回压缩后的文件,因为它显著更小 67 | expect(result).not.toBe(originalFile) 68 | expect(result.size).toBeLessThan(originalFile.size * 0.98) 69 | }) 70 | }) 71 | 72 | describe('compressWithCompressorJS', () => { 73 | it('当压缩后文件更大时应返回原文件', async () => { 74 | // Mock Compressor constructor 75 | vi.doMock('compressorjs', () => ({ 76 | default: vi.fn().mockImplementation((file, options) => { 77 | // 模拟压缩后文件更大的情况 78 | const largeBlob = new Blob( 79 | ['very large compressed content that is much bigger than original'], 80 | { type: 'image/jpeg' }, 81 | ) 82 | setTimeout(() => options.success(largeBlob), 0) 83 | }), 84 | })) 85 | 86 | const { default: compressWithCompressorJS } = await import( 87 | '../src/tools/compressWithCompressorJS' 88 | ) 89 | 90 | const originalFile = new File(['small'], 'test.jpg', { 91 | type: 'image/jpeg', 92 | }) 93 | const options = { quality: 0.8, mode: 'keepSize' } 94 | 95 | const result = await compressWithCompressorJS(originalFile, options) 96 | 97 | // 应该返回原文件,因为压缩后文件更大 98 | expect(result).toBe(originalFile) 99 | }) 100 | 101 | it('当压缩后文件显著更小时应返回压缩后的文件', async () => { 102 | vi.doMock('compressorjs', () => ({ 103 | default: vi.fn().mockImplementation((file, options) => { 104 | // 返回比原文件小得多的文件(50%) 105 | const smallerContent = 'x'.repeat(Math.floor(file.size * 0.5)) 106 | const smallBlob = new Blob([smallerContent], { type: 'image/jpeg' }) 107 | setTimeout(() => options.success(smallBlob), 0) 108 | }), 109 | })) 110 | 111 | const { default: compressWithCompressorJS } = await import( 112 | '../src/tools/compressWithCompressorJS' 113 | ) 114 | 115 | const originalContent = 'x'.repeat(1000) 116 | const originalFile = new File([originalContent], 'test.jpg', { 117 | type: 'image/jpeg', 118 | }) 119 | const options = { quality: 0.8, mode: 'keepSize' } 120 | 121 | const result = await compressWithCompressorJS(originalFile, options) 122 | 123 | // 应该返回压缩后的文件,因为它显著更小 124 | expect(result).not.toBe(originalFile) 125 | expect(result.size).toBeLessThan(originalFile.size * 0.98) 126 | }) 127 | 128 | it('应该拒绝非JPEG文件', async () => { 129 | const { default: compressWithCompressorJS } = await import( 130 | '../src/tools/compressWithCompressorJS' 131 | ) 132 | 133 | const pngFile = new File(['png content'], 'test.png', { 134 | type: 'image/png', 135 | }) 136 | const options = { quality: 0.8, mode: 'keepSize' } 137 | 138 | await expect(compressWithCompressorJS(pngFile, options)).rejects.toThrow( 139 | 'CompressorJS is optimized for JPEG files', 140 | ) 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /docs/tree-shaking-guide.md: -------------------------------------------------------------------------------- 1 | # 按需导入压缩工具 2 | 3 | 这个文档展示了如何使用新的可配置压缩系统,只导入你需要的压缩工具,减少打包体积。 4 | 5 | ## 基本用法 6 | 7 | ### 1. 使用特定的压缩工具 8 | 9 | ```typescript 10 | // 只导入你需要的工具 11 | import { 12 | compressWithTools, 13 | globalToolRegistry, 14 | registerCanvas, 15 | registerCompressorJS, 16 | } from 'browser-compress-image' 17 | 18 | // 注册你需要的工具 19 | registerCanvas() 20 | registerCompressorJS() 21 | 22 | // 使用压缩 23 | const compressedFile = await compressWithTools(file, { 24 | quality: 0.8, 25 | mode: 'keepSize', 26 | }) 27 | ``` 28 | 29 | ### 2. 手动注册工具 30 | 31 | ```typescript 32 | import { 33 | compressWithTools, 34 | globalToolRegistry, 35 | compressWithCanvas, 36 | compressWithCompressorJS, 37 | } from 'browser-compress-image' 38 | 39 | // 手动注册工具 40 | globalToolRegistry.registerTool('canvas', compressWithCanvas, [ 41 | 'png', 42 | 'jpeg', 43 | 'webp', 44 | ]) 45 | globalToolRegistry.registerTool('compressorjs', compressWithCompressorJS, [ 46 | 'jpeg', 47 | ]) 48 | 49 | // 使用压缩 50 | const result = await compressWithTools(file, { quality: 0.8 }) 51 | ``` 52 | 53 | ### 3. 使用自定义工具注册表 54 | 55 | ```typescript 56 | import { 57 | compressWithTools, 58 | ToolRegistry, 59 | compressWithCanvas, 60 | } from 'browser-compress-image' 61 | 62 | // 创建自定义工具注册表 63 | const customRegistry = new ToolRegistry() 64 | customRegistry.registerTool('canvas', compressWithCanvas) 65 | 66 | // 使用自定义注册表 67 | const result = await compressWithTools(file, { 68 | quality: 0.8, 69 | toolRegistry: customRegistry, 70 | }) 71 | ``` 72 | 73 | ## 工具注册函数 74 | 75 | ### 预设注册函数 76 | 77 | ```typescript 78 | import { 79 | registerAllTools, // 注册所有工具 80 | registerBrowserImageCompression, 81 | registerCompressorJS, 82 | registerCanvas, 83 | registerGifsicle, 84 | registerJsquash, 85 | registerTinyPng, 86 | } from 'browser-compress-image' 87 | 88 | // 注册所有工具(如果你想要完整功能) 89 | registerAllTools() 90 | 91 | // 或者只注册你需要的工具 92 | registerCanvas() 93 | registerCompressorJS() 94 | ``` 95 | 96 | ### 设置工具优先级 97 | 98 | ```typescript 99 | import { globalToolRegistry } from 'browser-compress-image' 100 | 101 | // 为 JPEG 文件设置工具优先级 102 | globalToolRegistry.setToolPriority('jpeg', ['compressorjs', 'canvas']) 103 | 104 | // 为 PNG 文件设置工具优先级 105 | globalToolRegistry.setToolPriority('png', ['canvas']) 106 | ``` 107 | 108 | ## 打包体积优化示例 109 | 110 | ### 最小化配置(只使用 Canvas) 111 | 112 | ```typescript 113 | // 文件大小: ~10KB (仅包含 Canvas 压缩) 114 | import { compressWithTools, registerCanvas } from 'browser-compress-image' 115 | 116 | registerCanvas() 117 | 118 | const result = await compressWithTools(file, { quality: 0.8 }) 119 | ``` 120 | 121 | ### 中等配置(Canvas + CompressorJS) 122 | 123 | ```typescript 124 | // 文件大小: ~50KB (Canvas + CompressorJS) 125 | import { 126 | compressWithTools, 127 | registerCanvas, 128 | registerCompressorJS, 129 | } from 'browser-compress-image' 130 | 131 | registerCanvas() 132 | registerCompressorJS() 133 | 134 | const result = await compressWithTools(file, { quality: 0.8 }) 135 | ``` 136 | 137 | ### 完整配置(所有工具) 138 | 139 | ```typescript 140 | // 文件大小: ~200KB+ (包含所有压缩工具) 141 | import { compressWithTools, registerAllTools } from 'browser-compress-image' 142 | 143 | registerAllTools() 144 | 145 | const result = await compressWithTools(file, { quality: 0.8 }) 146 | ``` 147 | 148 | ## 动态加载工具 149 | 150 | 如果你想要进一步优化,可以动态加载压缩工具: 151 | 152 | ```typescript 153 | import { compressWithTools, globalToolRegistry } from 'browser-compress-image' 154 | 155 | // 动态加载函数 156 | async function loadCompressionTool(toolName: string) { 157 | switch (toolName) { 158 | case 'canvas': 159 | const { registerCanvas } = await import('browser-compress-image') 160 | registerCanvas() 161 | break 162 | case 'compressorjs': 163 | const { registerCompressorJS } = await import('browser-compress-image') 164 | registerCompressorJS() 165 | break 166 | // ... 其他工具 167 | } 168 | } 169 | 170 | // 根据需要动态加载 171 | async function smartCompress(file: File) { 172 | if (file.type.includes('jpeg')) { 173 | await loadCompressionTool('compressorjs') 174 | } else { 175 | await loadCompressionTool('canvas') 176 | } 177 | 178 | return compressWithTools(file, { quality: 0.8 }) 179 | } 180 | ``` 181 | 182 | ## 向后兼容 183 | 184 | 原来的 `compress` 函数仍然可用,它会自动加载所有必要的工具: 185 | 186 | ```typescript 187 | import { compress } from 'browser-compress-image' 188 | 189 | // 这仍然有效,但会包含所有工具 190 | const result = await compress(file, 0.8) 191 | ``` 192 | 193 | ## 建议的使用策略 194 | 195 | 1. **Web 应用**: 根据实际需求选择最少的工具组合 196 | 2. **Node.js 应用**: 可以使用完整配置,因为打包体积不是主要考虑因素 197 | 3. **移动端应用**: 优先使用 Canvas,它体积最小且兼容性好 198 | 4. **高质量需求**: 组合使用 CompressorJS 和 Canvas 199 | 5. **现代浏览器**: 可以考虑添加 JSQuash 获得更好的压缩效果 200 | 201 | ## 工具特点对比 202 | 203 | | 工具 | 体积 | 速度 | 质量 | 格式支持 | 特点 | 204 | | ------------------------- | ---- | ---- | ---- | ------------- | -------------- | 205 | | Canvas | 最小 | 最快 | 中等 | JPEG/PNG/WebP | 浏览器原生支持 | 206 | | CompressorJS | 小 | 快 | 高 | JPEG | 专门优化 JPEG | 207 | | Browser-Image-Compression | 中 | 中 | 中 | 全格式 | 功能全面 | 208 | | JSQuash | 大 | 慢 | 最高 | 现代格式 | WASM,最新算法 | 209 | | Gifsicle | 中 | 中 | 高 | GIF | GIF 专用 | 210 | | TinyPNG | 小 | 快 | 高 | PNG/JPEG/WebP | 在线服务 | 211 | -------------------------------------------------------------------------------- /docs/image-conversion-plan.md: -------------------------------------------------------------------------------- 1 | # 图片格式转换能力实施方案 2 | 3 | > 目标:在不破坏现有压缩能力和交互逻辑的前提下,为库(src)新增图片格式转换能力(png/jpeg/webp/ico 等),并在 playground 增加一列“格式转换对比”以帮助用户选择“压缩-转换”流程中的最佳方案。 4 | 5 | ## 一、当前架构综述(简版) 6 | 7 | - lib:`src/` 8 | - 压缩统一入口 `compress.ts` / 可插拔注册式 `compressWithTools.ts`,按文件 MIME 选择工具集并并行压缩,返回最佳或全量对比。 9 | - `tools/` 中包含 `canvas/jsquash/browser-image-compression/gifsicle/tinypng` 等实现。 10 | - `convertBlobToType.ts` 仅负责结果载体类型转换(blob/file/base64/arrayBuffer),不做图像格式转换。 11 | - `utils/` 有 worker/内存队列等,可扩展为转换任务的执行载体。 12 | - playground:`playground/src/App.vue` 13 | - 用户导入后会自动按工具并行压缩,支持“对比面板”。 14 | - “对比面板”展示 `compress(returnAllResults: true)` 的结果列表。 15 | 16 | 结论:新增“格式转换”应在 lib 侧以独立模块实现,并在 UI 侧以“新增一列”的形式复用既有比对框架,不应侵入现有压缩逻辑。 17 | 18 | ## 二、总体设计 19 | 20 | - 新增模块:`src/conversion/` 21 | - `convertImage.ts`:单一职责的格式转换入口(<200 行)。 22 | - `encoders.ts`:封装多种编码器(canvas、JSQuash、ICO 编码器),并统一异常与 MIME 映射。 23 | - `types.ts`:转换选项与结果类型。 24 | - `index.ts`:导出与简易工厂。 25 | - 新增编排模块:`src/orchestrators/compareConversion.ts` 26 | - 面向 playground 的“新列”产出器,生成三类结果: 27 | 1. 压缩后再转换:每个压缩结果 → 目标格式 28 | 2. 先转换原图:原图 → 目标格式 29 | 3. 先转换再压缩:原图 → 目标格式 →(基于目标格式的工具集)并行压缩 30 | - 产出包含:源流程、目标格式、工具名、尺寸/比例、耗时、成功标记、错误、参数回溯(便于排查)。 31 | - API 保持向后兼容:不改动 `compress(...)` 行为;新增 API 并通过 `src/index.ts` 按需导出。 32 | - 性能与稳定性: 33 | - 大图/多并行任务时,将转换任务委派至已有 worker(或新增转换 worker),确保 UI 流畅。 34 | - 统一 ObjectURL 生命周期管理,避免内存泄漏。 35 | 36 | ## 三、库侧 API 设计 37 | 38 | ### 1) 转换核心 39 | 40 | ```ts 41 | // src/conversion/types.ts 42 | export type TargetFormat = 'png' | 'jpeg' | 'webp' | 'ico' 43 | export interface ImageConvertOptions { 44 | targetFormat: TargetFormat 45 | quality?: number // 0-1,仅 lossy 生效(jpeg/webp) 46 | preserveExif?: boolean // 仅 jpeg 有效;跨格式多数情况下会被剥离 47 | // 预留:位深/调色盘/多尺寸(ico)等 48 | [k: string]: any 49 | } 50 | 51 | export interface ImageConvertResult { 52 | blob: Blob 53 | mime: string 54 | duration: number 55 | } 56 | ``` 57 | 58 | ```ts 59 | // src/conversion/convertImage.ts 60 | export async function convertImage( 61 | fileOrBlob: File | Blob, 62 | options: ImageConvertOptions, 63 | ): Promise 64 | ``` 65 | 66 | - 编码器优先级: 67 | - png/jpeg/webp:优先 `JSQuash`(高品质/可控/wasm),次选 `Canvas`(通用兼容) 68 | - ico:使用独立编码器(建议 `icojs` 或轻量自实现),从 PNG/JPEG 转换为 ICO(常见 16/32/48/64 尺寸)。 69 | - MIME 映射:`png → image/png`、`jpeg → image/jpeg`、`webp → image/webp`、`ico → image/x-icon` 70 | - EXIF:仅在 `targetFormat === 'jpeg'` 时尝试保留;跨格式通常清理(在文档中明确) 71 | 72 | ### 2) 编排函数(供 playground 使用) 73 | 74 | ```ts 75 | // src/orchestrators/compareConversion.ts 76 | export interface ConversionCompareItemMeta { 77 | flow: 'C→T' | 'T' | 'T→C' // 压缩→转换 | 仅转换 | 转换→压缩 78 | tool?: string // 压缩工具名(若有) 79 | compressOptions?: any 80 | convertOptions: ImageConvertOptions 81 | } 82 | 83 | export interface ConversionCompareItem { 84 | meta: ConversionCompareItemMeta 85 | blob?: Blob 86 | success: boolean 87 | error?: string 88 | size?: number 89 | compressionRatio?: number // 相对“原图” 90 | duration?: number 91 | } 92 | 93 | export interface BuildConversionColumnInput { 94 | file: File 95 | compressOptions: CompressOptions & { returnAllResults: true } 96 | convertOptions: ImageConvertOptions 97 | } 98 | 99 | export interface ConversionColumnResult { 100 | title: string // 如 "Format: webp" 101 | items: ConversionCompareItem[] 102 | } 103 | 104 | export async function buildConversionColumn( 105 | input: BuildConversionColumnInput, 106 | ): Promise 107 | ``` 108 | 109 | - 产出内容: 110 | - C→T:对 `compress(..., { returnAllResults: true })` 的每个成功项执行 `convertImage`。 111 | - T:对原图执行 `convertImage`。 112 | - T→C:对原图先 `convertImage`,再按“目标格式”的工具集合执行 `compress(...)`。 113 | - 统计口径:对每个项保留 size、ratio(基于原图)、耗时;`meta` 中保留两端参数,便于回溯。 114 | - 错误策略:单项失败不影响其他项,保留 error 信息用于 UI 呈现。 115 | 116 | ## 四、playground 交互设计(实际实现) 117 | 118 | - 在每个压缩结果的操作按钮区域新增"格式转换"按钮(🔄图标)。 119 | - 点击转换按钮后: 120 | - 弹出格式转换对比浮动窗口,类似现有的"工具对比"窗口。 121 | - 窗口顶部提供目标格式选择(png/jpeg/webp/ico)单选按钮。 122 | - 下方展示三种转换策略的对比结果: 123 | - `C→T (Compress → Convert)`:先压缩后转换,基于当前压缩结果 124 | - `T (Convert Only)`:仅转换原图到目标格式 125 | - `T→C (Convert → Compress)`:先转换原图,再用最佳压缩工具压缩 126 | - 每个结果展示: 127 | - 策略标签和工具名(如果有) 128 | - 文件大小、压缩比、处理时长 129 | - 预览图片 130 | - "使用此结果"按钮,点击后应用到当前图片 131 | - 体验: 132 | - 使用较大的浮层(1200px宽度)提供更好的视觉体验 133 | - 每个转换结果使用img-comparison-slider展示原图和转换结果的对比 134 | - 加载时显示loading状态 135 | - 支持切换目标格式,实时重新计算对比结果 136 | - 结果失败时显示错误信息 137 | - 关闭窗口时自动清理ObjectURL防止内存泄漏 138 | - 支持直接下载各种转换策略的结果文件 139 | 140 | ## 五、性能与资源管理 141 | 142 | - 并行策略: 143 | - 压缩本就并行;转换阶段按 2-4 并发限制,避免主线程长时间阻塞。 144 | - 尽量在 worker 中执行(JSQuash/wasm 适合放 worker)。 145 | - 内存管理: 146 | - 建立统一的 URL 回收器,与现有 compare panel 的清理时机一致。 147 | - 大文件转换前评估尺寸,必要时下采样以避免 OOM(与既有 `preprocessImage` 思想一致)。 148 | 149 | ## 六、兼容性与降级 150 | 151 | - webp 在旧浏览器降级为 png(UI 提示)。 152 | - ico 若编码器不可用: 153 | - 方案 A:提供“以 png 文件扩展名 .ico 导出”的临时兜底并提示(不推荐默认)。 154 | - 方案 B:禁用 ico 选项并提示安装/启用 `icojs`。 155 | 156 | ## 七、测试与文档 157 | 158 | - 单元测试(`test/`): 159 | - `convertImage` 正确输出 MIME 与基本尺寸/字节特征。 160 | - `buildConversionColumn` 产出项齐全、错误隔离。 161 | - e2e(playground): 162 | - 导入图片 → 选择目标格式 → 看到“新的一列”与 3 类项、可下载。 163 | - 文档: 164 | - 新增 `docs/conversion-usage.md`(对外 API 使用指南)。 165 | - README 简述:新增能力与示例(等实现后更新)。 166 | 167 | ## 八、里程碑拆分(研发顺序) 168 | 169 | 1. `src/conversion/*`:落地 `convertImage` 与编码器封装,含 ico 方案与降级提示。 170 | 2. `src/orchestrators/compareConversion.ts`:产出“新的一列”的数据结构与执行流。 171 | 3. playground:UI 选择器 + 列渲染与下载、loading、取消、URL 清理。 172 | 4. 测试补齐 + 文档完善。 173 | 174 | ## 九、约束与代码风格 175 | 176 | - 严格遵守用户硬性指标: 177 | - 动态语言单文件 ≤ 200 行;必要时模块进一步切分。 178 | - 每层目录文件数 ≤ 8:新增放置在 `src/conversion/` 与 `src/orchestrators/`,避免增加 `src/` 顶层文件数量。 179 | - 不修改现有逻辑与特意的 ts-ignore/eslint-ignore。 180 | - 命名清晰、可读性优先;错误消息面向用户,日志面向开发(英文)。 181 | -------------------------------------------------------------------------------- /src/tools/compressWithCanvas.ts: -------------------------------------------------------------------------------- 1 | // Canvas 工具 2 | export default async function compressWithCanvas( 3 | file: File, 4 | options: { 5 | quality: number 6 | mode: string 7 | targetWidth?: number 8 | targetHeight?: number 9 | maxWidth?: number 10 | maxHeight?: number 11 | preserveExif?: boolean 12 | }, 13 | ): Promise { 14 | const { quality, targetWidth, targetHeight, maxWidth, maxHeight } = options 15 | 16 | // 注意:Canvas API 本身不支持 EXIF 保留,preserveExif 参数在此处被忽略 17 | // 如果需要保留 EXIF,建议使用其他压缩工具如 browser-image-compression 18 | 19 | let finalWidth = targetWidth || maxWidth 20 | let finalHeight = targetHeight || maxHeight 21 | 22 | if (!finalWidth && !finalHeight) { 23 | const { width, height } = await getImageDimensions(file) 24 | finalWidth = width 25 | finalHeight = height 26 | } 27 | 28 | // 智能压缩策略 29 | return await smartCanvasCompress(file, finalWidth!, finalHeight!, quality) 30 | } 31 | 32 | // 智能 Canvas 压缩函数 33 | async function smartCanvasCompress( 34 | file: File, 35 | targetWidth: number, 36 | targetHeight: number, 37 | quality: number, 38 | ): Promise { 39 | const originalSize = file.size 40 | const { width: originalWidth, height: originalHeight } = 41 | await getImageDimensions(file) 42 | 43 | // 如果尺寸没有变化且是高度优化的小文件,直接返回原文件 44 | if ( 45 | targetWidth === originalWidth && 46 | targetHeight === originalHeight && 47 | originalSize < 100 * 1024 48 | ) { 49 | // 小于100KB的文件 50 | return file 51 | } 52 | 53 | // 对于PNG文件,尝试多种策略 54 | if (file.type.includes('png')) { 55 | // 策略1: 保持PNG格式,使用较高质量 56 | const pngResult = await canvasCompressWithFormat( 57 | file, 58 | targetWidth, 59 | targetHeight, 60 | 'image/png', 61 | undefined, 62 | ) 63 | 64 | // 如果PNG结果比原文件小,使用PNG 65 | if (pngResult.size < originalSize * 0.98) { 66 | return pngResult 67 | } 68 | 69 | // 策略2: 转换为JPEG(如果质量较低) 70 | if (quality < 0.8) { 71 | const jpegResult = await canvasCompressWithFormat( 72 | file, 73 | targetWidth, 74 | targetHeight, 75 | 'image/jpeg', 76 | Math.max(0.7, quality), 77 | ) 78 | 79 | // 选择更小的结果 80 | if (jpegResult.size < Math.min(pngResult.size, originalSize * 0.98)) { 81 | return jpegResult 82 | } 83 | } 84 | 85 | // 如果压缩结果都不理想,返回原文件 86 | if (pngResult.size >= originalSize * 0.98) { 87 | return file 88 | } 89 | 90 | return pngResult 91 | } 92 | 93 | // 对于JPEG文件,使用渐进式质量压缩 94 | if (file.type.includes('jpeg') || file.type.includes('jpg')) { 95 | const qualities = [ 96 | quality, 97 | Math.max(0.5, quality - 0.2), 98 | Math.max(0.3, quality - 0.4), 99 | ] 100 | 101 | for (const q of qualities) { 102 | const result = await canvasCompressWithFormat( 103 | file, 104 | targetWidth, 105 | targetHeight, 106 | file.type, 107 | q, 108 | ) 109 | 110 | // 如果找到合适的压缩结果,返回 111 | if (result.size < originalSize * 0.98) { 112 | return result 113 | } 114 | } 115 | 116 | // 如果所有质量都不理想,返回原文件 117 | return file 118 | } 119 | 120 | // 其他格式,使用标准压缩 121 | const result = await canvasCompressWithFormat( 122 | file, 123 | targetWidth, 124 | targetHeight, 125 | file.type, 126 | quality, 127 | ) 128 | 129 | // 如果压缩后文件大于或接近原文件大小,返回原文件 130 | // 使用 98% 阈值,避免微小的压缩效果 131 | if (result.size >= originalSize * 0.98) { 132 | return file 133 | } 134 | 135 | return result 136 | } 137 | 138 | // Canvas 压缩指定格式 139 | async function canvasCompressWithFormat( 140 | file: File, 141 | targetWidth: number, 142 | targetHeight: number, 143 | outputType: string, 144 | quality?: number, 145 | ): Promise { 146 | return new Promise((resolve, reject) => { 147 | const img = new Image() 148 | const url = URL.createObjectURL(file) 149 | 150 | img.onload = () => { 151 | URL.revokeObjectURL(url) 152 | 153 | const canvas = document.createElement('canvas') 154 | const ctx = canvas.getContext('2d', { 155 | alpha: outputType.includes('png'), 156 | willReadFrequently: false, 157 | // 不使用 colorSpace 以提高兼容性 158 | }) 159 | 160 | if (!ctx) { 161 | reject(new Error('Failed to get canvas context')) 162 | return 163 | } 164 | 165 | canvas.width = targetWidth 166 | canvas.height = targetHeight 167 | 168 | // 根据输出类型优化渲染 169 | ctx.imageSmoothingEnabled = true 170 | ctx.imageSmoothingQuality = 'high' 171 | 172 | // 如果转换为JPEG,添加白色背景 173 | if (outputType.includes('jpeg') && !file.type.includes('jpeg')) { 174 | ctx.fillStyle = '#FFFFFF' 175 | ctx.fillRect(0, 0, targetWidth, targetHeight) 176 | } 177 | 178 | ctx.drawImage(img, 0, 0, targetWidth, targetHeight) 179 | 180 | canvas.toBlob( 181 | (blob) => { 182 | if (blob) { 183 | resolve(blob) 184 | } else { 185 | reject(new Error('Failed to create blob')) 186 | } 187 | }, 188 | outputType, 189 | quality, 190 | ) 191 | } 192 | 193 | img.onerror = () => { 194 | URL.revokeObjectURL(url) 195 | reject(new Error('Failed to load image')) 196 | } 197 | 198 | img.crossOrigin = 'anonymous' 199 | img.src = url 200 | }) 201 | } 202 | 203 | // 获取图片原始尺寸的辅助函数 204 | function getImageDimensions( 205 | file: File, 206 | ): Promise<{ width: number; height: number }> { 207 | return new Promise((resolve, reject) => { 208 | const img = new Image() 209 | const url = URL.createObjectURL(file) 210 | 211 | img.onload = () => { 212 | URL.revokeObjectURL(url) // 清理 URL 213 | resolve({ width: img.width, height: img.height }) 214 | } 215 | img.onerror = () => { 216 | URL.revokeObjectURL(url) // 清理 URL 217 | reject(new Error('Failed to load image')) 218 | } 219 | img.src = url 220 | }) 221 | } 222 | -------------------------------------------------------------------------------- /src/utils/preprocessImage.ts: -------------------------------------------------------------------------------- 1 | import { memoryManager } from './memoryManager' 2 | import type { PreprocessOptions, ResizeOptions } from '../types' 3 | 4 | // Load a Blob/File/String into an HTMLImageElement 5 | async function loadImage(src: Blob | File | string): Promise { 6 | return new Promise((resolve, reject) => { 7 | const img = new Image() 8 | img.crossOrigin = 'anonymous' 9 | img.onload = () => resolve(img) 10 | img.onerror = () => reject(new Error('Failed to load image')) 11 | if (src instanceof Blob) { 12 | const url = URL.createObjectURL(src) 13 | img.onload = () => { 14 | URL.revokeObjectURL(url) 15 | resolve(img) 16 | } 17 | img.src = url 18 | } else { 19 | img.src = src 20 | } 21 | }) 22 | } 23 | 24 | function computeResize( 25 | srcW: number, 26 | srcH: number, 27 | resize?: ResizeOptions, 28 | ): { width: number; height: number } { 29 | if (!resize) return { width: srcW, height: srcH } 30 | 31 | const { 32 | targetWidth, 33 | targetHeight, 34 | maxWidth, 35 | maxHeight, 36 | fit = 'contain', 37 | } = resize 38 | 39 | // Direct target sizing 40 | if (targetWidth && targetHeight) 41 | return { width: targetWidth, height: targetHeight } 42 | if (targetWidth && !targetHeight) { 43 | const scale = targetWidth / srcW 44 | return { width: targetWidth, height: Math.round(srcH * scale) } 45 | } 46 | if (!targetWidth && targetHeight) { 47 | const scale = targetHeight / srcH 48 | return { width: Math.round(srcW * scale), height: targetHeight } 49 | } 50 | 51 | // Max constraints 52 | let w = srcW 53 | let h = srcH 54 | if (maxWidth || maxHeight) { 55 | const maxW = maxWidth ?? Number.POSITIVE_INFINITY 56 | const maxH = maxHeight ?? Number.POSITIVE_INFINITY 57 | const scale = Math.min(maxW / srcW, maxH / srcH, 1) 58 | if (fit === 'scale-down') { 59 | if (scale < 1) { 60 | w = Math.round(srcW * scale) 61 | h = Math.round(srcH * scale) 62 | } 63 | } else if (fit === 'contain') { 64 | w = Math.round(srcW * scale) 65 | h = Math.round(srcH * scale) 66 | } else if (fit === 'cover') { 67 | // cover 意味着输出尺寸受 max 限制,但可能超出一边;这里等同 contain 处理 68 | const coverScale = Math.min(maxW / srcW, maxH / srcH, 1) 69 | w = Math.round(srcW * coverScale) 70 | h = Math.round(srcH * coverScale) 71 | } 72 | } 73 | return { width: w, height: h } 74 | } 75 | 76 | export interface PreprocessInputMeta { 77 | width: number 78 | height: number 79 | mimeType: string 80 | } 81 | 82 | export interface PreprocessOutput { 83 | blob: Blob 84 | width: number 85 | height: number 86 | mimeType: string 87 | } 88 | 89 | export async function preprocessImage( 90 | src: Blob | File | string, 91 | options: PreprocessOptions, 92 | ): Promise { 93 | const img = await loadImage(src) 94 | const naturalW = img.naturalWidth 95 | const naturalH = img.naturalHeight 96 | 97 | // Resolve crop region in natural pixels 98 | const crop = options.crop ?? { x: 0, y: 0, width: naturalW, height: naturalH } 99 | const cropX = Math.max(0, Math.min(crop.x, naturalW)) 100 | const cropY = Math.max(0, Math.min(crop.y, naturalH)) 101 | const cropW = Math.max(1, Math.min(crop.width, naturalW - cropX)) 102 | const cropH = Math.max(1, Math.min(crop.height, naturalH - cropY)) 103 | 104 | const rotate = (((options.rotate ?? 0) % 360) + 360) % 360 105 | const flipH = !!options.flipHorizontal 106 | const flipV = !!options.flipVertical 107 | 108 | // Determine intermediate canvas size after rotation 109 | const rad = (rotate * Math.PI) / 180 110 | const cos = Math.cos(rad) 111 | const sin = Math.sin(rad) 112 | const rotW = Math.abs(cropW * cos) + Math.abs(cropH * sin) 113 | const rotH = Math.abs(cropW * sin) + Math.abs(cropH * cos) 114 | 115 | // Final output size after resize 116 | const size = computeResize(Math.round(rotW), Math.round(rotH), options.resize) 117 | 118 | // Create canvas 119 | const canvas = memoryManager.createManagedCanvas() 120 | const ctx = canvas.getContext('2d') 121 | if (!ctx) throw new Error('Failed to get 2D context') 122 | 123 | canvas.width = size.width 124 | canvas.height = size.height 125 | 126 | // Draw pipeline: translate to center, apply rotation/flip, draw cropped image, then scale to fit output 127 | ctx.save() 128 | 129 | // Map output canvas to rotated crop space 130 | // We draw into an offscreen temp canvas first at rotated crop size, then draw scaled into final canvas for quality 131 | const temp = memoryManager.createManagedCanvas() 132 | const tctx = temp.getContext('2d') 133 | if (!tctx) throw new Error('Failed to get temp 2D context') 134 | temp.width = Math.round(rotW) 135 | temp.height = Math.round(rotH) 136 | 137 | // Clear 138 | tctx.clearRect(0, 0, temp.width, temp.height) 139 | tctx.save() 140 | // Move to center of temp and rotate/flip, then draw crop around center 141 | tctx.translate(temp.width / 2, temp.height / 2) 142 | if (rotate !== 0) tctx.rotate(rad) 143 | tctx.scale(flipH ? -1 : 1, flipV ? -1 : 1) 144 | // After rotation, the source crop should be centered and drawn with its top-left at (-cropW/2, -cropH/2) 145 | tctx.drawImage( 146 | img, 147 | cropX, 148 | cropY, 149 | cropW, 150 | cropH, 151 | -cropW / 2, 152 | -cropH / 2, 153 | cropW, 154 | cropH, 155 | ) 156 | tctx.restore() 157 | 158 | // Now scale temp into final canvas 159 | // Use drawImage for scaling; browsers apply good quality sampling 160 | ctx.clearRect(0, 0, canvas.width, canvas.height) 161 | ctx.drawImage(temp, 0, 0, canvas.width, canvas.height) 162 | ctx.restore() 163 | 164 | const mime = 165 | options.outputType || 166 | (src instanceof Blob ? src.type : 'image/png') || 167 | 'image/png' 168 | const quality = options.outputQuality 169 | 170 | const blob: Blob = await new Promise((resolve, reject) => { 171 | canvas.toBlob( 172 | (b) => (b ? resolve(b) : reject(new Error('Failed to export canvas'))), 173 | mime, 174 | quality, 175 | ) 176 | }) 177 | 178 | // Cleanup temp canvas 179 | memoryManager.cleanupCanvasElement(temp) 180 | memoryManager.cleanupCanvasElement(canvas) 181 | 182 | return { 183 | blob, 184 | width: canvas.width, 185 | height: canvas.height, 186 | mimeType: blob.type, 187 | } 188 | } 189 | 190 | export default preprocessImage 191 | -------------------------------------------------------------------------------- /docs/performance-optimization-guide.md: -------------------------------------------------------------------------------- 1 | # 图片压缩性能优化指南 2 | 3 | ## 概述 4 | 5 | 本库已经实现了多项性能优化,解决了大量图片压缩时的性能瓶颈问题。主要优化包括: 6 | 7 | 1. **队列管理系统** - 控制并发数量,避免系统过载 8 | 2. **Worker支持** - 利用Web Workers进行后台压缩计算 9 | 3. **设备性能自适应** - 根据设备性能动态调整并发数量 10 | 4. **内存管理** - 智能资源管理和内存清理 11 | 12 | ## 使用方法 13 | 14 | ### 1. 增强的压缩函数(推荐) 15 | 16 | 使用新的 `compressEnhanced` 函数获得最佳性能: 17 | 18 | ```typescript 19 | import { compressEnhanced } from 'browser-compress-image' 20 | 21 | // 单个文件压缩 22 | const result = await compressEnhanced(file, { 23 | quality: 0.8, 24 | useWorker: true, // 启用Worker支持 25 | useQueue: true, // 启用队列管理 26 | priority: 10, // 设置优先级(可选) 27 | timeout: 30000, // 设置超时时间 28 | }) 29 | 30 | // 批量文件压缩 31 | import { compressEnhancedBatch } from 'browser-compress-image' 32 | 33 | const results = await compressEnhancedBatch(files, { 34 | quality: 0.8, 35 | useWorker: true, 36 | useQueue: true, 37 | }) 38 | ``` 39 | 40 | ### 2. 兼容性说明 41 | 42 | - **Worker支持**: 自动检测浏览器Worker支持,不支持时自动降级到主线程 43 | - **DOM依赖工具**: `canvas` 和 `jsquash` 工具使用DOM API,在Worker中会自动降级到主线程 44 | - **Worker兼容工具**: `browser-image-compression`, `compressorjs`, `gifsicle`, `tinypng` 支持Worker 45 | 46 | ## 性能特性 47 | 48 | ### 1. 智能并发控制 49 | 50 | 系统会根据设备性能自动调整并发数量: 51 | 52 | - **移动设备**: 最多2个并发任务 53 | - **低性能设备**: 2个并发任务 54 | - **中等性能设备**: 3个并发任务 55 | - **高性能设备**: 最多5个并发任务(基于CPU核心数) 56 | 57 | ```typescript 58 | import { 59 | getCompressionStats, 60 | configureCompression, 61 | } from 'browser-compress-image' 62 | 63 | // 查看当前状态 64 | const stats = getCompressionStats() 65 | console.log('队列统计:', stats.queue) 66 | console.log('Worker支持:', stats.worker.supported) 67 | 68 | // 手动调整并发数量(可选) 69 | configureCompression({ maxConcurrency: 3 }) 70 | ``` 71 | 72 | ### 2. 设备性能检测 73 | 74 | ```typescript 75 | import { PerformanceDetector } from 'browser-compress-image' 76 | 77 | const detector = PerformanceDetector.getInstance() 78 | const deviceInfo = detector.detectDevice() 79 | 80 | console.log('设备信息:', { 81 | isMobile: deviceInfo.isMobile, 82 | cpuCores: deviceInfo.cpuCores, 83 | memoryGB: deviceInfo.memoryGB, 84 | performance: deviceInfo.estimatedPerformance, 85 | }) 86 | 87 | // 获取建议的并发数量 88 | const optimalConcurrency = detector.calculateOptimalConcurrency() 89 | ``` 90 | 91 | ### 3. 内存管理 92 | 93 | ```typescript 94 | import { 95 | memoryManager, 96 | checkMemoryBeforeOperation, 97 | } from 'browser-compress-image' 98 | 99 | // 检查内存状态 100 | const memoryStats = memoryManager.getMemoryStats() 101 | console.log('内存使用率:', memoryStats.memoryUsagePercentage + '%') 102 | 103 | // 在大操作前检查内存 104 | if (checkMemoryBeforeOperation(fileSize)) { 105 | // 内存充足,可以继续 106 | await compressEnhanced(file, options) 107 | } else { 108 | console.warn('内存不足,建议减少并发任务') 109 | } 110 | 111 | // 手动清理内存(通常不需要) 112 | memoryManager.performCleanup() 113 | ``` 114 | 115 | ## 在应用中集成 116 | 117 | ### 替换现有压缩调用 118 | 119 | 如果你当前使用的是: 120 | 121 | ```typescript 122 | // 旧的方式 123 | const compressed = await compress(file, { quality: 0.8 }) 124 | ``` 125 | 126 | 可以直接替换为: 127 | 128 | ```typescript 129 | // 新的优化方式 130 | const compressed = await compressEnhanced(file, { 131 | quality: 0.8, 132 | useWorker: true, 133 | useQueue: true, 134 | }) 135 | ``` 136 | 137 | ### 批量处理优化 138 | 139 | 对于大量图片的处理: 140 | 141 | ```typescript 142 | // 替换 App.vue 中的批量压缩 143 | async function compressImages(items: ImageItem[]) { 144 | // 使用增强的批量压缩 145 | const files = items.map((item) => item.file) 146 | 147 | try { 148 | const results = await compressEnhancedBatch(files, { 149 | quality: globalQuality.value, 150 | preserveExif: preserveExif.value, 151 | toolConfigs: enabledToolConfigs, 152 | useWorker: true, 153 | useQueue: true, 154 | }) 155 | 156 | // 处理结果 157 | results.forEach((result, index) => { 158 | const item = items[index] 159 | if (item.compressedUrl) { 160 | URL.revokeObjectURL(item.compressedUrl) 161 | } 162 | item.compressedUrl = URL.createObjectURL(result) 163 | item.compressedSize = result.size 164 | item.compressionRatio = 165 | ((item.originalSize - result.size) / item.originalSize) * 100 166 | }) 167 | } catch (error) { 168 | console.error('批量压缩失败:', error) 169 | } 170 | } 171 | ``` 172 | 173 | ## 性能监控 174 | 175 | ### 队列统计 176 | 177 | ```typescript 178 | import { getCompressionStats } from 'browser-compress-image' 179 | 180 | // 定期检查队列状态 181 | setInterval(() => { 182 | const stats = getCompressionStats() 183 | console.log( 184 | `队列状态: 等待${stats.queue.pending}, 运行${stats.queue.running}, 完成${stats.queue.completed}`, 185 | ) 186 | }, 5000) 187 | ``` 188 | 189 | ### 内存监控 190 | 191 | ```typescript 192 | import { memoryManager } from 'browser-compress-image' 193 | 194 | // 监控内存使用 195 | setInterval(() => { 196 | const stats = memoryManager.getMemoryStats() 197 | if (stats.memoryUsagePercentage > 80) { 198 | console.warn('内存使用率过高:', stats.memoryUsagePercentage + '%') 199 | } 200 | }, 10000) 201 | ``` 202 | 203 | ## 最佳实践 204 | 205 | 1. **总是使用增强的压缩函数**: `compressEnhanced` 和 `compressEnhancedBatch` 206 | 2. **启用Worker**: 设置 `useWorker: true` 来利用后台处理 207 | 3. **启用队列**: 设置 `useQueue: true` 来控制并发 208 | 4. **设置合适的超时**: 为大文件设置较长的超时时间 209 | 5. **监控性能**: 定期检查队列和内存状态 210 | 6. **清理资源**: 不再需要的对象URL要及时清理 211 | 212 | ## 故障排除 213 | 214 | ### Worker不工作 215 | 216 | ```typescript 217 | import { compressionWorkerManager } from 'browser-compress-image' 218 | 219 | if (!compressionWorkerManager.isSupported()) { 220 | console.log('Worker不支持,将使用主线程压缩') 221 | } 222 | ``` 223 | 224 | ### 内存不足 225 | 226 | ```typescript 227 | import { memoryManager } from 'browser-compress-image' 228 | 229 | if (memoryManager.isMemoryCritical()) { 230 | // 减少并发数量 231 | configureCompression({ maxConcurrency: 1 }) 232 | 233 | // 或清理内存 234 | memoryManager.performCleanup() 235 | } 236 | ``` 237 | 238 | ### 队列阻塞 239 | 240 | ```typescript 241 | import { clearCompressionQueue } from 'browser-compress-image' 242 | 243 | // 清空队列中的待处理任务 244 | clearCompressionQueue() 245 | ``` 246 | 247 | ## 迁移指南 248 | 249 | ### 从旧版本升级 250 | 251 | 1. 安装最新版本 252 | 2. 导入新的压缩函数 253 | 3. 替换压缩调用 254 | 4. 测试性能改进 255 | 256 | ### 配置选项 257 | 258 | ```typescript 259 | // 高性能配置(桌面端) 260 | const highPerformanceConfig = { 261 | useWorker: true, 262 | useQueue: true, 263 | priority: 10, 264 | timeout: 60000, 265 | } 266 | 267 | // 移动端配置 268 | const mobileConfig = { 269 | useWorker: true, 270 | useQueue: true, 271 | priority: 5, 272 | timeout: 30000, 273 | } 274 | 275 | // 选择合适的配置 276 | const config = /Mobile|Android|iOS/.test(navigator.userAgent) 277 | ? mobileConfig 278 | : highPerformanceConfig 279 | 280 | await compressEnhanced(file, { quality: 0.8, ...config }) 281 | ``` 282 | -------------------------------------------------------------------------------- /CSS_COMPATIBILITY.md: -------------------------------------------------------------------------------- 1 | # CSS兼容性解决方案 2 | 3 | ## 🚀 推荐的Vite插件 4 | 5 | ### 1. Autoprefixer - 自动添加浏览器前缀 6 | 7 | ```bash 8 | npm install -D autoprefixer 9 | ``` 10 | 11 | **vite.config.ts 配置:** 12 | 13 | ```typescript 14 | css: { 15 | postcss: { 16 | plugins: [ 17 | autoprefixer({ 18 | overrideBrowserslist: [ 19 | 'Chrome >= 35', 20 | 'Firefox >= 38', 21 | 'Edge >= 12', 22 | 'Explorer >= 10', 23 | 'iOS >= 8', 24 | 'Safari >= 8', 25 | 'Android >= 4.4', 26 | 'Opera >= 22', 27 | ], 28 | }), 29 | ], 30 | }, 31 | }, 32 | ``` 33 | 34 | ### 2. Legacy Support - 支持旧版浏览器 35 | 36 | ```bash 37 | npm install -D @vitejs/plugin-legacy 38 | ``` 39 | 40 | **vite.config.ts 配置:** 41 | 42 | ```typescript 43 | import legacy from '@vitejs/plugin-legacy' 44 | 45 | plugins: [ 46 | legacy({ 47 | targets: ['ie >= 11'], 48 | additionalLegacyPolyfills: ['regenerator-runtime/runtime'], 49 | modernPolyfills: true, 50 | }), 51 | ] 52 | ``` 53 | 54 | ### 3. PostCSS Preset Env - 现代CSS特性回退 55 | 56 | ```bash 57 | npm install -D postcss-preset-env 58 | ``` 59 | 60 | **vite.config.ts 配置:** 61 | 62 | ```typescript 63 | css: { 64 | postcss: { 65 | plugins: [ 66 | postcssPresetEnv({ 67 | stage: 3, 68 | features: { 69 | 'nesting-rules': true, 70 | 'custom-properties': true, 71 | 'custom-media-queries': true, 72 | }, 73 | }), 74 | ], 75 | }, 76 | }, 77 | ``` 78 | 79 | ### 4. CSS Nano - 生产环境CSS优化 80 | 81 | ```bash 82 | npm install -D cssnano 83 | ``` 84 | 85 | **vite.config.ts 配置:** 86 | 87 | ```typescript 88 | css: { 89 | postcss: { 90 | plugins: [ 91 | process.env.NODE_ENV === 'production' ? cssnano({ 92 | preset: ['default', { 93 | discardComments: { removeAll: true }, 94 | }], 95 | }) : null, 96 | ].filter(Boolean), 97 | }, 98 | }, 99 | ``` 100 | 101 | ## 🎯 构建目标配置 102 | 103 | **vite.config.ts:** 104 | 105 | ```typescript 106 | build: { 107 | target: ['es2015', 'edge88', 'firefox78', 'chrome87', 'safari12'], 108 | cssTarget: ['chrome61', 'firefox60', 'safari11'], 109 | }, 110 | ``` 111 | 112 | ## 📱 CSS兼容性最佳实践 113 | 114 | ### 1. 使用CSS Autoprefixer支持的属性 115 | 116 | ```css 117 | /* 自动添加前缀 */ 118 | .element { 119 | display: flex; 120 | backdrop-filter: blur(10px); 121 | transform: translateY(-4px); 122 | } 123 | 124 | /* 编译后 */ 125 | .element { 126 | display: -webkit-flex; 127 | display: flex; 128 | -webkit-backdrop-filter: blur(10px); 129 | backdrop-filter: blur(10px); 130 | -webkit-transform: translateY(-4px); 131 | transform: translateY(-4px); 132 | } 133 | ``` 134 | 135 | ### 2. 使用@supports进行特性检测 136 | 137 | ```css 138 | /* 检测backdrop-filter支持 */ 139 | @supports (backdrop-filter: blur(10px)) { 140 | .modal { 141 | backdrop-filter: blur(10px); 142 | } 143 | } 144 | 145 | @supports not (backdrop-filter: blur(10px)) { 146 | .modal { 147 | background: rgba(255, 255, 255, 0.95); 148 | } 149 | } 150 | ``` 151 | 152 | ### 3. Flexbox和Grid回退 153 | 154 | ```css 155 | /* Flexbox 回退 */ 156 | .container { 157 | display: table; /* IE9+ */ 158 | display: -webkit-flex; /* Safari */ 159 | display: flex; /* 现代浏览器 */ 160 | } 161 | 162 | /* Grid 回退 */ 163 | .grid { 164 | display: flex; /* 回退 */ 165 | flex-wrap: wrap; 166 | } 167 | 168 | @supports (display: grid) { 169 | .grid { 170 | display: grid; 171 | grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); 172 | } 173 | } 174 | ``` 175 | 176 | ### 4. 常见兼容性问题修复 177 | 178 | #### 4.1 object-fit 兼容性 179 | 180 | ```css 181 | .image { 182 | width: 100%; 183 | height: 400px; 184 | object-fit: cover; 185 | font-family: 'object-fit: cover;'; /* IE9-11 polyfill */ 186 | } 187 | 188 | /* 不支持 object-fit 的回退 */ 189 | @supports not (object-fit: cover) { 190 | .image { 191 | background-size: cover; 192 | background-position: center; 193 | background-repeat: no-repeat; 194 | } 195 | } 196 | ``` 197 | 198 | #### 4.2 CSS 变量回退 199 | 200 | ```css 201 | :root { 202 | --primary-color: #667eea; 203 | --border-radius: 8px; 204 | } 205 | 206 | .button { 207 | background: #667eea; /* 回退值 */ 208 | background: var(--primary-color); 209 | border-radius: 8px; /* 回退值 */ 210 | border-radius: var(--border-radius); 211 | } 212 | ``` 213 | 214 | #### 4.3 渐变背景兼容性 215 | 216 | ```css 217 | .gradient { 218 | background: #667eea; /* 回退 */ 219 | background: -webkit-linear-gradient(45deg, #667eea, #764ba2); 220 | background: -moz-linear-gradient(45deg, #667eea, #764ba2); 221 | background: linear-gradient(45deg, #667eea, #764ba2); 222 | } 223 | ``` 224 | 225 | ## 🔧 完整的vite.config.ts示例 226 | 227 | ```typescript 228 | import { defineConfig } from 'vite' 229 | import Vue from '@vitejs/plugin-vue' 230 | import legacy from '@vitejs/plugin-legacy' 231 | import autoprefixer from 'autoprefixer' 232 | import postcssPresetEnv from 'postcss-preset-env' 233 | 234 | export default defineConfig({ 235 | plugins: [ 236 | Vue(), 237 | legacy({ 238 | targets: ['ie >= 11'], 239 | additionalLegacyPolyfills: ['regenerator-runtime/runtime'], 240 | }), 241 | ], 242 | 243 | css: { 244 | postcss: { 245 | plugins: [ 246 | autoprefixer({ 247 | overrideBrowserslist: [ 248 | 'Chrome >= 35', 249 | 'Firefox >= 38', 250 | 'Edge >= 12', 251 | 'Explorer >= 10', 252 | 'iOS >= 8', 253 | 'Safari >= 8', 254 | 'Android >= 4.4', 255 | 'Opera >= 22', 256 | ], 257 | }), 258 | postcssPresetEnv({ 259 | stage: 3, 260 | features: { 261 | 'nesting-rules': true, 262 | 'custom-properties': true, 263 | }, 264 | }), 265 | ], 266 | }, 267 | }, 268 | 269 | build: { 270 | target: ['es2015', 'edge88', 'firefox78', 'chrome87', 'safari12'], 271 | cssTarget: ['chrome61', 'firefox60', 'safari11'], 272 | }, 273 | }) 274 | ``` 275 | 276 | ## 📋 浏览器支持目标 277 | 278 | - **Chrome**: >= 61 279 | - **Firefox**: >= 60 280 | - **Safari**: >= 11 281 | - **Edge**: >= 16 282 | - **IE**: >= 11 (通过 legacy plugin) 283 | - **iOS Safari**: >= 10 284 | - **Android Chrome**: >= 61 285 | 286 | ## 🛠️ 额外工具推荐 287 | 288 | ### 1. Browserslist 配置 289 | 290 | 创建 `.browserslistrc` 文件: 291 | 292 | ``` 293 | > 1% 294 | last 2 versions 295 | not dead 296 | ie >= 11 297 | ``` 298 | 299 | ### 2. StyleLint 配置 300 | 301 | ```bash 302 | npm install -D stylelint stylelint-config-standard 303 | ``` 304 | 305 | **stylelint.config.js:** 306 | 307 | ```javascript 308 | module.exports = { 309 | extends: ['stylelint-config-standard'], 310 | rules: { 311 | 'property-no-vendor-prefix': null, 312 | 'value-no-vendor-prefix': null, 313 | }, 314 | } 315 | ``` 316 | 317 | 这套方案可以确保你的CSS在各种浏览器中都能正常工作,特别是解决Safari和Chrome之间的兼容性差异。 318 | -------------------------------------------------------------------------------- /src/tools/compressWithTinyPng.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from '../utils/lruCache' 2 | import logger from '../utils/logger' 3 | 4 | // 缓存对象,用于存储文件的压缩结果(最多缓存50个文件) 5 | const compressionCache = new LRUCache(50) 6 | 7 | // 生成文件的唯一标识符 8 | function generateFileKey(file: File, options: any): string { 9 | // TinyPNG不支持质量调整,所以忽略quality参数 10 | // 只考虑文件内容、尺寸调整参数 11 | const relevantOptions = { 12 | targetWidth: options.targetWidth, 13 | targetHeight: options.targetHeight, 14 | maxWidth: options.maxWidth, 15 | maxHeight: options.maxHeight, 16 | mode: options.mode === 'keepQuality' ? options.mode : undefined, // 只有keepQuality模式下才考虑尺寸调整 17 | } 18 | 19 | // 使用文件名、大小、最后修改时间和相关选项生成key 20 | return `${file.name}_${file.size}_${file.lastModified}_${JSON.stringify(relevantOptions)}` 21 | } 22 | 23 | export function compressWithTinyPng( 24 | file: File, 25 | options: { 26 | quality: number 27 | mode: string 28 | targetWidth?: number 29 | targetHeight?: number 30 | maxWidth?: number 31 | maxHeight?: number 32 | preserveExif?: boolean 33 | key?: string 34 | }, 35 | ): Promise { 36 | return new Promise(async (resolve, reject) => { 37 | try { 38 | // 生成缓存key 39 | const cacheKey = generateFileKey(file, options) 40 | 41 | // 检查缓存 42 | if (compressionCache.has(cacheKey)) { 43 | logger.log('TinyPNG: Using cached result for file:', file.name) 44 | resolve(compressionCache.get(cacheKey)!) 45 | return 46 | } 47 | // 检查是否提供了 TinyPNG API 密钥 48 | const apiKey = options.key 49 | 50 | if (!apiKey) { 51 | reject( 52 | 'TinyPNG API key is required. Please set TINYPNG_API_KEY environment variable or window.TINYPNG_API_KEY', 53 | ) 54 | return 55 | } 56 | 57 | // 验证文件类型 58 | const supportedTypes = [ 59 | 'image/jpeg', 60 | 'image/jpg', 61 | 'image/png', 62 | 'image/webp', 63 | ] 64 | if (!supportedTypes.includes(file.type)) { 65 | reject( 66 | `Unsupported file type: ${file.type}. TinyPNG supports JPEG, PNG, and WebP images.`, 67 | ) 68 | return 69 | } 70 | 71 | // 步骤1: 上传图片到 TinyPNG 进行压缩 72 | const uploadResponse = await fetch('https://api.tinify.com/shrink', { 73 | method: 'POST', 74 | headers: { 75 | Authorization: `Basic ${btoa(`api:${apiKey}`)}`, 76 | 'Content-Type': file.type, 77 | }, 78 | body: file, 79 | }) 80 | 81 | if (!uploadResponse.ok) { 82 | const errorText = await uploadResponse.text() 83 | reject(`TinyPNG upload failed: ${uploadResponse.status} - ${errorText}`) 84 | return 85 | } 86 | 87 | const outputUrl = uploadResponse.headers.get('Location') 88 | 89 | if (!outputUrl) { 90 | reject('No output URL received from TinyPNG') 91 | return 92 | } 93 | 94 | // 如果需要调整尺寸,构建调整选项 95 | let resizeOptions: any = null 96 | if ( 97 | options.mode === 'keepQuality' && 98 | (options.targetWidth || 99 | options.targetHeight || 100 | options.maxWidth || 101 | options.maxHeight) 102 | ) { 103 | resizeOptions = {} 104 | 105 | if (options.targetWidth && options.targetHeight) { 106 | resizeOptions.method = 'fit' 107 | resizeOptions.width = options.targetWidth 108 | resizeOptions.height = options.targetHeight 109 | } else if (options.maxWidth && options.maxHeight) { 110 | resizeOptions.method = 'scale' 111 | resizeOptions.width = options.maxWidth 112 | resizeOptions.height = options.maxHeight 113 | } else if (options.targetWidth) { 114 | resizeOptions.method = 'scale' 115 | resizeOptions.width = options.targetWidth 116 | } else if (options.targetHeight) { 117 | resizeOptions.method = 'scale' 118 | resizeOptions.height = options.targetHeight 119 | } 120 | } 121 | 122 | let finalUrl = outputUrl 123 | 124 | // 步骤2: 如果需要调整尺寸,发送调整请求 125 | if (resizeOptions) { 126 | const resizeResponse = await fetch(outputUrl, { 127 | method: 'POST', 128 | headers: { 129 | Authorization: `Basic ${btoa(`api:${apiKey}`)}`, 130 | 'Content-Type': 'application/json', 131 | }, 132 | body: JSON.stringify({ 133 | resize: resizeOptions, 134 | }), 135 | }) 136 | 137 | if (!resizeResponse.ok) { 138 | const errorText = await resizeResponse.text() 139 | reject( 140 | `TinyPNG resize failed: ${resizeResponse.status} - ${errorText}`, 141 | ) 142 | return 143 | } 144 | 145 | finalUrl = resizeResponse.headers.get('Location') || outputUrl 146 | } 147 | 148 | // 步骤3: 下载压缩后的图片 149 | const downloadResponse = await fetch(finalUrl, { 150 | headers: { 151 | Authorization: `Basic ${btoa(`api:${apiKey}`)}`, 152 | }, 153 | }) 154 | 155 | if (!downloadResponse.ok) { 156 | reject( 157 | `Failed to download compressed image: ${downloadResponse.status}`, 158 | ) 159 | return 160 | } 161 | 162 | const compressedBlob = await downloadResponse.blob() 163 | 164 | // 检查压缩效果 165 | if (compressedBlob.size >= file.size * 0.98) { 166 | logger.warn( 167 | 'TinyPNG compression did not significantly reduce file size, returning original file', 168 | ) 169 | // 缓存原始文件 170 | compressionCache.set(cacheKey, file) 171 | resolve(file) 172 | } else { 173 | // 创建一个新的 Blob,确保正确的 MIME 类型 174 | const finalBlob = new Blob([compressedBlob], { type: file.type }) 175 | // 缓存压缩结果 176 | compressionCache.set(cacheKey, finalBlob) 177 | resolve(finalBlob) 178 | } 179 | } catch (error) { 180 | logger.error('TinyPNG compression error:', error) 181 | reject(error) 182 | } 183 | }) 184 | } 185 | 186 | // 导出缓存管理功能 187 | export function clearTinyPngCache() { 188 | compressionCache.clear() 189 | logger.log('TinyPNG cache cleared') 190 | } 191 | export function getTinyPngCacheSize() { 192 | return compressionCache.size 193 | } 194 | 195 | export function getTinyPngCacheInfo() { 196 | const cacheEntries = Array.from(compressionCache.entries()).map( 197 | ([key, blob]) => ({ 198 | key, 199 | size: blob.size, 200 | type: blob.type, 201 | }), 202 | ) 203 | 204 | const stats = compressionCache.getStats() 205 | 206 | return { 207 | totalEntries: stats.size, 208 | maxSize: stats.maxSize, 209 | usageRate: stats.usageRate, 210 | entries: cacheEntries, 211 | } 212 | } 213 | 214 | // 配置缓存最大大小 215 | export function configureTinyPngCache(maxSize: number = 50) { 216 | compressionCache.setMaxSize(maxSize) 217 | logger.log(`TinyPNG cache reconfigured with max size: ${maxSize}`) 218 | } 219 | -------------------------------------------------------------------------------- /docs/configuration-guide.md: -------------------------------------------------------------------------------- 1 | # 工具配置指南 2 | 3 | 本文档展示了如何根据不同场景选择和配置压缩工具,以优化打包体积和性能。 4 | 5 | ## 配置策略概览 6 | 7 | ### 1. 最小体积配置 (≈8-10KB) 8 | 9 | 适用于移动端应用、博客等对打包体积敏感的场景。 10 | 11 | ```typescript 12 | import { compressWithTools, registerCanvas } from 'browser-compress-image' 13 | 14 | // 只注册 Canvas 工具 15 | registerCanvas() 16 | 17 | const result = await compressWithTools(file, { 18 | quality: 0.8, 19 | mode: 'keepSize', 20 | }) 21 | ``` 22 | 23 | **优点**: 体积最小,兼容性最好,无外部依赖 24 | **缺点**: 压缩效果一般,功能有限 25 | **适用场景**: 移动端、博客、简单 Web 应用 26 | 27 | ### 2. 平衡配置 (≈40-50KB) 28 | 29 | 适用于大多数 Web 应用,平衡体积和质量。 30 | 31 | ```typescript 32 | import { 33 | compressWithTools, 34 | registerCanvas, 35 | registerCompressorJS, 36 | } from 'browser-compress-image' 37 | 38 | registerCanvas() 39 | registerCompressorJS() 40 | 41 | const result = await compressWithTools(file, { 42 | quality: 0.8, 43 | mode: 'keepSize', 44 | }) 45 | ``` 46 | 47 | **优点**: 体积适中,JPEG 压缩效果好,功能较全 48 | **缺点**: 比最小配置稍大 49 | **适用场景**: 大多数 Web 应用、电商网站 50 | 51 | ### 3. 高质量配置 (≈100-150KB) 52 | 53 | 适用于图片处理应用,提供最佳压缩质量。 54 | 55 | ```typescript 56 | import { 57 | compressWithTools, 58 | registerJsquash, 59 | registerCompressorJS, 60 | registerCanvas, 61 | } from 'browser-compress-image' 62 | 63 | registerJsquash() 64 | registerCompressorJS() 65 | registerCanvas() 66 | 67 | const result = await compressWithTools(file, { 68 | quality: 0.9, 69 | mode: 'keepQuality', 70 | }) 71 | ``` 72 | 73 | **优点**: 压缩质量最佳,支持现代格式,算法先进 74 | **缺点**: 体积较大,需要 WASM 支持 75 | **适用场景**: 图片编辑器、专业图片处理应用 76 | 77 | ## 动态加载策略 78 | 79 | ### 按需加载工具 80 | 81 | 根据文件类型动态加载对应的压缩工具: 82 | 83 | ```typescript 84 | import { compressWithTools } from 'browser-compress-image' 85 | 86 | async function smartCompress(file: File) { 87 | // 根据文件类型动态加载工具 88 | if (file.type.includes('jpeg')) { 89 | const { registerCompressorJS } = await import('browser-compress-image') 90 | registerCompressorJS() 91 | } else if (file.type.includes('gif')) { 92 | const { registerGifsicle } = await import('browser-compress-image') 93 | registerGifsicle() 94 | } else { 95 | const { registerCanvas } = await import('browser-compress-image') 96 | registerCanvas() 97 | } 98 | 99 | return compressWithTools(file, { quality: 0.8 }) 100 | } 101 | ``` 102 | 103 | ### 渐进式加载 104 | 105 | 从基础工具开始,根据需要逐步加载更多工具: 106 | 107 | ```typescript 108 | class ProgressiveCompressor { 109 | private toolsLoaded = new Set() 110 | 111 | async compress(file: File, quality: 'basic' | 'good' | 'excellent' = 'good') { 112 | // 基础工具总是可用 113 | if (!this.toolsLoaded.has('canvas')) { 114 | const { registerCanvas } = await import('browser-compress-image') 115 | registerCanvas() 116 | this.toolsLoaded.add('canvas') 117 | } 118 | 119 | // 根据质量要求加载额外工具 120 | if (quality === 'good' && !this.toolsLoaded.has('compressorjs')) { 121 | const { registerCompressorJS } = await import('browser-compress-image') 122 | registerCompressorJS() 123 | this.toolsLoaded.add('compressorjs') 124 | } 125 | 126 | if (quality === 'excellent' && !this.toolsLoaded.has('jsquash')) { 127 | const { registerJsquash } = await import('browser-compress-image') 128 | registerJsquash() 129 | this.toolsLoaded.add('jsquash') 130 | } 131 | 132 | return compressWithTools(file, { quality: 0.8 }) 133 | } 134 | } 135 | ``` 136 | 137 | ## 环境特定配置 138 | 139 | ### Web 应用配置 140 | 141 | ```typescript 142 | // webpack.config.js 或 vite.config.js 中配置代码分割 143 | export default { 144 | build: { 145 | rollupOptions: { 146 | external: ['@jsquash/*'], // 可选:将 JSQuash 作为外部依赖 147 | output: { 148 | manualChunks: { 149 | 'compression-tools': [ 150 | 'browser-compress-image/compressWithCanvas', 151 | 'browser-compress-image/compressWithCompressorJS', 152 | ], 153 | }, 154 | }, 155 | }, 156 | }, 157 | } 158 | ``` 159 | 160 | ### Node.js 配置 161 | 162 | Node.js 环境下不需要考虑打包体积,可以使用完整配置: 163 | 164 | ```typescript 165 | import { registerAllTools, compressWithTools } from 'browser-compress-image' 166 | 167 | // 注册所有工具 168 | registerAllTools() 169 | 170 | // 添加 TinyPNG 配置 171 | const result = await compressWithTools(file, { 172 | quality: 0.8, 173 | toolConfigs: [ 174 | { 175 | name: 'tinypng', 176 | apiKey: process.env.TINYPNG_API_KEY, 177 | enableCache: true, 178 | maxCacheSize: 1000, 179 | }, 180 | ], 181 | }) 182 | ``` 183 | 184 | ## 自定义工具注册表 185 | 186 | ### 创建独立的压缩实例 187 | 188 | ```typescript 189 | import { 190 | ToolRegistry, 191 | compressWithTools, 192 | compressWithCanvas, 193 | compressWithCompressorJS, 194 | } from 'browser-compress-image' 195 | 196 | class CustomCompressor { 197 | private registry = new ToolRegistry() 198 | 199 | constructor() { 200 | // 只注册需要的工具 201 | this.registry.registerTool('canvas', compressWithCanvas) 202 | this.registry.registerTool('compressorjs', compressWithCompressorJS) 203 | 204 | // 设置工具优先级 205 | this.registry.setToolPriority('jpeg', ['compressorjs', 'canvas']) 206 | this.registry.setToolPriority('png', ['canvas']) 207 | } 208 | 209 | async compress(file: File, options = {}) { 210 | return compressWithTools(file, { 211 | quality: 0.8, 212 | ...options, 213 | toolRegistry: this.registry, 214 | }) 215 | } 216 | } 217 | ``` 218 | 219 | ## 性能优化建议 220 | 221 | ### 1. 预加载关键工具 222 | 223 | ```typescript 224 | // 在应用启动时预加载关键工具 225 | import { registerCanvas } from 'browser-compress-image' 226 | 227 | // 立即注册基础工具 228 | registerCanvas() 229 | 230 | // 延迟加载其他工具 231 | setTimeout(async () => { 232 | const { registerCompressorJS } = await import('browser-compress-image') 233 | registerCompressorJS() 234 | }, 1000) 235 | ``` 236 | 237 | ### 2. 使用 Web Workers 238 | 239 | ```typescript 240 | // worker.js 241 | import { compressWithTools, registerCanvas } from 'browser-compress-image' 242 | 243 | registerCanvas() 244 | 245 | self.onmessage = async (e) => { 246 | const { file, options } = e.data 247 | const result = await compressWithTools(file, options) 248 | self.postMessage(result) 249 | } 250 | ``` 251 | 252 | ### 3. 缓存压缩结果 253 | 254 | ```typescript 255 | class CachedCompressor { 256 | private cache = new Map() 257 | 258 | async compress(file: File, options = {}) { 259 | const key = `${file.name}-${file.size}-${JSON.stringify(options)}` 260 | 261 | if (this.cache.has(key)) { 262 | return this.cache.get(key) 263 | } 264 | 265 | const result = await compressWithTools(file, options) 266 | this.cache.set(key, result) 267 | return result 268 | } 269 | } 270 | ``` 271 | 272 | ## 体积分析表 273 | 274 | | 配置 | 体积估算 | 包含工具 | 适用场景 | 275 | | ---------- | -------- | -------------------------------- | -------------- | 276 | | 最小配置 | ~8KB | Canvas | 移动端、博客 | 277 | | 基础配置 | ~20KB | Canvas + CompressorJS | 企业官网 | 278 | | 平衡配置 | ~50KB | 上述 + Browser-Image-Compression | Web 应用 | 279 | | 高质量配置 | ~150KB | 上述 + JSQuash | 图片处理应用 | 280 | | 完整配置 | ~200KB+ | 所有工具 | 专业图片编辑器 | 281 | 282 | ## 最佳实践 283 | 284 | 1. **按需加载**: 根据实际使用的功能动态导入工具 285 | 2. **工具优先级**: 为不同文件格式设置合适的工具优先级 286 | 3. **环境检测**: 根据浏览器能力和网络状况选择工具 287 | 4. **渐进增强**: 从基础功能开始,逐步增强 288 | 5. **性能监控**: 监控压缩性能,优化工具组合 289 | 290 | 通过合理的配置策略,可以在保证功能的同时显著减少打包体积,提升应用性能。 291 | -------------------------------------------------------------------------------- /src/orchestrators/compareConversion.ts: -------------------------------------------------------------------------------- 1 | import { compress } from '../compress' 2 | import type { CompressOptions } from '../types' 3 | import { convertImage, type ImageConvertOptions } from '../conversion' 4 | import type { CompressResultType } from '../types' 5 | 6 | export interface ConversionCompareItemMeta { 7 | flow: 'C→T' | 'T' | 'T→C' // 压缩→转换 | 仅转换 | 转换→压缩 8 | tool?: string // 压缩工具名(若有) 9 | compressOptions?: any 10 | convertOptions: ImageConvertOptions 11 | } 12 | 13 | export interface ConversionCompareItem { 14 | meta: ConversionCompareItemMeta 15 | blob?: Blob 16 | success: boolean 17 | error?: string 18 | size?: number 19 | compressionRatio?: number // 相对"原图" 20 | duration?: number 21 | } 22 | 23 | export interface BuildConversionColumnInput { 24 | file: File 25 | compressOptions?: CompressOptions & { returnAllResults: true } 26 | convertOptions: ImageConvertOptions 27 | } 28 | 29 | export interface ConversionColumnResult { 30 | title: string // 如 "Format: webp" 31 | items: ConversionCompareItem[] 32 | } 33 | 34 | // Helper function to get tools for target format (currently unused) 35 | // function getToolsForFormat(targetFormat: string): string[] { 36 | // const formatTools: Record = { 37 | // png: ['jsquash', 'browser-image-compression', 'canvas', 'compressorjs'], 38 | // jpeg: ['jsquash', 'compressorjs', 'canvas', 'browser-image-compression'], 39 | // webp: ['jsquash', 'canvas', 'browser-image-compression', 'compressorjs'], 40 | // ico: ['canvas'] // ICO typically converted from PNG 41 | // } 42 | 43 | // return formatTools[targetFormat] || ['jsquash', 'canvas', 'browser-image-compression'] 44 | // } 45 | 46 | // C→T flow: compress then convert 47 | export async function buildConversionColumn( 48 | input: BuildConversionColumnInput, 49 | ): Promise { 50 | const { file, compressOptions, convertOptions } = input 51 | const { targetFormat } = convertOptions 52 | 53 | const items: ConversionCompareItem[] = [] 54 | 55 | try { 56 | const promises: Promise[] = 57 | [] 58 | 59 | // C→T: Compress then convert (only if compressOptions provided) 60 | if (compressOptions) { 61 | const cToTPromise = (async () => { 62 | const compressResults = await compress(file, { 63 | ...compressOptions, 64 | returnAllResults: true, 65 | type: 'blob' as CompressResultType, 66 | }) 67 | 68 | return Promise.all( 69 | compressResults.allResults 70 | .filter((result) => result.success) 71 | .map(async (result) => { 72 | try { 73 | const convertResult = await convertImage( 74 | result.result as Blob, 75 | convertOptions, 76 | ) 77 | 78 | return { 79 | meta: { 80 | flow: 'C→T' as const, 81 | tool: result.tool, 82 | compressOptions, 83 | convertOptions, 84 | }, 85 | blob: convertResult.blob, 86 | success: true, 87 | size: convertResult.blob.size, 88 | compressionRatio: 89 | ((file.size - convertResult.blob.size) / file.size) * 100, 90 | duration: result.duration + convertResult.duration, 91 | } as ConversionCompareItem 92 | } catch (error) { 93 | return { 94 | meta: { 95 | flow: 'C→T' as const, 96 | tool: result.tool, 97 | compressOptions, 98 | convertOptions, 99 | }, 100 | success: false, 101 | error: error instanceof Error ? error.message : String(error), 102 | duration: result.duration, 103 | } as ConversionCompareItem 104 | } 105 | }), 106 | ) 107 | })() 108 | 109 | promises.push(cToTPromise) 110 | } 111 | 112 | // T: Convert original file only 113 | promises.push( 114 | (async () => { 115 | const startTime = performance.now() 116 | 117 | try { 118 | const convertResult = await convertImage(file, convertOptions) 119 | const duration = performance.now() - startTime 120 | 121 | return { 122 | meta: { 123 | flow: 'T' as const, 124 | convertOptions, 125 | }, 126 | blob: convertResult.blob, 127 | success: true, 128 | size: convertResult.blob.size, 129 | compressionRatio: 130 | ((file.size - convertResult.blob.size) / file.size) * 100, 131 | duration, 132 | } as ConversionCompareItem 133 | } catch (error) { 134 | const duration = performance.now() - startTime 135 | 136 | return { 137 | meta: { 138 | flow: 'T' as const, 139 | convertOptions, 140 | }, 141 | success: false, 142 | error: error instanceof Error ? error.message : String(error), 143 | duration, 144 | } as ConversionCompareItem 145 | } 146 | })(), 147 | ) 148 | 149 | // T→C: Convert then compress (only if compressOptions provided) 150 | if (compressOptions) { 151 | promises.push( 152 | (async () => { 153 | const startTime = performance.now() 154 | 155 | try { 156 | // First convert 157 | const convertResult = await convertImage(file, convertOptions) 158 | 159 | // Then compress with tools suitable for target format 160 | const convertedFile = new File([convertResult.blob], file.name, { 161 | type: convertResult.mime, 162 | }) 163 | 164 | const compressResultsAfterConvert = await compress(convertedFile, { 165 | ...compressOptions, 166 | returnAllResults: true, 167 | type: 'blob' as CompressResultType, 168 | }) 169 | 170 | const bestResult = compressResultsAfterConvert.allResults 171 | .filter((result) => result.success) 172 | .reduce( 173 | (best, current) => 174 | current.compressedSize < best.compressedSize ? current : best, 175 | compressResultsAfterConvert.allResults[0], 176 | ) 177 | 178 | const totalDuration = performance.now() - startTime 179 | 180 | return { 181 | meta: { 182 | flow: 'T→C' as const, 183 | tool: bestResult.tool, 184 | compressOptions, 185 | convertOptions, 186 | }, 187 | blob: bestResult.result as Blob, 188 | success: true, 189 | size: bestResult.compressedSize, 190 | compressionRatio: 191 | ((file.size - bestResult.compressedSize) / file.size) * 100, 192 | duration: totalDuration, 193 | } as ConversionCompareItem 194 | } catch (error) { 195 | const duration = performance.now() - startTime 196 | 197 | return { 198 | meta: { 199 | flow: 'T→C' as const, 200 | convertOptions, 201 | }, 202 | success: false, 203 | error: error instanceof Error ? error.message : String(error), 204 | duration, 205 | } as ConversionCompareItem 206 | } 207 | })(), 208 | ) 209 | } 210 | 211 | // Wait for all flows to complete 212 | const results = await Promise.all(promises) 213 | 214 | // Flatten results (some might be arrays from C→T flow) 215 | for (const result of results) { 216 | if (Array.isArray(result)) { 217 | items.push(...result) 218 | } else { 219 | items.push(result) 220 | } 221 | } 222 | } catch (error) { 223 | // If overall process fails, add error item 224 | items.push({ 225 | meta: { 226 | flow: 'T' as const, 227 | convertOptions, 228 | }, 229 | success: false, 230 | error: error instanceof Error ? error.message : String(error), 231 | }) 232 | } 233 | 234 | return { 235 | title: `Format: ${targetFormat}`, 236 | items, 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /src/utils/imageQuality.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Image quality utilities: PSNR, SSIM, and difference heatmap (browser only) 3 | */ 4 | 5 | export interface QualityOptions { 6 | /** Max dimension to downscale for analysis to speed up (e.g., 512 or 1024) */ 7 | maxDimension?: number 8 | } 9 | 10 | export interface QualityResult { 11 | ssim: number 12 | psnr: number 13 | heatmap?: Blob 14 | } 15 | 16 | /** Load an image source (blob or URL) into a canvas with optional downscale */ 17 | async function loadToCanvas( 18 | src: Blob | string, 19 | maxDimension = 512, 20 | ): Promise<{ 21 | canvas: HTMLCanvasElement 22 | ctx: CanvasRenderingContext2D 23 | imageData: ImageData 24 | }> { 25 | const img = await loadImage(src) 26 | const { width, height } = downscaleDims(img.width, img.height, maxDimension) 27 | 28 | const canvas = document.createElement('canvas') 29 | canvas.width = width 30 | canvas.height = height 31 | const ctx = canvas.getContext('2d', { willReadFrequently: true })! 32 | // Use imageSmoothing for downscale quality 33 | ctx.imageSmoothingEnabled = true 34 | ctx.imageSmoothingQuality = 'high' 35 | ctx.drawImage(img as any, 0, 0, width, height) 36 | const imageData = ctx.getImageData(0, 0, width, height) 37 | // Cleanup ImageBitmap if used 38 | if ('close' in (img as any) && typeof (img as any).close === 'function') { 39 | try { 40 | ;(img as any).close() 41 | } catch {} 42 | } 43 | return { canvas, ctx, imageData } 44 | } 45 | 46 | async function loadImage( 47 | src: Blob | string, 48 | ): Promise { 49 | try { 50 | if (typeof createImageBitmap === 'function') { 51 | const blob = 52 | typeof src === 'string' ? await fetch(src).then((r) => r.blob()) : src 53 | return await createImageBitmap(blob) 54 | } 55 | } catch {} 56 | 57 | return await new Promise((resolve, reject) => { 58 | const img = new Image() 59 | img.crossOrigin = 'anonymous' 60 | img.onload = () => resolve(img) 61 | img.onerror = () => reject(new Error('Failed to load image')) 62 | if (typeof src === 'string') img.src = src 63 | else img.src = URL.createObjectURL(src) 64 | }) 65 | } 66 | 67 | function downscaleDims(w: number, h: number, maxDimension: number) { 68 | if (!maxDimension || (w <= maxDimension && h <= maxDimension)) 69 | return { width: w, height: h } 70 | const scale = Math.min(maxDimension / w, maxDimension / h) 71 | return { 72 | width: Math.max(1, Math.round(w * scale)), 73 | height: Math.max(1, Math.round(h * scale)), 74 | } 75 | } 76 | 77 | /** Convert RGBA ImageData to grayscale luma array (Float64) */ 78 | function toLumaArray(img: ImageData): Float64Array { 79 | const { data, width, height } = img 80 | const n = width * height 81 | const Y = new Float64Array(n) 82 | for (let i = 0, p = 0; i < data.length; i += 4, p++) { 83 | const r = data[i] 84 | const g = data[i + 1] 85 | const b = data[i + 2] 86 | // BT.601 luma 87 | Y[p] = 0.299 * r + 0.587 * g + 0.114 * b 88 | } 89 | return Y 90 | } 91 | 92 | /** PSNR on grayscale luma */ 93 | export function computePSNR( 94 | original: ImageData, 95 | compressed: ImageData, 96 | ): number { 97 | const w = Math.min(original.width, compressed.width) 98 | const h = Math.min(original.height, compressed.height) 99 | const a = cropImageData(original, w, h) 100 | const b = cropImageData(compressed, w, h) 101 | const A = toLumaArray(a) 102 | const B = toLumaArray(b) 103 | let mse = 0 104 | for (let i = 0; i < A.length; i++) { 105 | const d = A[i] - B[i] 106 | mse += d * d 107 | } 108 | mse /= A.length 109 | if (mse === 0) return Infinity 110 | const MAX = 255 111 | const psnr = 10 * Math.log10((MAX * MAX) / mse) 112 | return psnr 113 | } 114 | 115 | /** Simplified global SSIM (not windowed) on grayscale luma */ 116 | export function computeSSIM( 117 | original: ImageData, 118 | compressed: ImageData, 119 | ): number { 120 | const w = Math.min(original.width, compressed.width) 121 | const h = Math.min(original.height, compressed.height) 122 | const a = cropImageData(original, w, h) 123 | const b = cropImageData(compressed, w, h) 124 | const A = toLumaArray(a) 125 | const B = toLumaArray(b) 126 | 127 | const n = A.length 128 | let muA = 0, 129 | muB = 0 130 | for (let i = 0; i < n; i++) { 131 | muA += A[i] 132 | muB += B[i] 133 | } 134 | muA /= n 135 | muB /= n 136 | 137 | let sigmaA2 = 0, 138 | sigmaB2 = 0, 139 | sigmaAB = 0 140 | for (let i = 0; i < n; i++) { 141 | const da = A[i] - muA 142 | const db = B[i] - muB 143 | sigmaA2 += da * da 144 | sigmaB2 += db * db 145 | sigmaAB += da * db 146 | } 147 | sigmaA2 /= n - 1 148 | sigmaB2 /= n - 1 149 | sigmaAB /= n - 1 150 | 151 | const L = 255 152 | const k1 = 0.01 153 | const k2 = 0.03 154 | const C1 = (k1 * L) ** 2 155 | const C2 = (k2 * L) ** 2 156 | 157 | const numerator = (2 * muA * muB + C1) * (2 * sigmaAB + C2) 158 | const denominator = (muA * muA + muB * muB + C1) * (sigmaA2 + sigmaB2 + C2) 159 | let ssim = numerator / (denominator || 1) 160 | if (!Number.isFinite(ssim)) ssim = 0 161 | return Math.max(-1, Math.min(1, ssim)) 162 | } 163 | 164 | function cropImageData(img: ImageData, w: number, h: number): ImageData { 165 | if (img.width === w && img.height === h) return img 166 | const canvas = document.createElement('canvas') 167 | canvas.width = w 168 | canvas.height = h 169 | const ctx = canvas.getContext('2d', { willReadFrequently: true })! 170 | ctx.putImageData(img, 0, 0) 171 | return ctx.getImageData(0, 0, w, h) 172 | } 173 | 174 | /** Generate a difference heatmap PNG blob where hotter colors indicate larger per-pixel differences */ 175 | export async function generateDifferenceHeatmap( 176 | original: ImageData, 177 | compressed: ImageData, 178 | ): Promise { 179 | const w = Math.min(original.width, compressed.width) 180 | const h = Math.min(original.height, compressed.height) 181 | const a = cropImageData(original, w, h) 182 | const b = cropImageData(compressed, w, h) 183 | const A = toLumaArray(a) 184 | const B = toLumaArray(b) 185 | 186 | const heat = new Uint8ClampedArray(w * h * 4) 187 | let maxDiff = 1 188 | for (let i = 0; i < A.length; i++) { 189 | const d = Math.abs(A[i] - B[i]) 190 | if (d > maxDiff) maxDiff = d 191 | } 192 | maxDiff = Math.max(1, maxDiff) 193 | 194 | for (let i = 0; i < A.length; i++) { 195 | const norm = Math.abs(A[i] - B[i]) / maxDiff // 0..1 196 | const [r, g, bl] = colormapTurbo(norm) 197 | const o = i * 4 198 | heat[o] = r 199 | heat[o + 1] = g 200 | heat[o + 2] = bl 201 | heat[o + 3] = 255 202 | } 203 | 204 | const canvas = document.createElement('canvas') 205 | canvas.width = w 206 | canvas.height = h 207 | const ctx = canvas.getContext('2d')! 208 | ctx.putImageData(new ImageData(heat, w, h), 0, 0) 209 | const blob = await new Promise((resolve) => 210 | canvas.toBlob((b) => resolve(b as Blob), 'image/png'), 211 | ) 212 | return blob 213 | } 214 | 215 | // Google Turbo colormap approximation (returns [r,g,b]) 216 | function colormapTurbo(x: number): [number, number, number] { 217 | x = Math.min(1, Math.max(0, x)) 218 | const r = Math.round( 219 | 255 * 220 | (0.13572 + 221 | 4.61539 * x - 222 | 42.6609 * x ** 2 + 223 | 132.131 * x ** 3 - 224 | 152.942 * x ** 4 + 225 | 59.2866 * x ** 5), 226 | ) 227 | const g = Math.round( 228 | 255 * 229 | (0.091402 + 230 | 2.194 * x + 231 | 4.84274 * x ** 2 - 232 | 27.278 * x ** 3 + 233 | 44.3536 * x ** 4 - 234 | 22.1643 * x ** 5), 235 | ) 236 | const b = Math.round( 237 | 255 * 238 | (0.106673 + 239 | 0.628 * x + 240 | 1.44467 * x ** 2 - 241 | 6.42 * x ** 3 + 242 | 7.7133 * x ** 4 - 243 | 2.79586 * x ** 5), 244 | ) 245 | return [ 246 | Math.max(0, Math.min(255, r)), 247 | Math.max(0, Math.min(255, g)), 248 | Math.max(0, Math.min(255, b)), 249 | ] 250 | } 251 | 252 | /** High-level helper: assess SSIM/PSNR and optional heatmap from two sources */ 253 | export async function assessQuality( 254 | originalSrc: Blob | string, 255 | compressedSrc: Blob | string, 256 | options: QualityOptions = {}, 257 | ): Promise { 258 | const maxDimension = options.maxDimension ?? 512 259 | const { imageData: a } = await loadToCanvas(originalSrc, maxDimension) 260 | const { imageData: b } = await loadToCanvas(compressedSrc, maxDimension) 261 | const ssim = computeSSIM(a, b) 262 | const psnr = computePSNR(a, b) 263 | const heatmap = await generateDifferenceHeatmap(a, b) 264 | return { ssim, psnr, heatmap } 265 | } 266 | -------------------------------------------------------------------------------- /test/format-conversion.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { 3 | convertImage, 4 | detectFileFormat, 5 | isSvgContent, 6 | renderSvgToCanvas, 7 | encodeSvgToFormat, 8 | } from '../src/conversion' 9 | 10 | // Mock DOM APIs for Node.js environment 11 | global.Image = class MockImage { 12 | onload: (() => void) | null = null 13 | onerror: (() => void) | null = null 14 | private _src: string = '' 15 | width: number = 200 16 | height: number = 200 17 | naturalWidth: number = 200 18 | naturalHeight: number = 200 19 | 20 | set src(value: string) { 21 | this._src = value 22 | // Simulate immediate successful load for testing 23 | if (this.onload) { 24 | // Use a microtask to ensure the function is properly set up 25 | queueMicrotask(() => { 26 | if (this.onload) { 27 | this.onload() 28 | } 29 | }) 30 | } 31 | } 32 | 33 | get src() { 34 | return this._src 35 | } 36 | } as any 37 | 38 | global.HTMLCanvasElement = class MockCanvas { 39 | width: number = 200 40 | height: number = 200 41 | 42 | getContext(contextId: string) { 43 | if (contextId === '2d') { 44 | return { 45 | clearRect: () => {}, 46 | drawImage: () => {}, 47 | getImageData: () => ({ 48 | data: new Uint8ClampedArray(200 * 200 * 4), 49 | width: 200, 50 | height: 200, 51 | }), 52 | } 53 | } 54 | return null 55 | } 56 | 57 | toBlob( 58 | callback: (blob: Blob | null) => void, 59 | type?: string, 60 | quality?: number, 61 | ) { 62 | // Create a simple mock blob based on type 63 | let mimeType = 'image/png' 64 | if (type === 'image/jpeg') mimeType = 'image/jpeg' 65 | else if (type === 'image/webp') mimeType = 'image/webp' 66 | 67 | const mockBlob = new Blob(['mock-image-data'], { type: mimeType }) 68 | // Use microtask for immediate callback 69 | queueMicrotask(() => callback(mockBlob)) 70 | } 71 | } as any 72 | 73 | global.document = { 74 | createElement: (tagName: string) => { 75 | if (tagName === 'canvas') { 76 | return new HTMLCanvasElement() 77 | } 78 | return {} as any 79 | }, 80 | } as any 81 | 82 | global.URL = { 83 | createObjectURL: () => 'mock-object-url', 84 | revokeObjectURL: () => {}, 85 | } as any 86 | 87 | const testSvg = ` 88 | 89 | 90 | SVG Test 91 | ` 92 | 93 | describe('SVG format detection', () => { 94 | it('should detect SVG file format correctly', () => { 95 | const svgFile = new File([testSvg], 'test.svg', { type: 'image/svg+xml' }) 96 | const format = detectFileFormat(svgFile) 97 | expect(format).toBe('svg') 98 | }) 99 | 100 | it('should detect SVG by file extension when mime type is missing', () => { 101 | const svgFile = new File([testSvg], 'test.svg', { type: '' }) 102 | const format = detectFileFormat(svgFile) 103 | expect(format).toBe('svg') 104 | }) 105 | 106 | it('should detect other image formats correctly', () => { 107 | const pngFile = new File(['fake-png'], 'test.png', { type: 'image/png' }) 108 | expect(detectFileFormat(pngFile)).toBe('png') 109 | 110 | const jpegFile = new File(['fake-jpeg'], 'test.jpg', { type: 'image/jpeg' }) 111 | expect(detectFileFormat(jpegFile)).toBe('jpeg') 112 | 113 | const webpFile = new File(['fake-webp'], 'test.webp', { 114 | type: 'image/webp', 115 | }) 116 | expect(detectFileFormat(webpFile)).toBe('webp') 117 | }) 118 | }) 119 | 120 | describe('SVG content validation', () => { 121 | it('should correctly identify valid SVG content', () => { 122 | expect(isSvgContent(testSvg)).toBe(true) 123 | expect(isSvgContent('')).toBe(true) 124 | expect(isSvgContent(' content ')).toBe(true) 125 | }) 126 | 127 | it('should reject invalid SVG content', () => { 128 | expect(isSvgContent('')).toBe(false) 129 | expect(isSvgContent('not svg content')).toBe(false) 130 | expect(isSvgContent('
html content
')).toBe(false) 131 | }) 132 | 133 | it('should handle SVG content with embedded SVG tags', () => { 134 | const complexSvg = `content` 135 | expect(isSvgContent(complexSvg)).toBe(true) 136 | }) 137 | }) 138 | 139 | describe('SVG canvas rendering', () => { 140 | it('should render SVG to canvas with default dimensions', async () => { 141 | const canvas = await renderSvgToCanvas(testSvg) 142 | expect(canvas).toBeInstanceOf(HTMLCanvasElement) 143 | expect(canvas.width).toBe(200) // From mock 144 | expect(canvas.height).toBe(200) // From mock 145 | }) 146 | 147 | it('should render SVG to canvas with custom dimensions', async () => { 148 | const canvas = await renderSvgToCanvas(testSvg, 400, 300) 149 | expect(canvas).toBeInstanceOf(HTMLCanvasElement) 150 | expect(canvas.width).toBe(400) 151 | expect(canvas.height).toBe(300) 152 | }) 153 | }) 154 | 155 | describe('SVG to format encoding', () => { 156 | it('should convert SVG to PNG', async () => { 157 | const blob = await encodeSvgToFormat(testSvg, 'png') 158 | expect(blob).toBeInstanceOf(Blob) 159 | expect(blob.type).toBe('image/png') 160 | }) 161 | 162 | it('should convert SVG to JPEG with quality', async () => { 163 | const blob = await encodeSvgToFormat(testSvg, 'jpeg', { 164 | targetFormat: 'jpeg', 165 | quality: 0.9, 166 | }) 167 | expect(blob).toBeInstanceOf(Blob) 168 | expect(blob.type).toBe('image/jpeg') 169 | }) 170 | 171 | it('should convert SVG to WebP', async () => { 172 | const blob = await encodeSvgToFormat(testSvg, 'webp') 173 | expect(blob).toBeInstanceOf(Blob) 174 | expect(blob.type).toBe('image/webp') 175 | }) 176 | 177 | it('should convert SVG to ICO format', async () => { 178 | const blob = await encodeSvgToFormat(testSvg, 'ico') 179 | expect(blob).toBeInstanceOf(Blob) 180 | expect(blob.type).toBe('image/x-icon') 181 | }) 182 | 183 | it('should handle custom dimensions in conversion', async () => { 184 | const blob = await encodeSvgToFormat(testSvg, 'png', { 185 | targetFormat: 'png', 186 | width: 500, 187 | height: 400, 188 | }) 189 | expect(blob).toBeInstanceOf(Blob) 190 | expect(blob.type).toBe('image/png') 191 | }) 192 | }) 193 | 194 | describe('Full SVG conversion workflow', () => { 195 | it('should convert SVG file to PNG through convertImage function', async () => { 196 | const svgFile = new File([testSvg], 'test.svg', { type: 'image/svg+xml' }) 197 | 198 | const result = await convertImage(svgFile, { targetFormat: 'png' }) 199 | 200 | expect(result.blob).toBeInstanceOf(Blob) 201 | expect(result.mime).toBe('image/png') 202 | expect(result.duration).toBeGreaterThan(0) 203 | }) 204 | 205 | it('should convert SVG file to JPEG with quality settings', async () => { 206 | const svgFile = new File([testSvg], 'test.svg', { type: 'image/svg+xml' }) 207 | 208 | const result = await convertImage(svgFile, { 209 | targetFormat: 'jpeg', 210 | quality: 0.8, 211 | }) 212 | 213 | expect(result.blob).toBeInstanceOf(Blob) 214 | expect(result.mime).toBe('image/jpeg') 215 | expect(result.duration).toBeGreaterThan(0) 216 | }) 217 | 218 | it('should convert SVG file to WebP', async () => { 219 | const svgFile = new File([testSvg], 'test.svg', { type: 'image/svg+xml' }) 220 | 221 | const result = await convertImage(svgFile, { targetFormat: 'webp' }) 222 | 223 | expect(result.blob).toBeInstanceOf(Blob) 224 | expect(result.mime).toBe('image/webp') 225 | expect(result.duration).toBeGreaterThan(0) 226 | }) 227 | 228 | it('should handle SVG with custom render dimensions', async () => { 229 | const svgFile = new File([testSvg], 'test.svg', { type: 'image/svg+xml' }) 230 | 231 | const result = await convertImage(svgFile, { 232 | targetFormat: 'png', 233 | width: 800, 234 | height: 600, 235 | }) 236 | 237 | expect(result.blob).toBeInstanceOf(Blob) 238 | expect(result.mime).toBe('image/png') 239 | expect(result.duration).toBeGreaterThan(0) 240 | }) 241 | }) 242 | 243 | describe('Error handling', () => { 244 | it('should handle invalid SVG content gracefully', async () => { 245 | const invalidSvgFile = new File(['not svg content'], 'test.svg', { 246 | type: 'image/svg+xml', 247 | }) 248 | 249 | await expect( 250 | convertImage(invalidSvgFile, { targetFormat: 'png' }), 251 | ).rejects.toThrow(/SVG conversion failed/) 252 | }) 253 | 254 | it('should handle unsupported target formats', async () => { 255 | await expect(encodeSvgToFormat(testSvg, 'gif' as any)).rejects.toThrow( 256 | /Unsupported target format/, 257 | ) 258 | }) 259 | }) 260 | 261 | // Legacy placeholder test to maintain compatibility 262 | describe('format conversion placeholder', () => { 263 | it('placeholder test - keeps test runner happy', () => { 264 | expect(true).toBe(true) 265 | }) 266 | }) 267 | -------------------------------------------------------------------------------- /test/features.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from 'vitest' 2 | import { compress } from '../src' 3 | import type { MultipleCompressResults } from '../src/types' 4 | 5 | // 创建测试图片文件 6 | function createTestImageFile( 7 | type: string = 'image/jpeg', 8 | size: number = 1024, 9 | ): File { 10 | // 创建一个简单的测试图片数据 11 | const canvas = document.createElement('canvas') 12 | canvas.width = 100 13 | canvas.height = 100 14 | const ctx = canvas.getContext('2d')! 15 | 16 | // 绘制一个简单的图案 17 | ctx.fillStyle = '#ff0000' 18 | ctx.fillRect(0, 0, 50, 50) 19 | ctx.fillStyle = '#00ff00' 20 | ctx.fillRect(50, 0, 50, 50) 21 | ctx.fillStyle = '#0000ff' 22 | ctx.fillRect(0, 50, 50, 50) 23 | ctx.fillStyle = '#ffff00' 24 | ctx.fillRect(50, 50, 50, 50) 25 | 26 | // 转换为 Blob 27 | return new Promise((resolve) => { 28 | canvas.toBlob( 29 | (blob) => { 30 | const file = new File([blob!], 'test-image.jpg', { type }) 31 | resolve(file) 32 | }, 33 | type, 34 | 0.9, 35 | ) 36 | }) as any as File 37 | } 38 | 39 | describe('新功能验证测试', () => { 40 | let testFile: File 41 | let pngFile: File 42 | let gifFile: File 43 | 44 | beforeAll(async () => { 45 | // 在浏览器环境中模拟 canvas 46 | if (typeof document === 'undefined') { 47 | // Node.js 环境,创建模拟文件 48 | const buffer = new ArrayBuffer(1024) 49 | testFile = new File([buffer], 'test.jpg', { type: 'image/jpeg' }) 50 | pngFile = new File([buffer], 'test.png', { type: 'image/png' }) 51 | gifFile = new File([buffer], 'test.gif', { type: 'image/gif' }) 52 | } else { 53 | // 浏览器环境,创建真实测试文件 54 | testFile = createTestImageFile('image/jpeg') 55 | pngFile = createTestImageFile('image/png') 56 | gifFile = createTestImageFile('image/gif') 57 | } 58 | }) 59 | 60 | describe('returnAllResults 功能测试', () => { 61 | it('应该返回所有工具的压缩结果', async () => { 62 | const result = (await compress(testFile, { 63 | quality: 0.8, 64 | returnAllResults: true, 65 | type: 'blob', 66 | })) as MultipleCompressResults<'blob'> 67 | 68 | // 验证返回结构 69 | expect(result).toHaveProperty('bestResult') 70 | expect(result).toHaveProperty('bestTool') 71 | expect(result).toHaveProperty('allResults') 72 | expect(result).toHaveProperty('totalDuration') 73 | 74 | // 验证 bestResult 是 Blob 75 | expect(result.bestResult).toBeInstanceOf(Blob) 76 | 77 | // 验证 bestTool 是字符串 78 | expect(typeof result.bestTool).toBe('string') 79 | 80 | // 验证 allResults 是数组且包含正确的结构 81 | expect(Array.isArray(result.allResults)).toBe(true) 82 | expect(result.allResults.length).toBeGreaterThan(0) 83 | 84 | // 验证每个结果项的结构 85 | result.allResults.forEach((item) => { 86 | expect(item).toHaveProperty('tool') 87 | expect(item).toHaveProperty('result') 88 | expect(item).toHaveProperty('originalSize') 89 | expect(item).toHaveProperty('compressedSize') 90 | expect(item).toHaveProperty('compressionRatio') 91 | expect(item).toHaveProperty('duration') 92 | expect(item).toHaveProperty('success') 93 | 94 | expect(typeof item.tool).toBe('string') 95 | expect(item.result).toBeInstanceOf(Blob) 96 | expect(typeof item.originalSize).toBe('number') 97 | expect(typeof item.compressedSize).toBe('number') 98 | expect(typeof item.compressionRatio).toBe('number') 99 | expect(typeof item.duration).toBe('number') 100 | expect(typeof item.success).toBe('boolean') 101 | }) 102 | 103 | // 验证至少有一个成功的结果 104 | const successfulResults = result.allResults.filter((item) => item.success) 105 | 106 | // 调试信息 107 | console.log('🔍 详细调试信息:') 108 | result.allResults.forEach((item) => { 109 | console.log( 110 | ` ${item.tool}: success=${item.success}, error=${item.error || 'none'}`, 111 | ) 112 | }) 113 | 114 | // 在 Node.js 环境中,压缩工具可能会失败,但应该有原始文件作为后备 115 | // 如果所有压缩工具都失败,最优工具应该是 'original' 116 | if (successfulResults.length === 0) { 117 | expect(result.bestTool).toBe('original') 118 | console.log( 119 | 'ℹ️ 所有压缩工具在 Node.js 环境中失败,使用原始文件作为后备', 120 | ) 121 | } else { 122 | expect(successfulResults.length).toBeGreaterThan(0) 123 | } 124 | 125 | console.log('🎯 returnAllResults 测试结果:') 126 | console.log(`最优工具: ${result.bestTool}`) 127 | console.log(`总耗时: ${result.totalDuration}ms`) 128 | console.log('所有结果:') 129 | result.allResults.forEach((item) => { 130 | console.log( 131 | ` ${item.tool}: ${item.compressedSize} bytes (${item.compressionRatio.toFixed(1)}% reduction, ${item.duration}ms) ${item.success ? '✅' : '❌'}`, 132 | ) 133 | }) 134 | }) 135 | 136 | it('应该正确处理不同的输出类型', async () => { 137 | const fileResult = (await compress(testFile, { 138 | quality: 0.8, 139 | returnAllResults: true, 140 | type: 'file', 141 | })) as MultipleCompressResults<'file'> 142 | 143 | expect(fileResult.bestResult).toBeInstanceOf(File) 144 | fileResult.allResults.forEach((item) => { 145 | expect(item.result).toBeInstanceOf(File) 146 | }) 147 | 148 | console.log('✅ 文件类型输出验证通过') 149 | }) 150 | }) 151 | 152 | describe('preserveExif 功能测试', () => { 153 | it('应该在 preserveExif=true 时过滤工具', async () => { 154 | const result = (await compress(testFile, { 155 | quality: 0.8, 156 | preserveExif: true, 157 | returnAllResults: true, 158 | type: 'blob', 159 | })) as MultipleCompressResults<'blob'> 160 | 161 | // 验证只使用支持 EXIF 的工具 162 | const supportedTools = [ 163 | 'browser-image-compression', 164 | 'compressorjs', 165 | 'original', 166 | ] 167 | result.allResults.forEach((item) => { 168 | expect(supportedTools).toContain(item.tool) 169 | }) 170 | 171 | // 验证不包含不支持 EXIF 的工具 172 | const unsupportedTools = ['canvas', 'gifsicle'] 173 | result.allResults.forEach((item) => { 174 | expect(unsupportedTools).not.toContain(item.tool) 175 | }) 176 | 177 | console.log('🔒 EXIF 工具过滤验证:') 178 | console.log( 179 | `使用的工具: ${result.allResults.map((item) => item.tool).join(', ')}`, 180 | ) 181 | }) 182 | 183 | it('应该在 GIF 文件 + preserveExif=true 时抛出错误', async () => { 184 | await expect( 185 | compress(gifFile, { 186 | quality: 0.8, 187 | preserveExif: true, 188 | type: 'blob', 189 | }), 190 | ).rejects.toThrow('No EXIF-supporting tools available') 191 | 192 | console.log('🚫 GIF + preserveExif 错误处理验证通过') 193 | }) 194 | 195 | it('应该在 PNG 文件 + preserveExif=true 时正常工作', async () => { 196 | const result = (await compress(pngFile, { 197 | quality: 0.8, 198 | preserveExif: true, 199 | returnAllResults: true, 200 | type: 'blob', 201 | })) as MultipleCompressResults<'blob'> 202 | 203 | // PNG 文件应该只使用 browser-image-compression(canvas 被过滤掉) 204 | const usedTools = result.allResults.map((item) => item.tool) 205 | expect(usedTools).toContain('browser-image-compression') 206 | expect(usedTools).not.toContain('canvas') 207 | 208 | console.log('🖼️ PNG + preserveExif 验证:') 209 | console.log(`使用的工具: ${usedTools.join(', ')}`) 210 | }) 211 | }) 212 | 213 | describe('兼容性和回退测试', () => { 214 | it('应该向后兼容旧的 API', async () => { 215 | // 测试旧的 API 格式仍然有效 216 | const result = await compress(testFile, 0.8, 'blob') 217 | expect(result).toBeInstanceOf(Blob) 218 | 219 | console.log('🔄 向后兼容性验证通过') 220 | }) 221 | 222 | it('应该在所有工具失败时使用原文件', async () => { 223 | // 创建一个会导致压缩失败的场景(极低质量) 224 | const result = (await compress(testFile, { 225 | quality: 0.01, // 极低质量可能导致某些工具失败 226 | returnAllResults: true, 227 | type: 'blob', 228 | })) as MultipleCompressResults<'blob'> 229 | 230 | // 即使有失败,也应该有结果 231 | expect(result.allResults.length).toBeGreaterThan(0) 232 | expect(result.bestResult).toBeInstanceOf(Blob) 233 | 234 | console.log('🛡️ 容错处理验证:') 235 | console.log(`结果数量: ${result.allResults.length}`) 236 | console.log(`最优工具: ${result.bestTool}`) 237 | }) 238 | }) 239 | 240 | describe('性能和统计测试', () => { 241 | it('应该正确计算压缩比例和统计信息', async () => { 242 | const result = (await compress(testFile, { 243 | quality: 0.7, 244 | returnAllResults: true, 245 | type: 'blob', 246 | })) as MultipleCompressResults<'blob'> 247 | 248 | result.allResults.forEach((item) => { 249 | // 验证压缩比例计算 250 | const expectedRatio = 251 | ((item.originalSize - item.compressedSize) / item.originalSize) * 100 252 | expect(Math.abs(item.compressionRatio - expectedRatio)).toBeLessThan( 253 | 0.01, 254 | ) 255 | 256 | // 验证大小合理性 257 | expect(item.originalSize).toBeGreaterThan(0) 258 | expect(item.compressedSize).toBeGreaterThan(0) 259 | expect(item.duration).toBeGreaterThanOrEqual(0) 260 | }) 261 | 262 | console.log('📊 统计信息验证通过') 263 | }) 264 | 265 | it('应该正确选择最优结果', async () => { 266 | const result = (await compress(testFile, { 267 | quality: 0.7, 268 | returnAllResults: true, 269 | type: 'blob', 270 | })) as MultipleCompressResults<'blob'> 271 | 272 | // 验证最优结果是文件大小最小的成功结果 273 | const successfulResults = result.allResults.filter((item) => item.success) 274 | if (successfulResults.length > 1) { 275 | const minSize = Math.min( 276 | ...successfulResults.map((item) => item.compressedSize), 277 | ) 278 | const bestItem = successfulResults.find( 279 | (item) => item.compressedSize === minSize, 280 | ) 281 | expect(result.bestTool).toBe(bestItem?.tool) 282 | } 283 | 284 | console.log('🏆 最优结果选择验证通过') 285 | }) 286 | }) 287 | }) 288 | -------------------------------------------------------------------------------- /src/compressEnhanced.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CompressOptions, 3 | CompressResult, 4 | CompressResultType, 5 | } from './types' 6 | import convertBlobToType from './convertBlobToType' 7 | import { compressionQueue } from './utils/compressionQueue' 8 | import { compressionWorkerManager } from './utils/compressionWorker' 9 | import { preprocessImage } from './utils/preprocessImage' 10 | import type { PreprocessOptions } from './types' 11 | import logger from './utils/logger' 12 | 13 | // Enhanced compression options with queue and worker support 14 | export interface EnhancedCompressOptions extends CompressOptions { 15 | /** 16 | * Whether to use worker for compression (when available) 17 | * @default true 18 | */ 19 | useWorker?: boolean 20 | 21 | /** 22 | * Priority for queue processing (higher = processed first) 23 | * @default calculated based on file size 24 | */ 25 | priority?: number 26 | 27 | /** 28 | * Whether to use the compression queue for concurrency control 29 | * @default true 30 | */ 31 | useQueue?: boolean 32 | 33 | /** 34 | * Maximum time to wait for compression (in ms) 35 | * @default 30000 (30 seconds) 36 | */ 37 | timeout?: number 38 | 39 | /** 40 | * Optional preprocessing before compression (crop/rotate/flip/resize) 41 | */ 42 | preprocess?: PreprocessOptions 43 | } 44 | 45 | /** 46 | * Enhanced compression function with queue management and worker support 47 | * This is the new recommended way to compress images for better performance 48 | */ 49 | export async function compressEnhanced( 50 | file: File, 51 | options: EnhancedCompressOptions = {}, 52 | ): Promise> { 53 | const { 54 | useWorker = true, 55 | useQueue = true, 56 | priority, 57 | timeout = 30000, 58 | type = 'blob' as T, 59 | preprocess, 60 | ...compressOptions 61 | } = options 62 | 63 | // Input validation 64 | if (!file || !(file instanceof File)) { 65 | throw new Error('Invalid file input') 66 | } 67 | 68 | // If preprocessing is requested, apply on main thread first to get an interim Blob 69 | let inputForCompression: File | Blob = file 70 | if (preprocess) { 71 | try { 72 | // Map arbitrary file.type to supported union for preprocess output 73 | let guessedOutType: 'image/png' | 'image/jpeg' | 'image/webp' = 74 | 'image/png' 75 | if (preprocess.outputType) { 76 | guessedOutType = preprocess.outputType 77 | } else if (/jpe?g/i.test(file.type)) { 78 | guessedOutType = 'image/jpeg' 79 | } else if (/png/i.test(file.type)) { 80 | guessedOutType = 'image/png' 81 | } else if (/webp/i.test(file.type)) { 82 | guessedOutType = 'image/webp' 83 | } 84 | 85 | const pre = await preprocessImage(file, { 86 | ...preprocess, 87 | // Prefer to keep the source mime within supported set; downstream tools can convert if needed 88 | outputType: guessedOutType, 89 | }) 90 | inputForCompression = pre.blob 91 | } catch (e) { 92 | logger.warn('Preprocess failed, fallback to original file:', e) 93 | } 94 | } 95 | 96 | // For single file compression, use direct compression if queue is disabled 97 | if (!useQueue) { 98 | return (await compressDirectly( 99 | // if preprocessed, wrap as File to retain name when possible 100 | inputForCompression instanceof File 101 | ? inputForCompression 102 | : new File([inputForCompression], file.name, { 103 | type: (inputForCompression as Blob).type, 104 | }), 105 | compressOptions, 106 | useWorker, 107 | type, 108 | )) as CompressResult 109 | } 110 | 111 | // Use queue for concurrency control 112 | const compressPromise = compressionQueue.compress( 113 | inputForCompression instanceof File 114 | ? inputForCompression 115 | : new File([inputForCompression], file.name, { 116 | type: (inputForCompression as Blob).type, 117 | }), 118 | { 119 | ...compressOptions, 120 | useWorker, 121 | type: 'blob', // Always get blob from queue, convert later if needed 122 | }, 123 | priority, 124 | ) 125 | 126 | // Add timeout wrapper 127 | const timeoutPromise = new Promise((_, reject) => { 128 | setTimeout(() => { 129 | reject(new Error(`Compression timeout after ${timeout}ms`)) 130 | }, timeout) 131 | }) 132 | 133 | try { 134 | const blob = await Promise.race([compressPromise, timeoutPromise]) 135 | return (await convertBlobToType(blob, type)) as CompressResult 136 | } catch (error) { 137 | throw error instanceof Error ? error : new Error('Compression failed') 138 | } 139 | } 140 | 141 | /** 142 | * Direct compression without queue (for internal use and non-queued operations) 143 | */ 144 | async function compressDirectly( 145 | file: File, 146 | options: CompressOptions, 147 | useWorker: boolean, 148 | type: T, 149 | ): Promise> { 150 | let compressedBlob: Blob 151 | 152 | // Determine if we should use worker 153 | const shouldUseWorker = 154 | useWorker && 155 | compressionWorkerManager.isSupported() && 156 | canUseWorkerForFile(file, options) 157 | 158 | if (shouldUseWorker) { 159 | try { 160 | // Try worker compression first 161 | compressedBlob = await compressionWorkerManager.compressInWorker( 162 | file, 163 | options, 164 | ) 165 | logger.log('Used worker compression for', file.name) 166 | } catch (error) { 167 | logger.warn( 168 | 'Worker compression failed, falling back to main thread:', 169 | error, 170 | ) 171 | // Fallback to main thread compression 172 | compressedBlob = await compressInMainThread(file, options) 173 | } 174 | } else { 175 | // Use main thread compression directly 176 | compressedBlob = await compressInMainThread(file, options) 177 | } 178 | 179 | return (await convertBlobToType(compressedBlob, type)) as CompressResult 180 | } 181 | 182 | /** 183 | * Main thread compression using the original compress function 184 | */ 185 | async function compressInMainThread( 186 | file: File, 187 | options: CompressOptions, 188 | ): Promise { 189 | const { compress } = await import('./compress') 190 | const result = await compress(file, { 191 | ...options, 192 | type: 'blob', 193 | returnAllResults: false, // Ensure we get a direct blob result 194 | }) 195 | 196 | // Result should be Blob when returnAllResults is false 197 | return result as Blob 198 | } 199 | 200 | /** 201 | * Check if worker can be used for this file and options 202 | */ 203 | function canUseWorkerForFile(file: File, options: CompressOptions): boolean { 204 | // Worker limitations: 205 | // 1. Some tools require DOM APIs (canvas, jsquash) 206 | // 2. Large files might hit worker transfer limits 207 | // 3. EXIF preservation might require specific tools 208 | 209 | // Check file size (avoid transferring very large files to worker) 210 | const maxWorkerFileSize = 50 * 1024 * 1024 // 50MB 211 | if (file.size > maxWorkerFileSize) { 212 | logger.log('File too large for worker, using main thread') 213 | return false 214 | } 215 | 216 | // Check if EXIF preservation is required (might limit tool choice) 217 | if (options.preserveExif) { 218 | // EXIF preservation might require specific tools that work better in main thread 219 | logger.log( 220 | 'EXIF preservation required, may use main thread for better compatibility', 221 | ) 222 | return true // Still try worker, but tools will be filtered appropriately 223 | } 224 | 225 | return true 226 | } 227 | 228 | /** 229 | * Batch compression with enhanced queue management 230 | */ 231 | export async function compressEnhancedBatch( 232 | files: File[], 233 | options: EnhancedCompressOptions = {}, 234 | ): Promise[]> { 235 | if (!files || files.length === 0) { 236 | return [] 237 | } 238 | 239 | // Create compression promises for all files 240 | const compressionPromises = files.map((file, index) => { 241 | // Calculate priority: smaller files and earlier in list get higher priority 242 | const sizePriority = Math.max( 243 | 1, 244 | 100 - Math.floor(file.size / (1024 * 1024)), 245 | ) 246 | const indexPriority = Math.max(1, files.length - index) 247 | const calculatedPriority = 248 | options.priority || Math.floor((sizePriority + indexPriority) / 2) 249 | 250 | return compressEnhanced(file, { 251 | ...options, 252 | priority: calculatedPriority, 253 | type: 'blob', 254 | }) 255 | }) 256 | 257 | // Wait for all compressions to complete 258 | const results = await Promise.allSettled(compressionPromises) 259 | 260 | // Process results and handle failures 261 | return results.map((result, index) => { 262 | if (result.status === 'fulfilled') { 263 | return result.value 264 | } else { 265 | logger.error( 266 | `Compression failed for file ${files[index].name}:`, 267 | result.reason, 268 | ) 269 | throw result.reason 270 | } 271 | }) 272 | } 273 | 274 | /** 275 | * Wait for compression system initialization to complete 276 | */ 277 | export async function waitForCompressionInitialization(): Promise { 278 | await compressionWorkerManager.waitForInitialization() 279 | } 280 | 281 | /** 282 | * Get compression queue statistics 283 | */ 284 | export function getCompressionStats() { 285 | return { 286 | queue: compressionQueue.getStats(), 287 | worker: { 288 | supported: compressionWorkerManager.isSupported(), 289 | domDependentTools: compressionWorkerManager.getDOMDependentTools(), 290 | }, 291 | } 292 | } 293 | 294 | /** 295 | * Configure compression system 296 | */ 297 | export function configureCompression(config: { maxConcurrency?: number }) { 298 | if (config.maxConcurrency !== undefined) { 299 | compressionQueue.setMaxConcurrency(config.maxConcurrency) 300 | } 301 | } 302 | 303 | /** 304 | * Clear compression queue (cancel pending tasks) 305 | */ 306 | export function clearCompressionQueue() { 307 | compressionQueue.clearQueue() 308 | } 309 | 310 | // Export the enhanced compress function as the main export 311 | export default compressEnhanced 312 | -------------------------------------------------------------------------------- /test/new-features.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest' 2 | import { compress } from '../src' 3 | import type { MultipleCompressResults } from '../src/types' 4 | 5 | // 创建一个模拟的图片文件用于测试 6 | function createMockImageFile( 7 | type: string = 'image/jpeg', 8 | size: number = 1024, 9 | ): File { 10 | // 创建一个简单的测试数据,模拟图片文件 11 | const buffer = new ArrayBuffer(size) 12 | const view = new Uint8Array(buffer) 13 | 14 | // 填充一些模拟的图片数据 15 | for (let i = 0; i < size; i++) { 16 | view[i] = Math.floor(Math.random() * 256) 17 | } 18 | 19 | return new File([buffer], `test.${type.split('/')[1]}`, { type }) 20 | } 21 | 22 | describe('新功能验证测试', () => { 23 | const testFile = createMockImageFile('image/jpeg', 2048) 24 | const pngFile = createMockImageFile('image/png', 1536) 25 | const gifFile = createMockImageFile('image/gif', 1024) 26 | const webpFile = createMockImageFile('image/webp', 1792) 27 | 28 | describe('returnAllResults 功能测试', () => { 29 | it('应该返回多结果对象结构', async () => { 30 | const result = await compress(testFile, { 31 | quality: 0.8, 32 | returnAllResults: true, 33 | type: 'blob', 34 | }) 35 | 36 | // 验证是 MultipleCompressResults 类型 37 | expect(result).toHaveProperty('bestResult') 38 | expect(result).toHaveProperty('bestTool') 39 | expect(result).toHaveProperty('allResults') 40 | expect(result).toHaveProperty('totalDuration') 41 | 42 | const multiResult = result as MultipleCompressResults<'blob'> 43 | 44 | // 验证基本结构 45 | expect(multiResult.bestResult).toBeInstanceOf(Blob) 46 | expect(typeof multiResult.bestTool).toBe('string') 47 | expect(Array.isArray(multiResult.allResults)).toBe(true) 48 | expect(typeof multiResult.totalDuration).toBe('number') 49 | 50 | console.log('✅ 多结果结构验证通过') 51 | console.log(`最优工具: ${multiResult.bestTool}`) 52 | console.log(`结果数量: ${multiResult.allResults.length}`) 53 | }) 54 | 55 | it('应该返回单一结果当 returnAllResults=false 时', async () => { 56 | const result = await compress(testFile, { 57 | quality: 0.8, 58 | returnAllResults: false, 59 | type: 'blob', 60 | }) 61 | 62 | // 验证是单个 Blob 63 | expect(result).toBeInstanceOf(Blob) 64 | expect(result).not.toHaveProperty('allResults') 65 | 66 | console.log('✅ 单结果模式验证通过') 67 | }) 68 | 69 | it('应该支持不同的输出类型', async () => { 70 | // 测试 file 类型 71 | const fileResult = (await compress(testFile, { 72 | quality: 0.8, 73 | returnAllResults: true, 74 | type: 'file', 75 | })) as MultipleCompressResults<'file'> 76 | 77 | expect(fileResult.bestResult).toBeInstanceOf(File) 78 | expect( 79 | fileResult.allResults.every((item) => item.result instanceof File), 80 | ).toBe(true) 81 | 82 | console.log('✅ 文件类型输出验证通过') 83 | }) 84 | }) 85 | 86 | describe('preserveExif 功能测试', () => { 87 | it('应该抛出错误当 GIF + preserveExif=true', async () => { 88 | await expect( 89 | compress(gifFile, { 90 | quality: 0.8, 91 | preserveExif: true, 92 | type: 'blob', 93 | }), 94 | ).rejects.toThrow('No EXIF-supporting tools available') 95 | 96 | console.log('✅ GIF + preserveExif 错误处理验证通过') 97 | }) 98 | 99 | it('应该成功处理 JPEG + preserveExif=true', async () => { 100 | const result = (await compress(testFile, { 101 | quality: 0.8, 102 | preserveExif: true, 103 | returnAllResults: true, 104 | type: 'blob', 105 | })) as MultipleCompressResults<'blob'> 106 | 107 | // 验证只使用支持 EXIF 的工具 108 | const supportedTools = [ 109 | 'browser-image-compression', 110 | 'compressorjs', 111 | 'original', 112 | ] 113 | result.allResults.forEach((item) => { 114 | expect(supportedTools).toContain(item.tool) 115 | }) 116 | 117 | console.log('✅ JPEG + preserveExif 验证通过') 118 | console.log( 119 | `使用的工具: ${result.allResults.map((item) => item.tool).join(', ')}`, 120 | ) 121 | }) 122 | 123 | it('应该成功处理 PNG + preserveExif=true', async () => { 124 | const result = (await compress(pngFile, { 125 | quality: 0.8, 126 | preserveExif: true, 127 | returnAllResults: true, 128 | type: 'blob', 129 | })) as MultipleCompressResults<'blob'> 130 | 131 | // PNG 应该过滤掉 canvas 工具 132 | const usedTools = result.allResults.map((item) => item.tool) 133 | expect(usedTools).not.toContain('canvas') 134 | 135 | console.log('✅ PNG + preserveExif 验证通过') 136 | console.log(`使用的工具: ${usedTools.join(', ')}`) 137 | }) 138 | 139 | it('应该成功处理 WebP + preserveExif=true', async () => { 140 | const result = (await compress(webpFile, { 141 | quality: 0.8, 142 | preserveExif: true, 143 | returnAllResults: true, 144 | type: 'blob', 145 | })) as MultipleCompressResults<'blob'> 146 | 147 | // WebP 应该过滤掉 canvas,只保留 browser-image-compression 148 | const usedTools = result.allResults.map((item) => item.tool) 149 | expect(usedTools).toContain('browser-image-compression') 150 | expect(usedTools).not.toContain('canvas') 151 | 152 | console.log('✅ WebP + preserveExif 验证通过') 153 | console.log(`使用的工具: ${usedTools.join(', ')}`) 154 | }) 155 | }) 156 | 157 | describe('类型安全和重载测试', () => { 158 | it('应该正确推断返回类型', async () => { 159 | // 测试类型推断 160 | const singleResult = await compress(testFile, { 161 | quality: 0.8, 162 | type: 'blob', 163 | }) 164 | const multiResult = await compress(testFile, { 165 | quality: 0.8, 166 | returnAllResults: true, 167 | type: 'blob', 168 | }) 169 | const legacyResult = await compress(testFile, 0.8, 'blob') 170 | 171 | expect(singleResult).toBeInstanceOf(Blob) 172 | expect(multiResult).toHaveProperty('allResults') 173 | expect(legacyResult).toBeInstanceOf(Blob) 174 | 175 | console.log('✅ 类型推断验证通过') 176 | }) 177 | 178 | it('应该向后兼容旧的 API', async () => { 179 | // 测试旧 API 仍然工作 180 | const result1 = await compress(testFile, 0.8) 181 | const result2 = await compress(testFile, 0.7, 'blob') 182 | const result3 = await compress(testFile, 0.6, 'file') 183 | 184 | expect(result1).toBeInstanceOf(Blob) 185 | expect(result2).toBeInstanceOf(Blob) 186 | expect(result3).toBeInstanceOf(File) 187 | 188 | console.log('✅ 向后兼容性验证通过') 189 | }) 190 | }) 191 | 192 | describe('边界情况测试', () => { 193 | it('应该处理极高质量设置', async () => { 194 | const result = (await compress(testFile, { 195 | quality: 0.99, 196 | returnAllResults: true, 197 | type: 'blob', 198 | })) as MultipleCompressResults<'blob'> 199 | 200 | expect(result.bestResult).toBeInstanceOf(Blob) 201 | expect(result.allResults.length).toBeGreaterThan(0) 202 | 203 | console.log('✅ 极高质量设置验证通过') 204 | console.log(`最优工具: ${result.bestTool}`) 205 | }) 206 | 207 | it('应该处理极低质量设置', async () => { 208 | const result = (await compress(testFile, { 209 | quality: 0.01, 210 | returnAllResults: true, 211 | type: 'blob', 212 | })) as MultipleCompressResults<'blob'> 213 | 214 | expect(result.bestResult).toBeInstanceOf(Blob) 215 | expect(result.allResults.length).toBeGreaterThan(0) 216 | 217 | console.log('✅ 极低质量设置验证通过') 218 | console.log(`最优工具: ${result.bestTool}`) 219 | }) 220 | 221 | it('应该正确处理各种文件类型的工具选择', async () => { 222 | const fileTypes = [ 223 | { 224 | file: testFile, 225 | type: 'JPEG', 226 | expectedTools: [ 227 | 'jsquash', 228 | 'browser-image-compression', 229 | 'compressorjs', 230 | 'canvas', 231 | ], 232 | }, 233 | { 234 | file: pngFile, 235 | type: 'PNG', 236 | expectedTools: ['jsquash', 'browser-image-compression', 'canvas'], 237 | }, 238 | { 239 | file: webpFile, 240 | type: 'WebP', 241 | expectedTools: ['jsquash', 'canvas', 'browser-image-compression'], 242 | }, 243 | ] 244 | 245 | for (const { file, type, expectedTools } of fileTypes) { 246 | const result = (await compress(file, { 247 | quality: 0.8, 248 | returnAllResults: true, 249 | type: 'blob', 250 | })) as MultipleCompressResults<'blob'> 251 | 252 | const usedTools = result.allResults 253 | .map((item) => item.tool) 254 | .filter((tool) => tool !== 'original') 255 | 256 | // 验证使用的工具在预期范围内 257 | usedTools.forEach((tool) => { 258 | expect(expectedTools).toContain(tool) 259 | }) 260 | 261 | console.log(`✅ ${type} 文件工具选择验证通过: ${usedTools.join(', ')}`) 262 | } 263 | }) 264 | }) 265 | 266 | describe('数据完整性测试', () => { 267 | it('应该正确计算统计信息', async () => { 268 | const result = (await compress(testFile, { 269 | quality: 0.7, 270 | returnAllResults: true, 271 | type: 'blob', 272 | })) as MultipleCompressResults<'blob'> 273 | 274 | result.allResults.forEach((item) => { 275 | // 验证数据一致性 276 | expect(item.originalSize).toBe(testFile.size) 277 | expect(item.compressedSize).toBeGreaterThan(0) 278 | expect(typeof item.compressionRatio).toBe('number') 279 | expect(item.duration).toBeGreaterThanOrEqual(0) 280 | expect(typeof item.success).toBe('boolean') 281 | 282 | // 验证压缩比计算正确性 283 | const expectedRatio = 284 | ((item.originalSize - item.compressedSize) / item.originalSize) * 100 285 | expect(Math.abs(item.compressionRatio - expectedRatio)).toBeLessThan( 286 | 0.01, 287 | ) 288 | }) 289 | 290 | console.log('✅ 统计信息计算验证通过') 291 | }) 292 | 293 | it('应该保持文件名和类型信息', async () => { 294 | const result = (await compress(testFile, { 295 | quality: 0.8, 296 | returnAllResults: true, 297 | type: 'file', 298 | })) as MultipleCompressResults<'file'> 299 | 300 | result.allResults.forEach((item) => { 301 | if (item.success && item.result instanceof File) { 302 | expect(item.result.name).toBeTruthy() 303 | expect(item.result.type).toBeTruthy() 304 | } 305 | }) 306 | 307 | console.log('✅ 文件信息保持验证通过') 308 | }) 309 | }) 310 | }) 311 | -------------------------------------------------------------------------------- /src/utils/compressionWorker.ts: -------------------------------------------------------------------------------- 1 | // Compression worker system for offloading heavy computation 2 | export interface WorkerMessage { 3 | id: string 4 | type: 'compress' | 'result' | 'error' 5 | data?: any 6 | } 7 | 8 | export interface WorkerTask { 9 | id: string 10 | file: File 11 | options: any 12 | resolve: (result: Blob) => void 13 | reject: (error: Error) => void 14 | } 15 | 16 | // Worker-compatible tools (don't use DOM APIs) 17 | const WORKER_COMPATIBLE_TOOLS = [ 18 | 'browser-image-compression', 19 | 'compressorjs', 20 | 'gifsicle', 21 | 'tinypng', 22 | // Note: 'canvas' and 'jsquash' use DOM APIs and aren't worker-compatible 23 | ] 24 | 25 | // DOM-dependent tools that need main thread 26 | const DOM_DEPENDENT_TOOLS = ['canvas', 'jsquash'] 27 | 28 | export class CompressionWorkerManager { 29 | private static instance: CompressionWorkerManager 30 | private workers: Worker[] = [] 31 | private workerTasks: Map = new Map() 32 | private workerPool: Worker[] = [] 33 | private isWorkerSupported: boolean = false 34 | private workerScript: string | null = null 35 | private initPromise: Promise | null = null 36 | 37 | private constructor() { 38 | this.initPromise = this.initializeWorkerSupport() 39 | } 40 | 41 | static getInstance(): CompressionWorkerManager { 42 | if (!CompressionWorkerManager.instance) { 43 | CompressionWorkerManager.instance = new CompressionWorkerManager() 44 | } 45 | return CompressionWorkerManager.instance 46 | } 47 | 48 | private async initializeWorkerSupport(): Promise { 49 | try { 50 | // Check if Worker is available 51 | if (typeof Worker === 'undefined') { 52 | import('../utils/logger') 53 | .then((m) => 54 | m.default.log('Web Workers not supported in this environment'), 55 | ) 56 | .catch(() => {}) 57 | return 58 | } 59 | 60 | // Create worker script blob 61 | this.workerScript = this.createWorkerScript() 62 | 63 | // Test worker functionality with a simple task 64 | await this.testWorkerSupport() 65 | 66 | this.isWorkerSupported = true 67 | import('../utils/logger') 68 | .then((m) => 69 | m.default.log('Compression workers initialized successfully'), 70 | ) 71 | .catch(() => {}) 72 | } catch (error) { 73 | import('../utils/logger') 74 | .then((m) => 75 | m.default.warn('Worker support initialization failed:', error), 76 | ) 77 | .catch(() => {}) 78 | this.isWorkerSupported = false 79 | } 80 | } 81 | 82 | private createWorkerScript(): string { 83 | return ` 84 | // Compression worker script 85 | let compressionFunctions = null; 86 | 87 | // Import compression tools dynamically 88 | async function initializeTools() { 89 | try { 90 | // Note: In a real implementation, you'd need to properly bundle and load the tools 91 | // For now, we'll simulate the import structure 92 | 93 | // These would be the actual tool imports 94 | // const browserImageCompression = await import('browser-image-compression'); 95 | // const compressorjs = await import('compressorjs'); 96 | 97 | console.log('Compression tools initialized in worker'); 98 | return true; 99 | } catch (error) { 100 | console.error('Failed to initialize compression tools in worker:', error); 101 | return false; 102 | } 103 | } 104 | 105 | // Handle compression requests 106 | async function compressFile(fileData, options) { 107 | try { 108 | // Reconstruct File object from transferred data 109 | const file = new File([fileData.buffer], fileData.name, { type: fileData.type }); 110 | 111 | // For worker-compatible tools, we need to implement compression logic here 112 | // This is a simplified version - in production you'd import and use actual tools 113 | 114 | // Fallback to basic compression if advanced tools aren't available 115 | return await basicCompress(file, options); 116 | 117 | } catch (error) { 118 | throw new Error('Worker compression failed: ' + error.message); 119 | } 120 | } 121 | 122 | // Basic compression fallback (simplified implementation) 123 | async function basicCompress(file, options) { 124 | // This is a placeholder - real implementation would use proper compression 125 | // For now, just return the original file data 126 | const arrayBuffer = await file.arrayBuffer(); 127 | return { 128 | buffer: arrayBuffer, 129 | size: arrayBuffer.byteLength, 130 | type: file.type 131 | }; 132 | } 133 | 134 | // Worker message handler 135 | self.onmessage = async function(e) { 136 | const { id, type, data } = e.data; 137 | 138 | try { 139 | if (type === 'compress') { 140 | const result = await compressFile(data.file, data.options); 141 | 142 | // Send result back to main thread 143 | self.postMessage({ 144 | id, 145 | type: 'result', 146 | data: result 147 | }, [result.buffer]); // Transfer the buffer 148 | 149 | } else if (type === 'init') { 150 | const initialized = await initializeTools(); 151 | self.postMessage({ 152 | id, 153 | type: 'result', 154 | data: { initialized } 155 | }); 156 | } 157 | } catch (error) { 158 | self.postMessage({ 159 | id, 160 | type: 'error', 161 | data: { message: error.message } 162 | }); 163 | } 164 | }; 165 | 166 | console.log('Compression worker ready'); 167 | ` 168 | } 169 | 170 | private async testWorkerSupport(): Promise { 171 | return new Promise((resolve, reject) => { 172 | if (!this.workerScript) { 173 | reject(new Error('Worker script not created')) 174 | return 175 | } 176 | 177 | const blob = new Blob([this.workerScript], { 178 | type: 'application/javascript', 179 | }) 180 | const workerUrl = URL.createObjectURL(blob) 181 | 182 | try { 183 | const testWorker = new Worker(workerUrl) 184 | const testId = `test_${Date.now()}` 185 | 186 | const timeout = setTimeout(() => { 187 | testWorker.terminate() 188 | URL.revokeObjectURL(workerUrl) 189 | reject(new Error('Worker test timeout')) 190 | }, 5000) 191 | 192 | testWorker.onmessage = (e) => { 193 | const { id, type, data } = e.data 194 | 195 | if (id === testId && type === 'result') { 196 | clearTimeout(timeout) 197 | testWorker.terminate() 198 | URL.revokeObjectURL(workerUrl) 199 | resolve() 200 | } 201 | } 202 | 203 | testWorker.onerror = (error) => { 204 | clearTimeout(timeout) 205 | testWorker.terminate() 206 | URL.revokeObjectURL(workerUrl) 207 | reject(error) 208 | } 209 | 210 | // Send test message 211 | testWorker.postMessage({ 212 | id: testId, 213 | type: 'init', 214 | data: {}, 215 | }) 216 | } catch (error) { 217 | URL.revokeObjectURL(workerUrl) 218 | reject(error) 219 | } 220 | }) 221 | } 222 | 223 | // Wait for worker initialization to complete 224 | async waitForInitialization(): Promise { 225 | if (this.initPromise) { 226 | await this.initPromise 227 | } 228 | } 229 | 230 | // Check if workers are supported and available 231 | isSupported(): boolean { 232 | return this.isWorkerSupported 233 | } 234 | 235 | // Check if a compression tool can run in worker 236 | isToolWorkerCompatible(toolName: string): boolean { 237 | return WORKER_COMPATIBLE_TOOLS.includes(toolName) 238 | } 239 | 240 | // Get tools that need main thread execution 241 | getDOMDependentTools(): string[] { 242 | return [...DOM_DEPENDENT_TOOLS] 243 | } 244 | 245 | // Compress file using worker (if supported) 246 | async compressInWorker(file: File, options: any): Promise { 247 | if (!this.isSupported()) { 248 | throw new Error('Workers not supported') 249 | } 250 | 251 | return new Promise((resolve, reject) => { 252 | const taskId = `worker_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 253 | 254 | const task: WorkerTask = { 255 | id: taskId, 256 | file, 257 | options, 258 | resolve, 259 | reject, 260 | } 261 | 262 | this.workerTasks.set(taskId, task) 263 | 264 | try { 265 | // Get or create worker 266 | const worker = this.getAvailableWorker() 267 | 268 | // Setup message handler for this task 269 | const messageHandler = (e: MessageEvent) => { 270 | const { id, type, data } = e.data 271 | 272 | if (id === taskId) { 273 | worker.removeEventListener('message', messageHandler) 274 | this.workerTasks.delete(taskId) 275 | 276 | if (type === 'result') { 277 | // Reconstruct blob from transferred data 278 | const blob = new Blob([data.buffer], { type: data.type }) 279 | resolve(blob) 280 | } else if (type === 'error') { 281 | reject(new Error(data.message)) 282 | } 283 | } 284 | } 285 | 286 | worker.addEventListener('message', messageHandler) 287 | 288 | // Send compression task to worker 289 | const fileData = { 290 | buffer: file.arrayBuffer(), 291 | name: file.name, 292 | type: file.type, 293 | size: file.size, 294 | } 295 | 296 | // Wait for file buffer to be ready 297 | fileData.buffer.then((buffer) => { 298 | worker.postMessage( 299 | { 300 | id: taskId, 301 | type: 'compress', 302 | data: { 303 | file: { 304 | buffer, 305 | name: file.name, 306 | type: file.type, 307 | size: file.size, 308 | }, 309 | options, 310 | }, 311 | }, 312 | [buffer], 313 | ) 314 | }) 315 | } catch (error) { 316 | this.workerTasks.delete(taskId) 317 | reject(error) 318 | } 319 | }) 320 | } 321 | 322 | private getAvailableWorker(): Worker { 323 | // For simplicity, create a new worker each time 324 | // In production, you might want to implement a proper pool 325 | if (!this.workerScript) { 326 | throw new Error('Worker script not available') 327 | } 328 | 329 | const blob = new Blob([this.workerScript], { 330 | type: 'application/javascript', 331 | }) 332 | const workerUrl = URL.createObjectURL(blob) 333 | const worker = new Worker(workerUrl) 334 | 335 | // Clean up URL after worker is created 336 | worker.addEventListener('error', () => { 337 | URL.revokeObjectURL(workerUrl) 338 | }) 339 | 340 | return worker 341 | } 342 | 343 | // Clean up resources 344 | destroy(): void { 345 | this.workers.forEach((worker) => worker.terminate()) 346 | this.workers = [] 347 | this.workerTasks.clear() 348 | } 349 | } 350 | 351 | // Export singleton instance 352 | export const compressionWorkerManager = CompressionWorkerManager.getInstance() 353 | -------------------------------------------------------------------------------- /src/utils/compressionQueue.ts: -------------------------------------------------------------------------------- 1 | // Compression queue manager for performance optimization 2 | export interface CompressionTask { 3 | id: string 4 | file: File 5 | options: any 6 | resolve: (result: Blob) => void 7 | reject: (error: Error) => void 8 | priority?: number // Higher number = higher priority 9 | // Optional cancel listener used to cleanup when task is started or removed 10 | cancelListener?: () => void 11 | } 12 | 13 | export interface QueueStats { 14 | pending: number 15 | running: number 16 | completed: number 17 | failed: number 18 | maxConcurrency: number 19 | } 20 | 21 | // Device performance detection and concurrency calculation 22 | export class PerformanceDetector { 23 | private static instance: PerformanceDetector 24 | private deviceInfo: { 25 | isMobile: boolean 26 | cpuCores: number 27 | memoryGB: number 28 | estimatedPerformance: 'low' | 'medium' | 'high' 29 | } | null = null 30 | 31 | private constructor() {} 32 | 33 | static getInstance(): PerformanceDetector { 34 | if (!PerformanceDetector.instance) { 35 | PerformanceDetector.instance = new PerformanceDetector() 36 | } 37 | return PerformanceDetector.instance 38 | } 39 | 40 | detectDevice() { 41 | if (this.deviceInfo) return this.deviceInfo 42 | 43 | // Defensive checks for non-browser environments (Node tests, SSR) 44 | const hasNavigator = typeof navigator !== 'undefined' 45 | const hasWindow = typeof window !== 'undefined' 46 | 47 | // Detect if mobile device (safe guards) 48 | const userAgent = 49 | hasNavigator && (navigator as any).userAgent 50 | ? (navigator as any).userAgent 51 | : '' 52 | const isMobile = 53 | /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( 54 | userAgent, 55 | ) || (hasWindow ? window.innerWidth <= 768 : false) 56 | 57 | // Get CPU cores (with fallback) 58 | const cpuCores = 59 | (hasNavigator && (navigator as any).hardwareConcurrency) || 60 | (isMobile ? 4 : 8) 61 | 62 | // Estimate memory (with fallback) 63 | const memory = hasNavigator ? (navigator as any).deviceMemory : undefined 64 | const memoryGB = memory || (isMobile ? 2 : 8) 65 | 66 | // Estimate performance level 67 | let estimatedPerformance: 'low' | 'medium' | 'high' = 'medium' 68 | if (isMobile || cpuCores <= 2 || memoryGB <= 2) { 69 | estimatedPerformance = 'low' 70 | } else if (cpuCores >= 8 && memoryGB >= 8) { 71 | estimatedPerformance = 'high' 72 | } 73 | 74 | this.deviceInfo = { 75 | isMobile, 76 | cpuCores, 77 | memoryGB, 78 | estimatedPerformance, 79 | } 80 | 81 | import('../utils/logger') 82 | .then((m) => m.default.log('Device detected:', this.deviceInfo)) 83 | .catch(() => {}) 84 | return this.deviceInfo 85 | } 86 | 87 | calculateOptimalConcurrency(): number { 88 | const device = this.detectDevice() 89 | 90 | // User-specified fallback values 91 | if (device.isMobile) { 92 | return 2 // Mobile devices: limit to 2 concurrent tasks 93 | } 94 | 95 | // Desktop/tablet: dynamic calculation based on performance 96 | switch (device.estimatedPerformance) { 97 | case 'low': 98 | return 2 99 | case 'medium': 100 | return 3 101 | case 'high': 102 | return Math.min(5, Math.max(2, Math.floor(device.cpuCores / 2))) 103 | default: 104 | return 3 105 | } 106 | } 107 | } 108 | 109 | export class CompressionQueue { 110 | private static instance: CompressionQueue 111 | private queue: CompressionTask[] = [] 112 | private running: Map = new Map() 113 | private completed: number = 0 114 | private failed: number = 0 115 | private maxConcurrency: number 116 | private performanceDetector: PerformanceDetector 117 | 118 | private constructor() { 119 | this.performanceDetector = PerformanceDetector.getInstance() 120 | this.maxConcurrency = this.performanceDetector.calculateOptimalConcurrency() 121 | import('../utils/logger') 122 | .then((m) => 123 | m.default.log( 124 | `Compression queue initialized with concurrency: ${this.maxConcurrency}`, 125 | ), 126 | ) 127 | .catch(() => {}) 128 | } 129 | 130 | static getInstance(): CompressionQueue { 131 | if (!CompressionQueue.instance) { 132 | CompressionQueue.instance = new CompressionQueue() 133 | } 134 | return CompressionQueue.instance 135 | } 136 | 137 | // Add task to queue 138 | addTask(task: CompressionTask): void { 139 | // Add priority if not specified (higher priority for smaller files) 140 | if (task.priority === undefined) { 141 | task.priority = Math.max( 142 | 1, 143 | 100 - Math.floor(task.file.size / (1024 * 1024)), 144 | ) // Larger files get lower priority 145 | } 146 | 147 | // Insert task in priority order 148 | const insertIndex = this.queue.findIndex( 149 | (t) => (t.priority || 0) < task.priority!, 150 | ) 151 | if (insertIndex === -1) { 152 | this.queue.push(task) 153 | } else { 154 | this.queue.splice(insertIndex, 0, task) 155 | } 156 | 157 | this.processQueue() 158 | } 159 | 160 | // Remove task from queue (if not running) 161 | removeTask(taskId: string): boolean { 162 | const queueIndex = this.queue.findIndex((t) => t.id === taskId) 163 | if (queueIndex !== -1) { 164 | this.queue.splice(queueIndex, 1) 165 | return true 166 | } 167 | return false 168 | } 169 | 170 | // Get current queue statistics 171 | getStats(): QueueStats { 172 | return { 173 | pending: this.queue.length, 174 | running: this.running.size, 175 | completed: this.completed, 176 | failed: this.failed, 177 | maxConcurrency: this.maxConcurrency, 178 | } 179 | } 180 | 181 | // Update max concurrency (useful for manual adjustment) 182 | setMaxConcurrency(newMax: number): void { 183 | this.maxConcurrency = Math.max(1, Math.min(10, newMax)) // Limit between 1-10 184 | import('../utils/logger') 185 | .then((m) => 186 | m.default.log(`Concurrency updated to: ${this.maxConcurrency}`), 187 | ) 188 | .catch(() => {}) 189 | this.processQueue() 190 | } 191 | 192 | // Clear all pending tasks 193 | clearQueue(): void { 194 | this.queue.forEach((task) => { 195 | task.reject(new Error('Task cancelled: queue cleared')) 196 | }) 197 | this.queue = [] 198 | } 199 | 200 | // Process queue - start tasks if slots available 201 | private async processQueue(): Promise { 202 | while (this.running.size < this.maxConcurrency && this.queue.length > 0) { 203 | const task = this.queue.shift()! 204 | this.running.set(task.id, task) 205 | 206 | // Execute task 207 | this.executeTask(task).catch((error) => { 208 | import('../utils/logger') 209 | .then((m) => m.default.error('Task execution error:', error)) 210 | .catch(() => {}) 211 | }) 212 | } 213 | } 214 | 215 | // Execute a single compression task 216 | private async executeTask(task: CompressionTask): Promise { 217 | try { 218 | import('../utils/logger') 219 | .then((m) => m.default.log(`Starting compression task: ${task.id}`)) 220 | .catch(() => {}) 221 | 222 | // Remove any cancel listener since task is now running 223 | if (task.cancelListener) { 224 | try { 225 | task.cancelListener() 226 | } catch (e) { 227 | /* ignore */ 228 | } 229 | task.cancelListener = undefined 230 | } 231 | 232 | // Import compress function dynamically to avoid circular dependency 233 | const { compress } = await import('../compress') 234 | 235 | const result = await compress(task.file, { 236 | ...task.options, 237 | type: 'blob', 238 | }) 239 | 240 | // Extract the actual blob from the result (compress may return MultipleCompressResults) 241 | const blob = 242 | typeof result === 'object' && 'bestResult' in result 243 | ? result.bestResult 244 | : result 245 | 246 | this.running.delete(task.id) 247 | this.completed++ 248 | 249 | import('../utils/logger') 250 | .then((m) => m.default.log(`Task completed: ${task.id}`)) 251 | .catch(() => {}) 252 | task.resolve(blob as Blob) 253 | } catch (error) { 254 | this.running.delete(task.id) 255 | this.failed++ 256 | import('../utils/logger') 257 | .then((m) => m.default.error(`Task failed: ${task.id}`, error)) 258 | .catch(() => {}) 259 | task.reject( 260 | error instanceof Error ? error : new Error('Compression failed'), 261 | ) 262 | } 263 | 264 | // Continue processing queue 265 | this.processQueue() 266 | } 267 | 268 | // Utility: Create a promise-based compression task 269 | compress(file: File, options: any, priority?: number): Promise { 270 | return new Promise((resolve, reject) => { 271 | const taskId = `${file.name}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 272 | 273 | const task: CompressionTask = { 274 | id: taskId, 275 | file, 276 | options, 277 | resolve, 278 | reject, 279 | priority, 280 | } 281 | 282 | // If caller provided an AbortSignal in options, wire it to cancel this queued task 283 | if (options && options.signal) { 284 | const sig = options.signal as AbortSignal 285 | const onAbort = () => { 286 | import('../utils/logger') 287 | .then((m) => 288 | m.default.log( 289 | 'compressionQueue: abort signal received for task', 290 | taskId, 291 | ), 292 | ) 293 | .catch(() => {}) 294 | // If task still in queue, remove and reject 295 | const removed = this.removeTask(taskId) 296 | if (removed) { 297 | import('../utils/logger') 298 | .then((m) => 299 | m.default.log( 300 | 'compressionQueue: task removed from queue due to abort', 301 | taskId, 302 | ), 303 | ) 304 | .catch(() => {}) 305 | try { 306 | reject(new Error('Task cancelled')) 307 | } catch (e) { 308 | /* ignore */ 309 | } 310 | } else { 311 | import('../utils/logger') 312 | .then((m) => 313 | m.default.log( 314 | 'compressionQueue: abort received but task already started or not in queue', 315 | taskId, 316 | ), 317 | ) 318 | .catch(() => {}) 319 | } 320 | } 321 | 322 | // Attach listener and keep a cleanup function on the task 323 | sig.addEventListener('abort', onAbort, { once: true }) 324 | task.cancelListener = () => { 325 | try { 326 | sig.removeEventListener('abort', onAbort) 327 | } catch (e) { 328 | /* ignore */ 329 | } 330 | } 331 | } 332 | 333 | this.addTask(task) 334 | }) 335 | } 336 | } 337 | 338 | // Export singleton instance for easy access 339 | export const compressionQueue = CompressionQueue.getInstance() 340 | --------------------------------------------------------------------------------