Add squish app source, Dockerfile and HF config
Browse files- .gitignore +27 -0
- Dockerfile +47 -0
- LICENSE +21 -0
- README.md +11 -5
- eslint.config.js +28 -0
- index.html +39 -0
- package-lock.json +0 -0
- package.json +38 -0
- postcss.config.js +6 -0
- src/components/CompressionOptions.tsx +59 -0
- src/components/DownloadAll.tsx +19 -0
- src/components/DropZone.tsx +70 -0
- src/components/ImageList.tsx +99 -0
- src/hooks/useImageProcessing.ts +77 -0
- src/hooks/useImageQueue.ts +148 -0
- src/types/encoders.ts +16 -0
- src/utils/canvas.ts +19 -0
- src/utils/download.ts +17 -0
- src/utils/formatDefaults.ts +12 -0
- src/utils/imageProcessing.ts +90 -0
- src/utils/resize.ts +67 -0
- src/utils/wasm.ts +32 -0
- tailwind.config.js +8 -0
- tsconfig.app.json +24 -0
- tsconfig.json +7 -0
- tsconfig.node.json +22 -0
- vite.config.ts +27 -0
.gitignore
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Logs
|
2 |
+
logs
|
3 |
+
*.log
|
4 |
+
npm-debug.log*
|
5 |
+
yarn-debug.log*
|
6 |
+
yarn-error.log*
|
7 |
+
pnpm-debug.log*
|
8 |
+
lerna-debug.log*
|
9 |
+
|
10 |
+
node_modules
|
11 |
+
dist
|
12 |
+
dist-ssr
|
13 |
+
*.local
|
14 |
+
|
15 |
+
# Editor directories and files
|
16 |
+
.vscode/*
|
17 |
+
!.vscode/extensions.json
|
18 |
+
.idea
|
19 |
+
.DS_Store
|
20 |
+
*.suo
|
21 |
+
*.ntvs*
|
22 |
+
*.njsproj
|
23 |
+
*.sln
|
24 |
+
*.sw?
|
25 |
+
|
26 |
+
# Local Netlify folder
|
27 |
+
.netlify
|
Dockerfile
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# 步骤 1: 选择一个包含 Node.js 的基础镜像
|
2 |
+
# 我们选用一个 LTS (长期支持) 版本,alpine 版本体积更小
|
3 |
+
FROM node:18-alpine AS builder
|
4 |
+
|
5 |
+
# 步骤 2: 设置工作目录
|
6 |
+
WORKDIR /app
|
7 |
+
|
8 |
+
# 步骤 3: 复制 package.json 和 package-lock.json (或 yarn.lock)
|
9 |
+
# 这样做可以利用 Docker 的缓存机制,如果这些文件没变,就不用重新安装依赖
|
10 |
+
COPY package*.json ./
|
11 |
+
|
12 |
+
# 步骤 4: 安装生产依赖
|
13 |
+
# 使用 npm ci 通常比 npm install 更快、更可靠,特别是在 CI/CD 环境中
|
14 |
+
RUN npm ci --omit=dev
|
15 |
+
|
16 |
+
# 步骤 5: 复制项目的所有代码到工作目录
|
17 |
+
COPY . .
|
18 |
+
|
19 |
+
# 步骤 6: 构建项目
|
20 |
+
# 运行你在本地执行的 build 命令
|
21 |
+
RUN npm run build
|
22 |
+
|
23 |
+
# -------- 这部分是优化,只保留运行需要的部分 --------
|
24 |
+
# 如果你的项目构建后只需要 Node 来运行一个服务(比如 vite preview),
|
25 |
+
# 可以继续使用 node 镜像。如果只是纯静态文件,可以用 nginx 或 http-server 等。
|
26 |
+
# squish 项目的 `npm run build` 生成静态文件,但 `npm run preview` 启动了一个服务来预览,所以我们还是需要 Node。
|
27 |
+
|
28 |
+
# 步骤 7: (可选优化 - 如果 devDependencies 在运行时不需要)
|
29 |
+
# 如果运行时不需要开发依赖,可以重新安装纯生产依赖
|
30 |
+
# RUN rm -rf node_modules && npm ci --omit=dev --ignore-scripts
|
31 |
+
|
32 |
+
# 步骤 8: 暴露端口
|
33 |
+
# 查看 package.json,`npm run preview` 实际上是运行 `vite preview`。
|
34 |
+
# Vite preview 默认端口通常是 4173 或 5173。查阅 Vite 文档或尝试运行 `npm run preview -- --port 7860` 看看。
|
35 |
+
# Hugging Face Spaces 默认喜欢 7860 端口,但它通常也能自动映射。
|
36 |
+
# 我们先暴露 vite preview 的默认端口 4173,然后在 Hugging Face 配置里指定它。
|
37 |
+
# 如果你想强制用 7860,可以修改下面的 CMD 命令。
|
38 |
+
EXPOSE 4173
|
39 |
+
|
40 |
+
# 步骤 9: 定义容器启动时运行的命令
|
41 |
+
# 使用 `npm run preview -- --host` 让服务监听所有网络接口(Docker 容器内必须)
|
42 |
+
# `--host` 参数是传递给 `vite preview` 的
|
43 |
+
CMD ["npm", "run", "preview", "--", "--host"]
|
44 |
+
|
45 |
+
# --- 如果你想强制用 7860 端口 ---
|
46 |
+
# EXPOSE 7860
|
47 |
+
# CMD ["npm", "run", "preview", "--", "--host", "--port", "7860"]
|
LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
MIT License
|
2 |
+
|
3 |
+
Copyright (c) 2024 Addy Osmani
|
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.
|
README.md
CHANGED
@@ -1,11 +1,17 @@
|
|
1 |
---
|
2 |
-
title:
|
3 |
-
emoji:
|
4 |
-
colorFrom:
|
5 |
colorTo: blue
|
6 |
sdk: docker
|
|
|
7 |
pinned: false
|
8 |
-
license: apache-2.0
|
9 |
---
|
10 |
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
---
|
2 |
+
title: Squish Image Optimizer
|
3 |
+
emoji: 🖼️✨
|
4 |
+
colorFrom: green
|
5 |
colorTo: blue
|
6 |
sdk: docker
|
7 |
+
app_port: 4173 # 这个端口号必须和你 Dockerfile 中 EXPOSE 的端口一致
|
8 |
pinned: false
|
|
|
9 |
---
|
10 |
|
11 |
+
# Squish - 在线图片优化工具
|
12 |
+
|
13 |
+
这是一个部署在 Hugging Face Spaces 上的 Squish 应用实例。
|
14 |
+
|
15 |
+
它使用 Docker 进行部署。
|
16 |
+
|
17 |
+
原始项目地址: [https://github.com/addyosmani/squish](https://github.com/addyosmani/squish)
|
eslint.config.js
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import js from '@eslint/js';
|
2 |
+
import globals from 'globals';
|
3 |
+
import reactHooks from 'eslint-plugin-react-hooks';
|
4 |
+
import reactRefresh from 'eslint-plugin-react-refresh';
|
5 |
+
import tseslint from 'typescript-eslint';
|
6 |
+
|
7 |
+
export default tseslint.config(
|
8 |
+
{ ignores: ['dist'] },
|
9 |
+
{
|
10 |
+
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
11 |
+
files: ['**/*.{ts,tsx}'],
|
12 |
+
languageOptions: {
|
13 |
+
ecmaVersion: 2020,
|
14 |
+
globals: globals.browser,
|
15 |
+
},
|
16 |
+
plugins: {
|
17 |
+
'react-hooks': reactHooks,
|
18 |
+
'react-refresh': reactRefresh,
|
19 |
+
},
|
20 |
+
rules: {
|
21 |
+
...reactHooks.configs.recommended.rules,
|
22 |
+
'react-refresh/only-export-components': [
|
23 |
+
'warn',
|
24 |
+
{ allowConstantExport: true },
|
25 |
+
],
|
26 |
+
},
|
27 |
+
}
|
28 |
+
);
|
index.html
ADDED
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!doctype html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8" />
|
5 |
+
<!-- Primary Meta Tags -->
|
6 |
+
<title>Squish - Batch Browser-based Image Compression</title>
|
7 |
+
<meta name="title" content="Squish - Browser-based Image Compression">
|
8 |
+
<meta name="description" content="Fast, efficient batch image compression right in your browser. Supports AVIF, JPEG, JXL, PNG, and WebP formats with adjustable quality settings.">
|
9 |
+
<meta name="keywords" content="image, compression, squish, batch, avif, jpeg, jxl, png, webp, quality, lossless, lossy, browser, web, app, tool, utility, free, open-source, fast, efficient, simple, easy, fast, quick, online, offline, pwa, progressive, web, application, webapp, web-app">
|
10 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
11 |
+
|
12 |
+
<!-- Icons -->
|
13 |
+
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
14 |
+
<link rel="icon" type="image/png" sizes="128x128" href="/image-128x128.png" />
|
15 |
+
<link rel="icon" type="image/png" sizes="512x512" href="/image-512x512.png" />
|
16 |
+
<link rel="apple-touch-icon" sizes="512x512" href="/image-512x512.png">
|
17 |
+
|
18 |
+
<!-- Open Graph / Facebook -->
|
19 |
+
<meta property="og:type" content="website">
|
20 |
+
<meta property="og:url" content="https://squish.addy.ie">
|
21 |
+
<meta property="og:title" content="Squish - Batch Browser-based Image Compression">
|
22 |
+
<meta property="og:description" content="Fast, efficient batch image compression right in your browser. Supports AVIF, JPEG, JXL, PNG, and WebP formats with adjustable quality settings.">
|
23 |
+
<meta property="og:image" content="https://squish.addy.ie/meta.jpg">
|
24 |
+
|
25 |
+
<!-- Twitter -->
|
26 |
+
<meta property="twitter:card" content="summary_large_image">
|
27 |
+
<meta property="twitter:url" content="https://squish.addy.ie">
|
28 |
+
<meta property="twitter:title" content="Squish - Batch Browser-based Image Compression">
|
29 |
+
<meta property="twitter:description" content="Fast, efficient batch image compression right in your browser. Supports AVIF, JPEG, JXL, PNG, and WebP formats with adjustable quality settings.">
|
30 |
+
<meta property="twitter:image" content="https://squish.addy.ie/meta.jpg">
|
31 |
+
|
32 |
+
<!-- Theme Color -->
|
33 |
+
<meta name="theme-color" content="#18181b">
|
34 |
+
</head>
|
35 |
+
<body>
|
36 |
+
<div id="root"></div>
|
37 |
+
<script type="module" src="/src/main.tsx"></script>
|
38 |
+
</body>
|
39 |
+
</html>
|
package-lock.json
ADDED
The diff for this file is too large to render.
See raw diff
|
|
package.json
ADDED
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"name": "squish-image-compressor",
|
3 |
+
"private": true,
|
4 |
+
"version": "0.0.0",
|
5 |
+
"type": "module",
|
6 |
+
"scripts": {
|
7 |
+
"dev": "vite",
|
8 |
+
"build": "vite build",
|
9 |
+
"lint": "eslint .",
|
10 |
+
"preview": "vite preview"
|
11 |
+
},
|
12 |
+
"dependencies": {
|
13 |
+
"@jsquash/avif": "^1.1.0",
|
14 |
+
"@jsquash/jpeg": "^1.2.0",
|
15 |
+
"@jsquash/jxl": "^1.0.0",
|
16 |
+
"@jsquash/png": "^2.0.0",
|
17 |
+
"@jsquash/webp": "^1.2.0",
|
18 |
+
"lucide-react": "^0.344.0",
|
19 |
+
"react": "^18.3.1",
|
20 |
+
"react-dom": "^18.3.1"
|
21 |
+
},
|
22 |
+
"devDependencies": {
|
23 |
+
"@eslint/js": "^9.9.1",
|
24 |
+
"@types/react": "^18.3.5",
|
25 |
+
"@types/react-dom": "^18.3.0",
|
26 |
+
"@vitejs/plugin-react": "^4.3.1",
|
27 |
+
"autoprefixer": "^10.4.18",
|
28 |
+
"eslint": "^9.9.1",
|
29 |
+
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
30 |
+
"eslint-plugin-react-refresh": "^0.4.11",
|
31 |
+
"globals": "^15.9.0",
|
32 |
+
"postcss": "^8.4.35",
|
33 |
+
"tailwindcss": "^3.4.1",
|
34 |
+
"typescript": "^5.5.3",
|
35 |
+
"typescript-eslint": "^8.3.0",
|
36 |
+
"vite": "^5.4.2"
|
37 |
+
}
|
38 |
+
}
|
postcss.config.js
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export default {
|
2 |
+
plugins: {
|
3 |
+
tailwindcss: {},
|
4 |
+
autoprefixer: {},
|
5 |
+
},
|
6 |
+
};
|
src/components/CompressionOptions.tsx
ADDED
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import type { OutputType, CompressionOptions } from '../types';
|
3 |
+
|
4 |
+
interface CompressionOptionsProps {
|
5 |
+
options: CompressionOptions;
|
6 |
+
outputType: OutputType;
|
7 |
+
onOptionsChange: (options: CompressionOptions) => void;
|
8 |
+
onOutputTypeChange: (type: OutputType) => void;
|
9 |
+
}
|
10 |
+
|
11 |
+
export function CompressionOptions({
|
12 |
+
options,
|
13 |
+
outputType,
|
14 |
+
onOptionsChange,
|
15 |
+
onOutputTypeChange,
|
16 |
+
}: CompressionOptionsProps) {
|
17 |
+
return (
|
18 |
+
<div className="space-y-6 bg-white p-6 rounded-lg shadow-sm">
|
19 |
+
<div>
|
20 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
21 |
+
Output Format
|
22 |
+
</label>
|
23 |
+
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
24 |
+
{(['avif', 'jpeg', 'jxl', 'png', 'webp'] as const).map((format) => (
|
25 |
+
<button
|
26 |
+
key={format}
|
27 |
+
className={`px-4 py-2 rounded-md text-sm font-medium uppercase ${
|
28 |
+
outputType === format
|
29 |
+
? 'bg-blue-500 text-white'
|
30 |
+
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
31 |
+
}`}
|
32 |
+
onClick={() => onOutputTypeChange(format)}
|
33 |
+
>
|
34 |
+
{format}
|
35 |
+
</button>
|
36 |
+
))}
|
37 |
+
</div>
|
38 |
+
</div>
|
39 |
+
|
40 |
+
{outputType !== 'png' && (
|
41 |
+
<div>
|
42 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
43 |
+
Quality: {options.quality}%
|
44 |
+
</label>
|
45 |
+
<input
|
46 |
+
type="range"
|
47 |
+
min="1"
|
48 |
+
max="100"
|
49 |
+
value={options.quality}
|
50 |
+
onChange={(e) =>
|
51 |
+
onOptionsChange({ quality: Number(e.target.value) })
|
52 |
+
}
|
53 |
+
className="w-full"
|
54 |
+
/>
|
55 |
+
</div>
|
56 |
+
)}
|
57 |
+
</div>
|
58 |
+
);
|
59 |
+
}
|
src/components/DownloadAll.tsx
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import { Download } from 'lucide-react';
|
3 |
+
|
4 |
+
interface DownloadAllProps {
|
5 |
+
onDownloadAll: () => void;
|
6 |
+
count: number;
|
7 |
+
}
|
8 |
+
|
9 |
+
export function DownloadAll({ onDownloadAll, count }: DownloadAllProps) {
|
10 |
+
return (
|
11 |
+
<button
|
12 |
+
onClick={onDownloadAll}
|
13 |
+
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
|
14 |
+
>
|
15 |
+
<Download className="w-5 h-5" />
|
16 |
+
Download All ({count} {count === 1 ? 'image' : 'images'})
|
17 |
+
</button>
|
18 |
+
);
|
19 |
+
}
|
src/components/DropZone.tsx
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useCallback } from 'react';
|
2 |
+
import { Upload } from 'lucide-react';
|
3 |
+
import type { ImageFile } from '../types';
|
4 |
+
|
5 |
+
interface DropZoneProps {
|
6 |
+
onFilesDrop: (files: ImageFile[]) => void;
|
7 |
+
}
|
8 |
+
|
9 |
+
export function DropZone({ onFilesDrop }: DropZoneProps) {
|
10 |
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
11 |
+
e.preventDefault();
|
12 |
+
const files = Array.from(e.dataTransfer.files)
|
13 |
+
.filter(file => file.type.startsWith('image/') || file.name.toLowerCase().endsWith('jxl'))
|
14 |
+
.map(file => ({
|
15 |
+
id: crypto.randomUUID(),
|
16 |
+
file,
|
17 |
+
status: 'pending' as const,
|
18 |
+
originalSize: file.size,
|
19 |
+
}));
|
20 |
+
onFilesDrop(files);
|
21 |
+
}, [onFilesDrop]);
|
22 |
+
|
23 |
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
24 |
+
e.preventDefault();
|
25 |
+
}, []);
|
26 |
+
|
27 |
+
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
28 |
+
const files = Array.from(e.target.files || [])
|
29 |
+
.filter(file => file.type.startsWith('image/') || file.name.toLowerCase().endsWith('jxl'))
|
30 |
+
.map(file => ({
|
31 |
+
id: crypto.randomUUID(),
|
32 |
+
file,
|
33 |
+
status: 'pending' as const,
|
34 |
+
originalSize: file.size,
|
35 |
+
}));
|
36 |
+
onFilesDrop(files);
|
37 |
+
e.target.value = '';
|
38 |
+
}, [onFilesDrop]);
|
39 |
+
|
40 |
+
return (
|
41 |
+
<div
|
42 |
+
className="border-2 border-dashed border-gray-300 rounded-lg p-12 text-center hover:border-blue-500 transition-colors"
|
43 |
+
onDrop={handleDrop}
|
44 |
+
onDragOver={handleDragOver}
|
45 |
+
>
|
46 |
+
<input
|
47 |
+
type="file"
|
48 |
+
id="fileInput"
|
49 |
+
className="hidden"
|
50 |
+
multiple
|
51 |
+
accept="image/*,.jxl"
|
52 |
+
onChange={handleFileInput}
|
53 |
+
/>
|
54 |
+
<label
|
55 |
+
htmlFor="fileInput"
|
56 |
+
className="cursor-pointer flex flex-col items-center gap-4"
|
57 |
+
>
|
58 |
+
<Upload className="w-12 h-12 text-gray-400" />
|
59 |
+
<div>
|
60 |
+
<p className="text-lg font-medium text-gray-700">
|
61 |
+
Drop images here or click to upload
|
62 |
+
</p>
|
63 |
+
<p className="text-sm text-gray-500">
|
64 |
+
Supports JPEG, PNG, WebP, AVIF, and JXL
|
65 |
+
</p>
|
66 |
+
</div>
|
67 |
+
</label>
|
68 |
+
</div>
|
69 |
+
);
|
70 |
+
}
|
src/components/ImageList.tsx
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from 'react';
|
2 |
+
import { X, CheckCircle, AlertCircle, Loader2, Download } from 'lucide-react';
|
3 |
+
import type { ImageFile } from '../types';
|
4 |
+
import { formatFileSize } from '../utils/imageProcessing';
|
5 |
+
import { downloadImage } from '../utils/download';
|
6 |
+
|
7 |
+
interface ImageListProps {
|
8 |
+
images: ImageFile[];
|
9 |
+
onRemove: (id: string) => void;
|
10 |
+
}
|
11 |
+
|
12 |
+
export function ImageList({ images, onRemove }: ImageListProps) {
|
13 |
+
if (images.length === 0) return null;
|
14 |
+
|
15 |
+
return (
|
16 |
+
<div className="space-y-4">
|
17 |
+
{images.map((image) => (
|
18 |
+
<div
|
19 |
+
key={image.id}
|
20 |
+
className="bg-white rounded-lg shadow-sm p-4 flex items-center gap-4"
|
21 |
+
>
|
22 |
+
{image.preview && (
|
23 |
+
<img
|
24 |
+
src={image.preview}
|
25 |
+
alt={image.file.name}
|
26 |
+
className="w-16 h-16 object-cover rounded"
|
27 |
+
/>
|
28 |
+
)}
|
29 |
+
<div className="flex-1 min-w-0">
|
30 |
+
<div className="flex items-center justify-between">
|
31 |
+
<p className="text-sm font-medium text-gray-900 truncate">
|
32 |
+
{image.file.name}
|
33 |
+
</p>
|
34 |
+
<div className="flex items-center gap-2">
|
35 |
+
{image.status === 'complete' && (
|
36 |
+
<button
|
37 |
+
onClick={() => downloadImage(image)}
|
38 |
+
className="text-gray-400 hover:text-gray-600"
|
39 |
+
title="Download"
|
40 |
+
>
|
41 |
+
<Download className="w-5 h-5" />
|
42 |
+
</button>
|
43 |
+
)}
|
44 |
+
<button
|
45 |
+
onClick={() => onRemove(image.id)}
|
46 |
+
className="text-gray-400 hover:text-gray-600"
|
47 |
+
title="Remove"
|
48 |
+
>
|
49 |
+
<X className="w-5 h-5" />
|
50 |
+
</button>
|
51 |
+
</div>
|
52 |
+
</div>
|
53 |
+
<div className="mt-1 flex items-center gap-2 text-sm text-gray-500">
|
54 |
+
{image.status === 'pending' && (
|
55 |
+
<span>Ready to process</span>
|
56 |
+
)}
|
57 |
+
{image.status === 'processing' && (
|
58 |
+
<span className="flex items-center gap-2">
|
59 |
+
<Loader2 className="w-4 h-4 animate-spin" />
|
60 |
+
Processing...
|
61 |
+
</span>
|
62 |
+
)}
|
63 |
+
{image.status === 'complete' && (
|
64 |
+
<span className="flex items-center gap-2 text-green-600">
|
65 |
+
<CheckCircle className="w-4 h-4" />
|
66 |
+
Complete
|
67 |
+
</span>
|
68 |
+
)}
|
69 |
+
{image.status === 'error' && (
|
70 |
+
<span className="flex items-center gap-2 text-red-600">
|
71 |
+
<AlertCircle className="w-4 h-4" />
|
72 |
+
{image.error || 'Error processing image'}
|
73 |
+
</span>
|
74 |
+
)}
|
75 |
+
</div>
|
76 |
+
<div className="mt-1 text-sm text-gray-500">
|
77 |
+
{formatFileSize(image.originalSize)}
|
78 |
+
{image.compressedSize && (
|
79 |
+
<>
|
80 |
+
{' → '}
|
81 |
+
{formatFileSize(image.compressedSize)}{' '}
|
82 |
+
<span className="text-green-600">
|
83 |
+
(
|
84 |
+
{Math.round(
|
85 |
+
((image.originalSize - image.compressedSize) /
|
86 |
+
image.originalSize) *
|
87 |
+
100
|
88 |
+
)}
|
89 |
+
% smaller)
|
90 |
+
</span>
|
91 |
+
</>
|
92 |
+
)}
|
93 |
+
</div>
|
94 |
+
</div>
|
95 |
+
</div>
|
96 |
+
))}
|
97 |
+
</div>
|
98 |
+
);
|
99 |
+
}
|
src/hooks/useImageProcessing.ts
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useCallback } from 'react';
|
2 |
+
import type { ImageFile, OutputType, CompressionOptions } from '../types';
|
3 |
+
import { decode, encode, getFileType } from '../utils/imageProcessing';
|
4 |
+
|
5 |
+
export function useImageProcessing(
|
6 |
+
options: CompressionOptions,
|
7 |
+
outputType: OutputType,
|
8 |
+
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>
|
9 |
+
) {
|
10 |
+
const processImageFile = useCallback(async (image: ImageFile) => {
|
11 |
+
try {
|
12 |
+
setImages((prev) =>
|
13 |
+
prev.map((img) =>
|
14 |
+
img.id === image.id
|
15 |
+
? { ...img, status: 'processing' as const }
|
16 |
+
: img
|
17 |
+
)
|
18 |
+
);
|
19 |
+
|
20 |
+
const fileBuffer = await image.file.arrayBuffer();
|
21 |
+
const sourceType = getFileType(image.file);
|
22 |
+
|
23 |
+
if (!fileBuffer.byteLength) {
|
24 |
+
throw new Error('Empty file');
|
25 |
+
}
|
26 |
+
|
27 |
+
// Decode the image
|
28 |
+
const imageData = await decode(sourceType, fileBuffer);
|
29 |
+
|
30 |
+
if (!imageData || !imageData.width || !imageData.height) {
|
31 |
+
throw new Error('Invalid image data');
|
32 |
+
}
|
33 |
+
|
34 |
+
// Encode to the target format
|
35 |
+
const compressedBuffer = await encode(outputType, imageData, options);
|
36 |
+
|
37 |
+
if (!compressedBuffer.byteLength) {
|
38 |
+
throw new Error('Failed to compress image');
|
39 |
+
}
|
40 |
+
|
41 |
+
const blob = new Blob([compressedBuffer], { type: `image/${outputType}` });
|
42 |
+
const preview = URL.createObjectURL(blob);
|
43 |
+
|
44 |
+
setImages((prev) =>
|
45 |
+
prev.map((img) =>
|
46 |
+
img.id === image.id
|
47 |
+
? {
|
48 |
+
...img,
|
49 |
+
status: 'complete' as const,
|
50 |
+
preview,
|
51 |
+
blob,
|
52 |
+
compressedSize: compressedBuffer.byteLength,
|
53 |
+
outputType,
|
54 |
+
}
|
55 |
+
: img
|
56 |
+
)
|
57 |
+
);
|
58 |
+
} catch (error) {
|
59 |
+
console.error('Error processing image:', error);
|
60 |
+
setImages((prev) =>
|
61 |
+
prev.map((img) =>
|
62 |
+
img.id === image.id
|
63 |
+
? {
|
64 |
+
...img,
|
65 |
+
status: 'error' as const,
|
66 |
+
error: error instanceof Error
|
67 |
+
? error.message
|
68 |
+
: 'Failed to process image',
|
69 |
+
}
|
70 |
+
: img
|
71 |
+
)
|
72 |
+
);
|
73 |
+
}
|
74 |
+
}, [options, outputType, setImages]);
|
75 |
+
|
76 |
+
return { processImage: processImageFile };
|
77 |
+
}
|
src/hooks/useImageQueue.ts
ADDED
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
2 |
+
import type { ImageFile, OutputType, CompressionOptions } from '../types';
|
3 |
+
import { decode, encode, getFileType } from '../utils/imageProcessing';
|
4 |
+
|
5 |
+
export function useImageQueue(
|
6 |
+
options: CompressionOptions,
|
7 |
+
outputType: OutputType,
|
8 |
+
setImages: React.Dispatch<React.SetStateAction<ImageFile[]>>
|
9 |
+
) {
|
10 |
+
const MAX_PARALLEL_PROCESSING = 3;
|
11 |
+
const [queue, setQueue] = useState<string[]>([]);
|
12 |
+
const processingCount = useRef(0);
|
13 |
+
const processingImages = useRef(new Set<string>());
|
14 |
+
|
15 |
+
const processImage = useCallback(async (image: ImageFile) => {
|
16 |
+
if (processingImages.current.has(image.id)) {
|
17 |
+
return; // Skip if already processing this image
|
18 |
+
}
|
19 |
+
processingImages.current.add(image.id);
|
20 |
+
processingCount.current++;
|
21 |
+
|
22 |
+
try {
|
23 |
+
setImages((prev) =>
|
24 |
+
prev.map((img) =>
|
25 |
+
img.id === image.id
|
26 |
+
? { ...img, status: 'processing' as const }
|
27 |
+
: img
|
28 |
+
)
|
29 |
+
);
|
30 |
+
|
31 |
+
const fileBuffer = await image.file.arrayBuffer();
|
32 |
+
const sourceType = getFileType(image.file);
|
33 |
+
|
34 |
+
if (!fileBuffer.byteLength) {
|
35 |
+
throw new Error('Empty file');
|
36 |
+
}
|
37 |
+
|
38 |
+
// Decode the image
|
39 |
+
const imageData = await decode(sourceType, fileBuffer);
|
40 |
+
|
41 |
+
if (!imageData || !imageData.width || !imageData.height) {
|
42 |
+
throw new Error('Invalid image data');
|
43 |
+
}
|
44 |
+
|
45 |
+
// Encode to the target format
|
46 |
+
const compressedBuffer = await encode(outputType, imageData, options);
|
47 |
+
|
48 |
+
if (!compressedBuffer.byteLength) {
|
49 |
+
throw new Error('Failed to compress image');
|
50 |
+
}
|
51 |
+
|
52 |
+
const blob = new Blob([compressedBuffer], { type: `image/${outputType}` });
|
53 |
+
const preview = URL.createObjectURL(blob);
|
54 |
+
|
55 |
+
setImages((prev) =>
|
56 |
+
prev.map((img) =>
|
57 |
+
img.id === image.id
|
58 |
+
? {
|
59 |
+
...img,
|
60 |
+
status: 'complete' as const,
|
61 |
+
preview,
|
62 |
+
blob,
|
63 |
+
compressedSize: compressedBuffer.byteLength,
|
64 |
+
outputType,
|
65 |
+
}
|
66 |
+
: img
|
67 |
+
)
|
68 |
+
);
|
69 |
+
} catch (error) {
|
70 |
+
console.error('Error processing image:', error);
|
71 |
+
setImages((prev) =>
|
72 |
+
prev.map((img) =>
|
73 |
+
img.id === image.id
|
74 |
+
? {
|
75 |
+
...img,
|
76 |
+
status: 'error' as const,
|
77 |
+
error: error instanceof Error
|
78 |
+
? error.message
|
79 |
+
: 'Failed to process image',
|
80 |
+
}
|
81 |
+
: img
|
82 |
+
)
|
83 |
+
);
|
84 |
+
} finally {
|
85 |
+
processingImages.current.delete(image.id);
|
86 |
+
processingCount.current--;
|
87 |
+
// Try to process next images if any
|
88 |
+
setTimeout(processNextInQueue, 0);
|
89 |
+
}
|
90 |
+
}, [options, outputType, setImages]);
|
91 |
+
|
92 |
+
const processNextInQueue = useCallback(() => {
|
93 |
+
console.log('Processing next in queue:', {
|
94 |
+
queueLength: queue.length,
|
95 |
+
processingCount: processingCount.current,
|
96 |
+
processingImages: [...processingImages.current]
|
97 |
+
});
|
98 |
+
|
99 |
+
if (queue.length === 0) return;
|
100 |
+
|
101 |
+
// Get all images we can process in this batch
|
102 |
+
setImages(prev => {
|
103 |
+
const imagesToProcess = prev.filter(img =>
|
104 |
+
queue.includes(img.id) &&
|
105 |
+
!processingImages.current.has(img.id) &&
|
106 |
+
processingCount.current < MAX_PARALLEL_PROCESSING
|
107 |
+
);
|
108 |
+
|
109 |
+
console.log('Found images to process:', imagesToProcess.length);
|
110 |
+
|
111 |
+
if (imagesToProcess.length === 0) return prev;
|
112 |
+
|
113 |
+
// Start processing these images
|
114 |
+
imagesToProcess.forEach((image, index) => {
|
115 |
+
setTimeout(() => {
|
116 |
+
processImage(image);
|
117 |
+
}, index * 100);
|
118 |
+
});
|
119 |
+
|
120 |
+
// Remove these from queue
|
121 |
+
setQueue(current => current.filter(id =>
|
122 |
+
!imagesToProcess.some(img => img.id === id)
|
123 |
+
));
|
124 |
+
|
125 |
+
// Update status to queued
|
126 |
+
return prev.map(img =>
|
127 |
+
imagesToProcess.some(processImg => processImg.id === img.id)
|
128 |
+
? { ...img, status: 'queued' as const }
|
129 |
+
: img
|
130 |
+
);
|
131 |
+
});
|
132 |
+
}, [queue, processImage, setImages]);
|
133 |
+
|
134 |
+
// Start processing when queue changes
|
135 |
+
useEffect(() => {
|
136 |
+
console.log('Queue changed:', queue.length);
|
137 |
+
if (queue.length > 0) {
|
138 |
+
processNextInQueue();
|
139 |
+
}
|
140 |
+
}, [queue, processNextInQueue]);
|
141 |
+
|
142 |
+
const addToQueue = useCallback((imageId: string) => {
|
143 |
+
console.log('Adding to queue:', imageId);
|
144 |
+
setQueue(prev => [...prev, imageId]);
|
145 |
+
}, []);
|
146 |
+
|
147 |
+
return { addToQueue };
|
148 |
+
}
|
src/types/encoders.ts
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
export interface AvifEncodeOptions {
|
2 |
+
quality?: number;
|
3 |
+
effort?: number;
|
4 |
+
}
|
5 |
+
|
6 |
+
export interface JpegEncodeOptions {
|
7 |
+
quality?: number;
|
8 |
+
}
|
9 |
+
|
10 |
+
export interface JxlEncodeOptions {
|
11 |
+
quality?: number;
|
12 |
+
}
|
13 |
+
|
14 |
+
export interface WebpEncodeOptions {
|
15 |
+
quality?: number;
|
16 |
+
}
|
src/utils/canvas.ts
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// Canvas utility functions
|
2 |
+
export function createCanvas(width: number, height: number): HTMLCanvasElement {
|
3 |
+
const canvas = document.createElement('canvas');
|
4 |
+
canvas.width = width;
|
5 |
+
canvas.height = height;
|
6 |
+
return canvas;
|
7 |
+
}
|
8 |
+
|
9 |
+
export function imageDataToCanvas(imageData: ImageData): HTMLCanvasElement {
|
10 |
+
const canvas = createCanvas(imageData.width, imageData.height);
|
11 |
+
const ctx = canvas.getContext('2d')!;
|
12 |
+
ctx.putImageData(imageData, 0, 0);
|
13 |
+
return canvas;
|
14 |
+
}
|
15 |
+
|
16 |
+
export function canvasToImageData(canvas: HTMLCanvasElement): ImageData {
|
17 |
+
const ctx = canvas.getContext('2d')!;
|
18 |
+
return ctx.getImageData(0, 0, canvas.width, canvas.height);
|
19 |
+
}
|
src/utils/download.ts
ADDED
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { ImageFile } from '../types';
|
2 |
+
|
3 |
+
export function downloadImage(image: ImageFile) {
|
4 |
+
if (!image.blob || !image.outputType) return;
|
5 |
+
|
6 |
+
const link = document.createElement('a');
|
7 |
+
link.href = URL.createObjectURL(image.blob);
|
8 |
+
link.download = `${image.file.name.split('.')[0]}.${image.outputType}`;
|
9 |
+
link.click();
|
10 |
+
URL.revokeObjectURL(link.href);
|
11 |
+
}
|
12 |
+
|
13 |
+
export function downloadAllImages(images: ImageFile[]) {
|
14 |
+
images
|
15 |
+
.filter(image => image.status === 'complete' && image.blob)
|
16 |
+
.forEach(downloadImage);
|
17 |
+
}
|
src/utils/formatDefaults.ts
ADDED
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { FormatQualitySettings } from '../types';
|
2 |
+
|
3 |
+
export const DEFAULT_QUALITY_SETTINGS: FormatQualitySettings = {
|
4 |
+
avif: 50,
|
5 |
+
jpeg: 75,
|
6 |
+
jxl: 75,
|
7 |
+
webp: 75,
|
8 |
+
};
|
9 |
+
|
10 |
+
export function getDefaultQualityForFormat(format: keyof FormatQualitySettings): number {
|
11 |
+
return DEFAULT_QUALITY_SETTINGS[format];
|
12 |
+
}
|
src/utils/imageProcessing.ts
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as avif from '@jsquash/avif';
|
2 |
+
import * as jpeg from '@jsquash/jpeg';
|
3 |
+
import * as jxl from '@jsquash/jxl';
|
4 |
+
import * as png from '@jsquash/png';
|
5 |
+
import * as webp from '@jsquash/webp';
|
6 |
+
import type { OutputType, CompressionOptions } from '../types';
|
7 |
+
import type { AvifEncodeOptions, JpegEncodeOptions, JxlEncodeOptions, WebpEncodeOptions } from '../types/encoders';
|
8 |
+
import { ensureWasmLoaded } from './wasm';
|
9 |
+
|
10 |
+
export async function decode(sourceType: string, fileBuffer: ArrayBuffer): Promise<ImageData> {
|
11 |
+
// Ensure WASM is loaded for the source type
|
12 |
+
await ensureWasmLoaded(sourceType as OutputType);
|
13 |
+
|
14 |
+
try {
|
15 |
+
switch (sourceType) {
|
16 |
+
case 'avif':
|
17 |
+
return await avif.decode(fileBuffer);
|
18 |
+
case 'jpeg':
|
19 |
+
case 'jpg':
|
20 |
+
return await jpeg.decode(fileBuffer);
|
21 |
+
case 'jxl':
|
22 |
+
return await jxl.decode(fileBuffer);
|
23 |
+
case 'png':
|
24 |
+
return await png.decode(fileBuffer);
|
25 |
+
case 'webp':
|
26 |
+
return await webp.decode(fileBuffer);
|
27 |
+
default:
|
28 |
+
throw new Error(`Unsupported source type: ${sourceType}`);
|
29 |
+
}
|
30 |
+
} catch (error) {
|
31 |
+
console.error(`Failed to decode ${sourceType} image:`, error);
|
32 |
+
throw new Error(`Failed to decode ${sourceType} image`);
|
33 |
+
}
|
34 |
+
}
|
35 |
+
|
36 |
+
export async function encode(outputType: OutputType, imageData: ImageData, options: CompressionOptions): Promise<ArrayBuffer> {
|
37 |
+
// Ensure WASM is loaded for the output type
|
38 |
+
await ensureWasmLoaded(outputType);
|
39 |
+
|
40 |
+
try {
|
41 |
+
switch (outputType) {
|
42 |
+
case 'avif': {
|
43 |
+
const avifOptions: AvifEncodeOptions = {
|
44 |
+
quality: options.quality,
|
45 |
+
effort: 4 // Medium encoding effort
|
46 |
+
};
|
47 |
+
return await avif.encode(imageData, avifOptions as any);
|
48 |
+
}
|
49 |
+
case 'jpeg': {
|
50 |
+
const jpegOptions: JpegEncodeOptions = {
|
51 |
+
quality: options.quality
|
52 |
+
};
|
53 |
+
return await jpeg.encode(imageData, jpegOptions as any);
|
54 |
+
}
|
55 |
+
case 'jxl': {
|
56 |
+
const jxlOptions: JxlEncodeOptions = {
|
57 |
+
quality: options.quality
|
58 |
+
};
|
59 |
+
return await jxl.encode(imageData, jxlOptions as any);
|
60 |
+
}
|
61 |
+
case 'png':
|
62 |
+
return await png.encode(imageData);
|
63 |
+
case 'webp': {
|
64 |
+
const webpOptions: WebpEncodeOptions = {
|
65 |
+
quality: options.quality
|
66 |
+
};
|
67 |
+
return await webp.encode(imageData, webpOptions as any);
|
68 |
+
}
|
69 |
+
default:
|
70 |
+
throw new Error(`Unsupported output type: ${outputType}`);
|
71 |
+
}
|
72 |
+
} catch (error) {
|
73 |
+
console.error(`Failed to encode to ${outputType}:`, error);
|
74 |
+
throw new Error(`Failed to encode to ${outputType}`);
|
75 |
+
}
|
76 |
+
}
|
77 |
+
|
78 |
+
export function getFileType(file: File): string {
|
79 |
+
if (file.name.toLowerCase().endsWith('jxl')) return 'jxl';
|
80 |
+
const type = file.type.split('/')[1];
|
81 |
+
return type === 'jpeg' ? 'jpg' : type;
|
82 |
+
}
|
83 |
+
|
84 |
+
export function formatFileSize(bytes: number): string {
|
85 |
+
if (bytes === 0) return '0 B';
|
86 |
+
const k = 1024;
|
87 |
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
88 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
89 |
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
90 |
+
}
|
src/utils/resize.ts
ADDED
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { createCanvas, imageDataToCanvas } from './canvas';
|
2 |
+
|
3 |
+
interface ResizeOptions {
|
4 |
+
width?: number;
|
5 |
+
height?: number;
|
6 |
+
maintainAspectRatio?: boolean;
|
7 |
+
}
|
8 |
+
|
9 |
+
export function calculateDimensions(
|
10 |
+
originalWidth: number,
|
11 |
+
originalHeight: number,
|
12 |
+
options: ResizeOptions
|
13 |
+
): { width: number; height: number } {
|
14 |
+
const { width: targetWidth = 0, height: targetHeight = 0, maintainAspectRatio = true } = options;
|
15 |
+
|
16 |
+
if (!targetWidth && !targetHeight) {
|
17 |
+
return { width: originalWidth, height: originalHeight };
|
18 |
+
}
|
19 |
+
|
20 |
+
let finalWidth = targetWidth || originalWidth;
|
21 |
+
let finalHeight = targetHeight || originalHeight;
|
22 |
+
|
23 |
+
if (maintainAspectRatio) {
|
24 |
+
const aspectRatio = originalWidth / originalHeight;
|
25 |
+
|
26 |
+
if (targetWidth && !targetHeight) {
|
27 |
+
finalWidth = targetWidth;
|
28 |
+
finalHeight = Math.round(targetWidth / aspectRatio);
|
29 |
+
} else if (!targetWidth && targetHeight) {
|
30 |
+
finalHeight = targetHeight;
|
31 |
+
finalWidth = Math.round(targetHeight * aspectRatio);
|
32 |
+
} else {
|
33 |
+
const widthRatio = targetWidth / originalWidth;
|
34 |
+
const heightRatio = targetHeight / originalHeight;
|
35 |
+
const ratio = Math.min(widthRatio, heightRatio);
|
36 |
+
|
37 |
+
finalWidth = Math.round(originalWidth * ratio);
|
38 |
+
finalHeight = Math.round(originalHeight * ratio);
|
39 |
+
}
|
40 |
+
}
|
41 |
+
|
42 |
+
return {
|
43 |
+
width: Math.max(1, finalWidth),
|
44 |
+
height: Math.max(1, finalHeight),
|
45 |
+
};
|
46 |
+
}
|
47 |
+
|
48 |
+
export function resizeImage(imageData: ImageData, options: ResizeOptions): ImageData {
|
49 |
+
const sourceCanvas = imageDataToCanvas(imageData);
|
50 |
+
|
51 |
+
const { width, height } = calculateDimensions(
|
52 |
+
imageData.width,
|
53 |
+
imageData.height,
|
54 |
+
options
|
55 |
+
);
|
56 |
+
|
57 |
+
const destCanvas = createCanvas(width, height);
|
58 |
+
const ctx = destCanvas.getContext('2d')!;
|
59 |
+
|
60 |
+
// Use better image scaling algorithm
|
61 |
+
ctx.imageSmoothingEnabled = true;
|
62 |
+
ctx.imageSmoothingQuality = 'high';
|
63 |
+
|
64 |
+
ctx.drawImage(sourceCanvas, 0, 0, width, height);
|
65 |
+
|
66 |
+
return ctx.getImageData(0, 0, width, height);
|
67 |
+
}
|
src/utils/wasm.ts
ADDED
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import type { OutputType } from '../types';
|
2 |
+
|
3 |
+
// Track WASM module initialization
|
4 |
+
const wasmInitialized = new Map<OutputType, boolean>();
|
5 |
+
|
6 |
+
export async function ensureWasmLoaded(format: OutputType): Promise<void> {
|
7 |
+
if (wasmInitialized.get(format)) return;
|
8 |
+
|
9 |
+
try {
|
10 |
+
switch (format) {
|
11 |
+
case 'avif':
|
12 |
+
await import('@jsquash/avif');
|
13 |
+
break;
|
14 |
+
case 'jpeg':
|
15 |
+
await import('@jsquash/jpeg');
|
16 |
+
break;
|
17 |
+
case 'jxl':
|
18 |
+
await import('@jsquash/jxl');
|
19 |
+
break;
|
20 |
+
case 'png':
|
21 |
+
await import('@jsquash/png');
|
22 |
+
break;
|
23 |
+
case 'webp':
|
24 |
+
await import('@jsquash/webp');
|
25 |
+
break;
|
26 |
+
}
|
27 |
+
wasmInitialized.set(format, true);
|
28 |
+
} catch (error) {
|
29 |
+
console.error(`Failed to initialize WASM for ${format}:`, error);
|
30 |
+
throw new Error(`Failed to initialize ${format} support`);
|
31 |
+
}
|
32 |
+
}
|
tailwind.config.js
ADDED
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/** @type {import('tailwindcss').Config} */
|
2 |
+
export default {
|
3 |
+
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
4 |
+
theme: {
|
5 |
+
extend: {},
|
6 |
+
},
|
7 |
+
plugins: [],
|
8 |
+
};
|
tsconfig.app.json
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"target": "ES2020",
|
4 |
+
"useDefineForClassFields": true,
|
5 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
6 |
+
"module": "ESNext",
|
7 |
+
"skipLibCheck": true,
|
8 |
+
|
9 |
+
/* Bundler mode */
|
10 |
+
"moduleResolution": "bundler",
|
11 |
+
"allowImportingTsExtensions": true,
|
12 |
+
"isolatedModules": true,
|
13 |
+
"moduleDetection": "force",
|
14 |
+
"noEmit": true,
|
15 |
+
"jsx": "react-jsx",
|
16 |
+
|
17 |
+
/* Linting */
|
18 |
+
"strict": true,
|
19 |
+
"noUnusedLocals": true,
|
20 |
+
"noUnusedParameters": true,
|
21 |
+
"noFallthroughCasesInSwitch": true
|
22 |
+
},
|
23 |
+
"include": ["src"]
|
24 |
+
}
|
tsconfig.json
ADDED
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"files": [],
|
3 |
+
"references": [
|
4 |
+
{ "path": "./tsconfig.app.json" },
|
5 |
+
{ "path": "./tsconfig.node.json" }
|
6 |
+
]
|
7 |
+
}
|
tsconfig.node.json
ADDED
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"compilerOptions": {
|
3 |
+
"target": "ES2022",
|
4 |
+
"lib": ["ES2023"],
|
5 |
+
"module": "ESNext",
|
6 |
+
"skipLibCheck": true,
|
7 |
+
|
8 |
+
/* Bundler mode */
|
9 |
+
"moduleResolution": "bundler",
|
10 |
+
"allowImportingTsExtensions": true,
|
11 |
+
"isolatedModules": true,
|
12 |
+
"moduleDetection": "force",
|
13 |
+
"noEmit": true,
|
14 |
+
|
15 |
+
/* Linting */
|
16 |
+
"strict": true,
|
17 |
+
"noUnusedLocals": true,
|
18 |
+
"noUnusedParameters": true,
|
19 |
+
"noFallthroughCasesInSwitch": true
|
20 |
+
},
|
21 |
+
"include": ["vite.config.ts"]
|
22 |
+
}
|
vite.config.ts
ADDED
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { defineConfig } from 'vite';
|
2 |
+
import react from '@vitejs/plugin-react';
|
3 |
+
|
4 |
+
export default defineConfig({
|
5 |
+
plugins: [react()],
|
6 |
+
optimizeDeps: {
|
7 |
+
exclude: [
|
8 |
+
'@jsquash/avif',
|
9 |
+
'@jsquash/jpeg',
|
10 |
+
'@jsquash/jxl',
|
11 |
+
'@jsquash/png',
|
12 |
+
'@jsquash/webp',
|
13 |
+
],
|
14 |
+
},
|
15 |
+
build: {
|
16 |
+
target: 'esnext',
|
17 |
+
rollupOptions: {
|
18 |
+
output: {
|
19 |
+
format: 'es',
|
20 |
+
inlineDynamicImports: true
|
21 |
+
}
|
22 |
+
}
|
23 |
+
},
|
24 |
+
worker: {
|
25 |
+
format: 'es'
|
26 |
+
}
|
27 |
+
});
|