chriswu25 commited on
Commit
2bc6d22
·
1 Parent(s): 1322628

Add squish app source, Dockerfile and HF config

Browse files
.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: Pic
3
- emoji: 🐨
4
- colorFrom: pink
5
  colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
- license: apache-2.0
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
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
+ });