diff --git a/package-lock.json b/package-lock.json
index 2565a3a..3cb91d8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slider": "^1.3.6",
@@ -1252,6 +1253,83 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog": {
+ "version": "1.1.15",
+ "resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
+ "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-dismissable-layer": "1.1.11",
+ "@radix-ui/react-focus-guards": "1.1.3",
+ "@radix-ui/react-focus-scope": "1.1.7",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-portal": "1.1.9",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-slot": "1.2.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "aria-hidden": "^1.2.4",
+ "react-remove-scroll": "^2.6.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.2.3",
+ "resolved": "https://mirrors.tencent.com/npm/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+ "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-direction": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
diff --git a/package.json b/package.json
index e66d306..bf413df 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1",
+ "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slider": "^1.3.6",
diff --git a/src/app/(dashboard)/tools/image-compress/page.tsx b/src/app/(dashboard)/tools/image-compress/page.tsx
index ed1ac71..513f10c 100644
--- a/src/app/(dashboard)/tools/image-compress/page.tsx
+++ b/src/app/(dashboard)/tools/image-compress/page.tsx
@@ -14,7 +14,7 @@ import { useTranslation, getServerTranslations } from "@/lib/i18n";
import type { UploadedFile, ProcessedFile, ImageCompressConfig } from "@/types";
const imageAccept = {
- "image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
+ "image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff"],
};
const defaultConfig: ImageCompressConfig = {
@@ -52,6 +52,51 @@ function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => st
];
}
+/**
+ * Upload a file to the server
+ */
+async function uploadFile(file: File): Promise<{ fileId: string } | null> {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const response = await fetch("/api/upload", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.error || "Upload failed");
+ }
+
+ const data = await response.json();
+ return { fileId: data.fileId };
+}
+
+/**
+ * Process image compression
+ */
+async function processImageCompression(
+ fileId: string,
+ config: ImageCompressConfig
+): Promise<{ success: boolean; data?: any; error?: string }> {
+ const response = await fetch("/api/process/image-compress", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ fileId, config }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return { success: false, error: data.error || "Processing failed" };
+ }
+
+ return { success: true, data };
+}
+
export default function ImageCompressPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
@@ -101,54 +146,99 @@ export default function ImageCompressPage() {
message: getT("processing.uploadingImages"),
});
+ const results: ProcessedFile[] = [];
+ const errors: string[] = [];
+
try {
- // Simulate upload
- for (let i = 0; i <= 100; i += 10) {
- await new Promise((resolve) => setTimeout(resolve, 50));
+ // Process each file
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+
+ // Update progress
+ const uploadProgress = Math.round(((i + 0.5) / files.length) * 50);
setProcessingStatus({
status: "uploading",
- progress: i,
- message: getT("processing.uploadProgress", { progress: i }),
+ progress: uploadProgress,
+ message: getT("processing.uploadProgress", { progress: uploadProgress }),
});
- }
- setProcessingStatus({
- status: "processing",
- progress: 0,
- message: getT("processing.compressingImages"),
- });
+ // Upload file
+ let fileId: string;
+ try {
+ const uploadResult = await uploadFile(file.file);
+ if (!uploadResult) {
+ throw new Error("Upload failed");
+ }
+ fileId = uploadResult.fileId;
+ } catch (error) {
+ errors.push(`${file.name}: ${error instanceof Error ? error.message : "Upload failed"}`);
+ continue;
+ }
- // Simulate processing
- for (let i = 0; i <= 100; i += 5) {
- await new Promise((resolve) => setTimeout(resolve, 100));
+ // Update progress to processing
+ const processProgress = 50 + Math.round(((i + 0.5) / files.length) * 50);
setProcessingStatus({
status: "processing",
- progress: i,
- message: getT("processing.compressProgress", { progress: i }),
+ progress: processProgress,
+ message: getT("processing.compressProgress", { progress: processProgress }),
});
+
+ // Process image
+ try {
+ const result = await processImageCompression(fileId, config);
+
+ if (result.success && result.data) {
+ results.push({
+ id: generateId(),
+ originalFile: file,
+ processedUrl: result.data.fileUrl,
+ metadata: {
+ format: result.data.metadata.format,
+ quality: result.data.metadata.quality,
+ compressionRatio: result.data.metadata.compressionRatio,
+ resolution: `${result.data.metadata.width}x${result.data.metadata.height}`,
+ originalSize: result.data.metadata.originalSize,
+ compressedSize: result.data.metadata.compressedSize,
+ },
+ createdAt: new Date(),
+ });
+ } else {
+ errors.push(
+ `${file.name}: ${result.error || getT("processing.unknownError")}`
+ );
+ }
+ } catch (error) {
+ errors.push(
+ `${file.name}: ${error instanceof Error ? error.message : "Processing failed"}`
+ );
+ }
}
- // Simulate completion
- const results: ProcessedFile[] = files.map((file) => ({
- id: generateId(),
- originalFile: file,
- processedUrl: "#",
- metadata: {
- format: config.format === "original" ? file.file.type.split("/")[1] : config.format,
- quality: config.quality,
- compressionRatio: Math.floor(Math.random() * 30) + 40, // Simulated 40-70%
- },
- createdAt: new Date(),
- }));
-
- setProcessedFiles(results);
+ // Clear uploaded files
clearFiles();
- setProcessingStatus({
- status: "completed",
- progress: 100,
- message: getT("processing.compressionComplete"),
- });
+ // Set final status
+ if (results.length > 0) {
+ setProcessedFiles(results);
+ setProcessingStatus({
+ status: "completed",
+ progress: 100,
+ message: getT("processing.compressionComplete"),
+ });
+ } else if (errors.length > 0) {
+ setProcessingStatus({
+ status: "failed",
+ progress: 0,
+ message: errors[0],
+ error: errors.join("; "),
+ });
+ } else {
+ setProcessingStatus({
+ status: "failed",
+ progress: 0,
+ message: getT("processing.compressionFailed"),
+ });
+ }
} catch (error) {
setProcessingStatus({
status: "failed",
@@ -160,7 +250,16 @@ export default function ImageCompressPage() {
};
const handleDownload = (fileId: string) => {
- console.log("Downloading file:", fileId);
+ const file = processedFiles.find((f) => f.id === fileId);
+ if (file) {
+ // Create a temporary link to trigger download
+ const link = document.createElement("a");
+ link.href = file.processedUrl;
+ link.download = file.metadata.filename || `compressed-${file.originalFile.name}`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
};
const canProcess = files.length > 0 && processingStatus.status !== "processing";
@@ -168,10 +267,7 @@ export default function ImageCompressPage() {
return (
-
+
@@ -229,9 +325,11 @@ export default function ImageCompressPage() {
{getT("tools.imageCompression.features")}
- {(getT("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => (
- - • {feature}
- ))}
+ {(getT("tools.imageCompression.featureList") as unknown as string[]).map(
+ (feature, index) => (
+ - • {feature}
+ )
+ )}
diff --git a/src/app/api/download/[id]/route.ts b/src/app/api/download/[id]/route.ts
new file mode 100644
index 0000000..7d41769
--- /dev/null
+++ b/src/app/api/download/[id]/route.ts
@@ -0,0 +1,72 @@
+import { NextRequest, NextResponse } from "next/server";
+import { sanitizeFilename } from "@/lib/file-storage";
+import { getProcessedFile } from "@/lib/file-storage";
+
+export const runtime = "nodejs";
+
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params;
+
+ // Validate ID format (UUID-like)
+ if (!id || typeof id !== "string") {
+ return NextResponse.json(
+ { success: false, error: "Invalid download ID" },
+ { status: 400 }
+ );
+ }
+
+ // Sanitize ID to prevent path traversal
+ const sanitizedId = sanitizeFilename(id);
+ if (sanitizedId !== id) {
+ return NextResponse.json(
+ { success: false, error: "Invalid download ID" },
+ { status: 400 }
+ );
+ }
+
+ // Basic UUID format validation
+ const uuidRegex =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+ if (!uuidRegex.test(id)) {
+ return NextResponse.json(
+ { success: false, error: "Invalid download ID format" },
+ { status: 400 }
+ );
+ }
+
+ // Get processed file
+ const fileData = await getProcessedFile(id);
+
+ if (!fileData || fileData.length === 0) {
+ return NextResponse.json(
+ { success: false, error: "File not found or expired" },
+ { status: 404 }
+ );
+ }
+
+ const { buffer, filename, contentType } = fileData[0];
+
+ // Create response with file
+ const response = new NextResponse(buffer as unknown as BodyInit, {
+ status: 200,
+ headers: {
+ "Content-Type": contentType,
+ "Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
+ "Content-Length": buffer.length.toString(),
+ "Cache-Control": "private, max-age=3600",
+ },
+ });
+
+ return response;
+ } catch (error) {
+ console.error("Download error:", error);
+ return NextResponse.json(
+ { success: false, error: "Download failed" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/api/process/image-compress/route.ts b/src/app/api/process/image-compress/route.ts
index 08835f3..4152320 100644
--- a/src/app/api/process/image-compress/route.ts
+++ b/src/app/api/process/image-compress/route.ts
@@ -1,57 +1,137 @@
import { NextRequest, NextResponse } from "next/server";
+import { readFile, readdir } from "fs/promises";
+import path from "path";
import type { ImageCompressConfig } from "@/types";
+import {
+ saveProcessedFile,
+ cleanupFile,
+ sanitizeFilename,
+} from "@/lib/file-storage";
+import {
+ compressImage,
+ getImageMetadata,
+ validateImageBuffer,
+ validateCompressConfig,
+} from "@/lib/image-processor";
export const runtime = "nodejs";
+const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
+
interface ProcessRequest {
fileId: string;
config: ImageCompressConfig;
}
+/**
+ * Find file by ID in upload directory
+ */
+async function findUploadedFile(fileId: string): Promise<{ buffer: Buffer; name: string } | null> {
+ try {
+ const files = await readdir(UPLOAD_DIR);
+ const file = files.find((f) => f.startsWith(`${fileId}.`));
+
+ if (!file) {
+ return null;
+ }
+
+ const filePath = path.join(UPLOAD_DIR, file);
+ const buffer = await readFile(filePath);
+
+ // Validate it's actually an image
+ const isValid = await validateImageBuffer(buffer);
+ if (!isValid) {
+ return null;
+ }
+
+ // Extract original name from file (remove UUID prefix)
+ const originalName = file.substring(fileId.length + 1);
+
+ return { buffer, name: originalName };
+ } catch {
+ return null;
+ }
+}
+
export async function POST(request: NextRequest) {
try {
const body: ProcessRequest = await request.json();
const { fileId, config } = body;
- if (!fileId) {
+ // Validate request
+ if (!fileId || typeof fileId !== "string") {
return NextResponse.json(
- { success: false, error: "No file ID provided" },
+ { success: false, error: "Valid file ID is required" },
+ { status: 400 }
+ );
+ }
+
+ // Sanitize file ID to prevent path traversal
+ const sanitizedId = sanitizeFilename(fileId).replace(/\.[^.]+$/, "");
+ if (sanitizedId !== fileId) {
+ return NextResponse.json(
+ { success: false, error: "Invalid file ID" },
{ status: 400 }
);
}
// Validate config
- if (!config.quality || config.quality < 1 || config.quality > 100) {
+ const configValidation = validateCompressConfig(config);
+ if (!configValidation.valid) {
return NextResponse.json(
- { success: false, error: "Invalid quality value" },
+ { success: false, error: configValidation.error },
{ status: 400 }
);
}
- // In production, you would:
- // 1. Retrieve the file from storage
- // 2. Use Sharp to compress the image
- // 3. Apply format conversion if needed
- // 4. Upload to R2/S3
- // 5. Return download URL
+ // Find uploaded file
+ const uploadedFile = await findUploadedFile(fileId);
+ if (!uploadedFile) {
+ return NextResponse.json(
+ { success: false, error: "File not found or expired" },
+ { status: 404 }
+ );
+ }
- // Mock processing for now
- const resultFileId = `processed-${Date.now()}`;
+ // Get original metadata
+ const originalMetadata = await getImageMetadata(uploadedFile.buffer);
+
+ // Process image
+ const result = await compressImage(uploadedFile.buffer, config);
+
+ // Save processed file
+ const outputFormat = config.format === "original" ? originalMetadata.format : config.format;
+ const downloadInfo = await saveProcessedFile(
+ fileId, // Original file ID for tracking
+ result.buffer,
+ outputFormat,
+ uploadedFile.name
+ );
+
+ // Cleanup original file
+ await cleanupFile(fileId);
return NextResponse.json({
success: true,
- fileUrl: `/api/download/${resultFileId}`,
- filename: `compressed-${resultFileId}`,
+ fileUrl: downloadInfo.fileUrl,
+ filename: downloadInfo.filename,
metadata: {
- format: config.format,
+ format: result.format,
quality: config.quality,
- compressionRatio: Math.floor(Math.random() * 30) + 40, // Mock 40-70%
+ compressionRatio: result.compressionRatio,
+ originalSize: result.originalSize,
+ compressedSize: result.compressedSize,
+ width: result.width,
+ height: result.height,
},
});
} catch (error) {
console.error("Processing error:", error);
return NextResponse.json(
- { success: false, error: "Processing failed" },
+ {
+ success: false,
+ error: error instanceof Error ? error.message : "Processing failed",
+ },
{ status: 500 }
);
}
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index fef3283..944d8cf 100644
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -1,4 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
+import {
+ saveUploadedFile,
+ validateImageFile,
+ getAllowedImageTypes,
+ getMaxFileSize,
+} from "@/lib/file-storage";
export const runtime = "nodejs";
@@ -14,31 +20,26 @@ export async function POST(request: NextRequest) {
);
}
- // Check file size
- const maxSize = parseInt(process.env.MAX_FILE_SIZE || "52428800"); // 50MB default
- if (file.size > maxSize) {
+ // Validate file
+ const validation = validateImageFile(file);
+ if (!validation.valid) {
return NextResponse.json(
- { success: false, error: `File size exceeds ${maxSize / 1024 / 1024}MB limit` },
+ { success: false, error: validation.error },
{ status: 400 }
);
}
- // Generate file ID
- const fileId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
-
- // In production, you would:
- // 1. Save to Cloudflare R2 or S3
- // 2. Return the actual URL
- // For now, we'll return a mock response
+ // Save file to temp storage
+ const result = await saveUploadedFile(file);
return NextResponse.json({
success: true,
- fileId,
- fileUrl: `/uploads/${fileId}`,
+ fileId: result.fileId,
+ fileUrl: `/api/file/${result.fileId}`,
metadata: {
- name: file.name,
- size: file.size,
- type: file.type,
+ name: result.originalName,
+ size: result.size,
+ type: result.type,
},
});
} catch (error) {
@@ -49,3 +50,11 @@ export async function POST(request: NextRequest) {
);
}
}
+
+// Return allowed file types and max size for client
+export async function GET() {
+ return NextResponse.json({
+ allowedTypes: getAllowedImageTypes(),
+ maxSize: getMaxFileSize(),
+ });
+}
diff --git a/src/components/tools/ConfigPanel.tsx b/src/components/tools/ConfigPanel.tsx
index c03c6f0..80ce7b9 100644
--- a/src/components/tools/ConfigPanel.tsx
+++ b/src/components/tools/ConfigPanel.tsx
@@ -6,7 +6,7 @@ import { Slider } from "@/components/ui/slider";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
-import { useTranslation } from "@/lib/i18n";
+import { useSafeTranslation } from "@/lib/i18n";
export interface ConfigOption {
id: string;
@@ -39,7 +39,7 @@ export function ConfigPanel({
onReset,
className,
}: ConfigPanelProps) {
- const { t } = useTranslation();
+ const { t } = useSafeTranslation();
return (
diff --git a/src/components/tools/FileUploader.tsx b/src/components/tools/FileUploader.tsx
index 0aff2c6..2987838 100644
--- a/src/components/tools/FileUploader.tsx
+++ b/src/components/tools/FileUploader.tsx
@@ -8,7 +8,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { formatFileSize, getFileExtension } from "@/lib/utils";
-import { useTranslation } from "@/lib/i18n";
+import { useSafeTranslation } from "@/lib/i18n";
import type { UploadedFile } from "@/types";
interface FileUploaderProps {
@@ -36,7 +36,7 @@ export function FileUploader({
maxFiles = 10,
disabled = false,
}: FileUploaderProps) {
- const { t, plural } = useTranslation();
+ const { t, plural } = useSafeTranslation();
const onDrop = useCallback(
(acceptedFiles: File[]) => {
diff --git a/src/components/tools/ImageCompareSlider.tsx b/src/components/tools/ImageCompareSlider.tsx
new file mode 100644
index 0000000..cd782a8
--- /dev/null
+++ b/src/components/tools/ImageCompareSlider.tsx
@@ -0,0 +1,201 @@
+"use client";
+
+import React, { useState, useCallback, useRef, useEffect } from "react";
+import { cn, formatFileSize } from "@/lib/utils";
+import { Eye, EyeOff } from "lucide-react";
+
+interface ImageCompareSliderProps {
+ originalSrc: string;
+ compressedSrc: string;
+ originalSize?: number;
+ compressedSize?: number;
+ className?: string;
+ // Translation texts
+ texts: {
+ original: string;
+ compressed: string;
+ dragHint: string;
+ };
+}
+
+export function ImageCompareSlider({
+ originalSrc,
+ compressedSrc,
+ originalSize,
+ compressedSize,
+ className,
+ texts,
+}: ImageCompareSliderProps) {
+ const [sliderPosition, setSliderPosition] = useState(50);
+ const [isDragging, setIsDragging] = useState(false);
+ const containerRef = useRef(null);
+
+ const updateSliderPosition = useCallback((clientX: number) => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const rect = container.getBoundingClientRect();
+ const x = clientX - rect.left;
+ const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
+ setSliderPosition(percentage);
+ }, []);
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ setIsDragging(true);
+ updateSliderPosition(e.clientX);
+ };
+
+ const handleMouseMove = useCallback(
+ (e: MouseEvent) => {
+ if (isDragging) {
+ e.preventDefault();
+ updateSliderPosition(e.clientX);
+ }
+ },
+ [isDragging, updateSliderPosition]
+ );
+
+ const handleMouseUp = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ const handleTouchStart = (e: React.TouchEvent) => {
+ setIsDragging(true);
+ updateSliderPosition(e.touches[0].clientX);
+ };
+
+ const handleTouchMove = useCallback(
+ (e: TouchEvent) => {
+ if (isDragging) {
+ e.preventDefault();
+ updateSliderPosition(e.touches[0].clientX);
+ }
+ },
+ [isDragging, updateSliderPosition]
+ );
+
+ const handleTouchEnd = useCallback(() => {
+ setIsDragging(false);
+ }, []);
+
+ useEffect(() => {
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ document.addEventListener("touchmove", handleTouchMove, { passive: false });
+ document.addEventListener("touchend", handleTouchEnd);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ document.removeEventListener("touchmove", handleTouchMove);
+ document.removeEventListener("touchend", handleTouchEnd);
+ };
+ }
+ }, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
+
+ return (
+
+ {/* Original Image (Background - Left Side) */}
+
+

+
+
+ {/* Compressed Image (Foreground - Right Side with Clipping) */}
+
+

+
+
+ {/* Slider Handle */}
+
+ {/* Handle Button */}
+
+
+
+ {/* Labels */}
+
+
+ {texts.original}
+ {originalSize && ({formatFileSize(originalSize)})}
+
+
+
+
+ {texts.compressed}
+ {compressedSize && (
+ ({formatFileSize(compressedSize)})
+ )}
+
+
+ {/* Hint text */}
+
+ {texts.dragHint}
+
+
+ );
+}
diff --git a/src/components/tools/ProgressBar.tsx b/src/components/tools/ProgressBar.tsx
index 605d561..17dea25 100644
--- a/src/components/tools/ProgressBar.tsx
+++ b/src/components/tools/ProgressBar.tsx
@@ -5,7 +5,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
-import { useTranslation } from "@/lib/i18n";
+import { useSafeTranslation } from "@/lib/i18n";
import type { ProcessingProgress } from "@/types";
interface ProgressBarProps {
@@ -30,7 +30,7 @@ const statusColors = {
};
export function ProgressBar({ progress, className }: ProgressBarProps) {
- const { t } = useTranslation();
+ const { t } = useSafeTranslation();
const { status, progress: value, message, error } = progress;
const showProgress = status === "uploading" || status === "processing";
const Icon = statusIcons[status];
diff --git a/src/components/tools/ResultPreview.tsx b/src/components/tools/ResultPreview.tsx
index ec523b4..bef0631 100644
--- a/src/components/tools/ResultPreview.tsx
+++ b/src/components/tools/ResultPreview.tsx
@@ -1,12 +1,20 @@
"use client";
-import { motion } from "framer-motion";
-import { Download, Share2, File, Image as ImageIcon, Video, Music } from "lucide-react";
+import { useState, useCallback, useRef, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { Download, Share2, File, Image as ImageIcon, Video, Music, Eye } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ImageCompareSlider } from "@/components/tools/ImageCompareSlider";
import { formatFileSize } from "@/lib/utils";
-import { useTranslation } from "@/lib/i18n";
+import { useSafeTranslation } from "@/lib/i18n";
import type { ProcessedFile } from "@/types";
interface ResultPreviewProps {
@@ -22,7 +30,42 @@ export function ResultPreview({
onShare,
className,
}: ResultPreviewProps) {
- const { t, plural } = useTranslation();
+ const { t, plural } = useSafeTranslation();
+ const [previewFile, setPreviewFile] = useState(null);
+ const [originalImageUrl, setOriginalImageUrl] = useState(null);
+ const objectUrlRef = useRef(null);
+
+ // Cleanup object URL when preview closes or component unmounts
+ useEffect(() => {
+ return () => {
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ }
+ };
+ }, []);
+
+ const handlePreview = useCallback((file: ProcessedFile) => {
+ // Create object URL for original image
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ }
+ const url = URL.createObjectURL(file.originalFile.file);
+ objectUrlRef.current = url;
+ setOriginalImageUrl(url);
+ setPreviewFile(file);
+ }, []);
+
+ const handleClosePreview = useCallback(() => {
+ setPreviewFile(null);
+ // Small delay to allow dialog close animation before revoking
+ setTimeout(() => {
+ if (objectUrlRef.current) {
+ URL.revokeObjectURL(objectUrlRef.current);
+ objectUrlRef.current = null;
+ }
+ setOriginalImageUrl(null);
+ }, 300);
+ }, []);
if (results.length === 0) return null;
@@ -35,113 +78,209 @@ export function ResultPreview({
const getMetadataBadge = (file: ProcessedFile) => {
const metadata = file.metadata;
- const badges = [];
+ const badges: Array<{ label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = [];
if (metadata.compressionRatio) {
badges.push({
label: t("results.saved", { ratio: metadata.compressionRatio }),
- variant: "default" as const,
+ variant: "default",
});
}
if (metadata.format) {
badges.push({
label: metadata.format.toUpperCase(),
- variant: "secondary" as const,
+ variant: "secondary",
});
}
return badges;
};
+ const isImageFile = (file: ProcessedFile) => {
+ return file.originalFile.file.type.startsWith("image/");
+ };
+
+ // Translation texts for ImageCompareSlider
+ const previewTexts = {
+ original: t("preview.original"),
+ compressed: t("preview.compressed"),
+ dragHint: t("preview.dragHint"),
+ };
+
return (
-
-
-
{t("results.processingComplete")}
-
- {t("results.filesReady", {
- count: results.length,
- file: plural("results.file", results.length),
- })}
-
-
+ <>
+
+
+
{t("results.processingComplete")}
+
+ {t("results.filesReady", {
+ count: results.length,
+ file: plural("results.file", results.length),
+ })}
+
+
-
- {results.map((result, index) => {
- const Icon = getFileIcon(result.originalFile.file.type);
- const badges = getMetadataBadge(result);
+
+ {results.map((result, index) => {
+ const Icon = getFileIcon(result.originalFile.file.type);
+ const badges = getMetadataBadge(result);
+ const showPreview = isImageFile(result);
- return (
-
-
-
-
-
-
+ return (
+
+
+
+ {/* Thumbnail preview for images */}
+ {showPreview && (
+
+ )}
-
-
- {result.originalFile.name}
-
-
-
- {formatFileSize(result.originalFile.size)}
-
- {result.metadata.resolution && (
- <>
- •
-
- {result.metadata.resolution}
-
- >
- )}
-
- {badges.length > 0 && (
-
- {badges.map((badge, idx) => (
-
- {badge.label}
-
- ))}
+ {!showPreview && (
+
+
)}
-
-
-
- {onShare && (
+
+
+ {result.originalFile.name}
+
+
+ {result.metadata.originalSize && result.metadata.compressedSize ? (
+ <>
+
+ {formatFileSize(result.metadata.originalSize)}
+
+ →
+
+ {formatFileSize(result.metadata.compressedSize)}
+
+ >
+ ) : (
+
+ {formatFileSize(result.originalFile.size)}
+
+ )}
+ {result.metadata.resolution && (
+ <>
+ •
+
+ {result.metadata.resolution}
+
+ >
+ )}
+ {result.metadata.quality && (
+ <>
+ •
+
+ Q: {result.metadata.quality}%
+
+ >
+ )}
+
+ {badges.length > 0 && (
+
+ {badges.map((badge, idx) => (
+
+ {badge.label}
+
+ ))}
+
+ )}
+
+
+
+ {showPreview && (
+
+ )}
- )}
-
-
-
-
- );
- })}
-
-
+ {onShare && (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+
+ {/* Preview Dialog */}
+
+ {previewFile && originalImageUrl && (
+
+ )}
+
+ >
);
}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..f640609
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+ className,
+ ...props
+}: React.HTMLAttributes) => (
+
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+ Dialog,
+ DialogPortal,
+ DialogOverlay,
+ DialogClose,
+ DialogTrigger,
+ DialogContent,
+ DialogHeader,
+ DialogFooter,
+ DialogTitle,
+ DialogDescription,
+};
diff --git a/src/lib/file-storage.ts b/src/lib/file-storage.ts
new file mode 100644
index 0000000..72b09ca
--- /dev/null
+++ b/src/lib/file-storage.ts
@@ -0,0 +1,305 @@
+import { promises as fs } from "fs";
+import path from "path";
+import { randomUUID } from "crypto";
+
+/**
+ * File storage service for handling temporary files
+ * Files are automatically cleaned up after a specified TTL
+ */
+
+const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
+const DOWNLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "downloads");
+const FILE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds
+
+// Allowed MIME types for image upload
+const ALLOWED_IMAGE_TYPES = [
+ "image/jpeg",
+ "image/jpg",
+ "image/png",
+ "image/webp",
+ "image/gif",
+ "image/bmp",
+ "image/tiff",
+] as const;
+
+// Maximum file size (50MB)
+const MAX_FILE_SIZE = 50 * 1024 * 1024;
+
+// Track cleanup timeout
+const cleanupTimeouts = new Map();
+
+/**
+ * Initialize temporary directories
+ */
+export async function initFileStorage(): Promise {
+ try {
+ await fs.mkdir(UPLOAD_DIR, { recursive: true });
+ await fs.mkdir(DOWNLOAD_DIR, { recursive: true });
+ } catch (error) {
+ console.error("Failed to initialize file storage:", error);
+ throw new Error("File storage initialization failed");
+ }
+}
+
+/**
+ * Validate image file type and size
+ */
+export function validateImageFile(
+ file: File | { name: string; size: number; type: string }
+): { valid: boolean; error?: string } {
+ // Check file size
+ if (file.size > MAX_FILE_SIZE) {
+ return {
+ valid: false,
+ error: `File size exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit`,
+ };
+ }
+
+ // Check file size (minimum 100 bytes to avoid corrupt files)
+ if (file.size < 100) {
+ return {
+ valid: false,
+ error: "File is too small or corrupt",
+ };
+ }
+
+ // Check MIME type
+ if (!file.type || !ALLOWED_IMAGE_TYPES.includes(file.type as any)) {
+ return {
+ valid: false,
+ error: `Invalid file type. Allowed: ${ALLOWED_IMAGE_TYPES.join(", ")}`,
+ };
+ }
+
+ // Check file extension matches MIME type
+ const ext = getFileExtension(file.name).toLowerCase();
+ const validExtensions = ["jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff"];
+
+ if (!validExtensions.includes(ext)) {
+ return {
+ valid: false,
+ error: `Invalid file extension. Allowed: ${validExtensions.join(", ")}`,
+ };
+ }
+
+ return { valid: true };
+}
+
+/**
+ * Get file extension from filename
+ */
+function getFileExtension(filename: string): string {
+ const parts = filename.split(".");
+ return parts.length > 1 ? parts[parts.length - 1] : "";
+}
+
+/**
+ * Sanitize filename to prevent path traversal
+ */
+export function sanitizeFilename(filename: string): string {
+ // Remove any directory separators and special characters
+ const sanitized = filename
+ .replace(/[\\/]/g, "") // Remove path separators
+ .replace(/\.{2,}/g, ".") // Remove double dots
+ .replace(/[^a-zA-Z0-9._-]/g, "_") // Replace special chars
+ .substring(0, 255); // Limit length
+
+ return sanitized || "file";
+}
+
+/**
+ * Save uploaded file to temp storage
+ */
+export async function saveUploadedFile(
+ file: File | { name: string; type: string; arrayBuffer: () => Promise }
+): Promise<{ fileId: string; filePath: string; originalName: string; size: number; type: string }> {
+ await initFileStorage();
+
+ const fileId = randomUUID();
+ const sanitized = sanitizeFilename(file.name);
+ const ext = getFileExtension(sanitized);
+ const filename = `${fileId}.${ext}`;
+ const filePath = path.join(UPLOAD_DIR, filename);
+
+ // Save file
+ const buffer = Buffer.from(await file.arrayBuffer());
+ await fs.writeFile(filePath, buffer);
+
+ // Schedule cleanup
+ scheduleCleanup(fileId, filePath);
+
+ return {
+ fileId,
+ filePath,
+ originalName: file.name,
+ size: buffer.length,
+ type: file.type,
+ };
+}
+
+/**
+ * Save processed file to download directory
+ */
+export async function saveProcessedFile(
+ _fileId: string,
+ buffer: Buffer,
+ format: string,
+ originalName: string
+): Promise<{ fileUrl: string; filename: string; size: number }> {
+ await initFileStorage();
+
+ const downloadId = randomUUID();
+ const sanitized = sanitizeFilename(originalName);
+ const nameWithoutExt = sanitized.replace(/\.[^.]+$/, "");
+ const filename = `${nameWithoutExt}_compressed.${format}`;
+ const filePath = path.join(DOWNLOAD_DIR, `${downloadId}_${filename}`);
+
+ await fs.writeFile(filePath, buffer);
+
+ // Schedule cleanup
+ scheduleCleanup(downloadId, filePath);
+
+ return {
+ fileUrl: `/api/download/${downloadId}`,
+ filename,
+ size: buffer.length,
+ };
+}
+
+/**
+ * Get file from upload directory
+ */
+export async function getUploadedFile(fileId: string): Promise {
+ await initFileStorage();
+
+ try {
+ const files = await fs.readdir(UPLOAD_DIR);
+ const file = files.find((f) => f.startsWith(`${fileId}.`));
+
+ if (!file) {
+ return null;
+ }
+
+ const filePath = path.join(UPLOAD_DIR, file);
+ return await fs.readFile(filePath);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Get processed file for download
+ */
+export async function getProcessedFile(downloadId: string): Promise<
+ | { buffer: Buffer; filename: string; contentType: string }[]
+ | null
+> {
+ await initFileStorage();
+
+ try {
+ const files = await fs.readdir(DOWNLOAD_DIR);
+ const file = files.find((f) => f.startsWith(`${downloadId}_`));
+
+ if (!file) {
+ return null;
+ }
+
+ const filePath = path.join(DOWNLOAD_DIR, file);
+ const buffer = await fs.readFile(filePath);
+
+ // Extract filename
+ const filename = file.substring(downloadId.length + 1);
+
+ // Determine content type
+ const ext = getFileExtension(filename);
+ const contentTypes: Record = {
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ png: "image/png",
+ webp: "image/webp",
+ gif: "image/gif",
+ bmp: "image/bmp",
+ tif: "image/tiff",
+ tiff: "image/tiff",
+ };
+
+ return [
+ {
+ buffer,
+ filename,
+ contentType: contentTypes[ext] || "application/octet-stream",
+ },
+ ];
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Schedule file cleanup
+ */
+function scheduleCleanup(fileId: string, filePath: string): void {
+ // Clear existing timeout if any
+ const existing = cleanupTimeouts.get(fileId);
+ if (existing) {
+ clearTimeout(existing);
+ }
+
+ // Schedule new cleanup
+ const timeout = setTimeout(async () => {
+ try {
+ await fs.unlink(filePath);
+ cleanupTimeouts.delete(fileId);
+ } catch (error) {
+ console.error(`Failed to cleanup file ${fileId}:`, error);
+ }
+ }, FILE_TTL);
+
+ cleanupTimeouts.set(fileId, timeout);
+}
+
+/**
+ * Manually cleanup a file
+ */
+export async function cleanupFile(fileId: string): Promise {
+ const timeout = cleanupTimeouts.get(fileId);
+ if (timeout) {
+ clearTimeout(timeout);
+ cleanupTimeouts.delete(fileId);
+ }
+
+ // Try to delete from both directories
+ try {
+ const files = await fs.readdir(UPLOAD_DIR);
+ const file = files.find((f) => f.startsWith(`${fileId}.`));
+ if (file) {
+ await fs.unlink(path.join(UPLOAD_DIR, file));
+ }
+ } catch {
+ // Ignore
+ }
+
+ try {
+ const files = await fs.readdir(DOWNLOAD_DIR);
+ const file = files.find((f) => f.startsWith(`${fileId}_`));
+ if (file) {
+ await fs.unlink(path.join(DOWNLOAD_DIR, file));
+ }
+ } catch {
+ // Ignore
+ }
+}
+
+/**
+ * Get allowed image types
+ */
+export function getAllowedImageTypes(): readonly string[] {
+ return ALLOWED_IMAGE_TYPES;
+}
+
+/**
+ * Get maximum file size
+ */
+export function getMaxFileSize(): number {
+ return MAX_FILE_SIZE;
+}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
index 4ec26ac..960c646 100644
--- a/src/lib/i18n.ts
+++ b/src/lib/i18n.ts
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
+import { useState, useEffect } from "react";
import en from "@/locales/en.json";
import zh from "@/locales/zh.json";
@@ -70,6 +71,40 @@ export function useTranslation() {
};
}
+/**
+ * SSR-safe translation hook that prevents hydration mismatches.
+ * Use this in client components that are rendered on the server.
+ * Returns a stable translation during SSR and switches to client locale after hydration.
+ */
+export function useSafeTranslation() {
+ const [mounted, setMounted] = useState(false);
+ useEffect(() => setMounted(true), []);
+ const { locale, setLocale, t, plural, locales } = useTranslation();
+
+ // Use English during SSR, client locale after hydration
+ const safeT: typeof t = (key, params) => {
+ if (!mounted) return getServerTranslations("en").t(key, params);
+ return t(key, params);
+ };
+
+ const safePlural: typeof plural = (key, count) => {
+ if (!mounted) {
+ const suffix = count === 1 ? "_one" : "_other";
+ return getServerTranslations("en").t(`${key}${suffix}`, { count });
+ }
+ return plural(key, count);
+ };
+
+ return {
+ locale,
+ setLocale,
+ t: safeT,
+ plural: safePlural,
+ locales,
+ mounted, // Expose mounted state for conditional rendering if needed
+ };
+}
+
// Helper for SSR
export function getServerTranslations(locale: Locale = "en") {
return {
diff --git a/src/lib/image-processor.ts b/src/lib/image-processor.ts
new file mode 100644
index 0000000..680eee4
--- /dev/null
+++ b/src/lib/image-processor.ts
@@ -0,0 +1,289 @@
+import sharp from "sharp";
+import type { ImageCompressConfig } from "@/types";
+
+/**
+ * Image processing service using Sharp
+ * Handles compression, format conversion, and resizing
+ */
+
+export interface ProcessedImageResult {
+ buffer: Buffer;
+ format: string;
+ width: number;
+ height: number;
+ originalSize: number;
+ compressedSize: number;
+ compressionRatio: number;
+}
+
+export interface ImageMetadata {
+ format: string;
+ width: number;
+ height: number;
+ size: number;
+}
+
+// Supported output formats for compression
+const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "gif", "tiff", "tif"] as const;
+type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number];
+
+/**
+ * Get image metadata without loading the full image
+ */
+export async function getImageMetadata(buffer: Buffer): Promise {
+ const metadata = await sharp(buffer).metadata();
+
+ return {
+ format: metadata.format || "unknown",
+ width: metadata.width || 0,
+ height: metadata.height || 0,
+ size: buffer.length,
+ };
+}
+
+/**
+ * Validate image buffer using Sharp
+ * Checks if the buffer contains a valid image
+ */
+export async function validateImageBuffer(buffer: Buffer): Promise {
+ try {
+ const metadata = await sharp(buffer).metadata();
+ return (
+ metadata.format !== undefined &&
+ metadata.width !== undefined &&
+ metadata.width > 0 &&
+ metadata.height !== undefined &&
+ metadata.height > 0
+ );
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if format is supported for output
+ */
+function isSupportedFormat(format: string): format is SupportedFormat {
+ return SUPPORTED_OUTPUT_FORMATS.includes(format as SupportedFormat);
+}
+
+/**
+ * Compress and/or convert image
+ */
+export async function compressImage(
+ buffer: Buffer,
+ config: ImageCompressConfig
+): Promise {
+ // Validate input buffer
+ const isValid = await validateImageBuffer(buffer);
+ if (!isValid) {
+ throw new Error("Invalid image data");
+ }
+
+ // Get original metadata
+ const originalMetadata = await getImageMetadata(buffer);
+
+ // Create Sharp instance
+ let pipeline = sharp(buffer, {
+ // Limit input pixels to prevent DoS attacks
+ limitInputPixels: 268402689, // ~16384x16384
+ // Enforce memory limits
+ unlimited: false,
+ });
+
+ // Apply resizing if configured
+ if (config.resize) {
+ const { width, height, fit } = config.resize;
+
+ if (width || height) {
+ pipeline = pipeline.resize(width || null, height || null, {
+ fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill",
+ // Don't enlarge images
+ withoutEnlargement: fit !== "fill",
+ });
+ }
+ }
+
+ // Determine output format
+ let outputFormat = config.format === "original" ? originalMetadata.format : config.format;
+
+ // For BMP input without format conversion, use JPEG as output
+ // since Sharp doesn't support BMP output
+ if (outputFormat === "bmp") {
+ outputFormat = "jpeg";
+ }
+
+ // Validate format is supported
+ if (!isSupportedFormat(outputFormat)) {
+ outputFormat = "jpeg";
+ }
+
+ // Apply format-specific compression
+ switch (outputFormat) {
+ case "jpeg":
+ case "jpg":
+ pipeline = pipeline.jpeg({
+ quality: config.quality,
+ mozjpeg: true, // Use MozJPEG for better compression
+ progressive: true, // Progressive loading
+ });
+ break;
+
+ case "png":
+ // PNG compression is lossless, quality affects compression level
+ // Map 1-100 to 0-9 compression level (inverted)
+ const compressionLevel = Math.floor(((100 - config.quality) / 100) * 9);
+ pipeline = pipeline.png({
+ compressionLevel,
+ adaptiveFiltering: true,
+ palette: false, // Keep true color
+ });
+ break;
+
+ case "webp":
+ pipeline = pipeline.webp({
+ quality: config.quality,
+ effort: 6, // Compression effort (0-6, 6 is highest)
+ });
+ break;
+
+ case "gif":
+ // GIF doesn't support quality parameter in the same way
+ // We'll use near-lossless for better quality
+ pipeline = pipeline.gif({
+ dither: 1.0,
+ });
+ break;
+
+ case "tiff":
+ case "tif":
+ pipeline = pipeline.tiff({
+ quality: config.quality,
+ compression: "jpeg",
+ });
+ break;
+
+ default:
+ // Default to JPEG
+ pipeline = pipeline.jpeg({
+ quality: config.quality,
+ mozjpeg: true,
+ });
+ }
+
+ // Get metadata before compression
+ const metadata = await pipeline.metadata();
+
+ // Process image
+ const compressedBuffer = await pipeline.toBuffer();
+
+ // Calculate compression ratio
+ const compressionRatio = Math.round(
+ ((buffer.length - compressedBuffer.length) / buffer.length) * 100
+ );
+
+ return {
+ buffer: compressedBuffer,
+ format: outputFormat,
+ width: metadata.width || 0,
+ height: metadata.height || 0,
+ originalSize: buffer.length,
+ compressedSize: compressedBuffer.length,
+ compressionRatio,
+ };
+}
+
+/**
+ * Batch compress multiple images
+ */
+export async function batchCompressImages(
+ files: Array<{ buffer: Buffer; name: string }>,
+ config: ImageCompressConfig,
+ onProgress?: (current: number, total: number) => void
+): Promise> {
+ const results: Array<{ result: ProcessedImageResult; name: string }> = [];
+
+ for (let i = 0; i < files.length; i++) {
+ const result = await compressImage(files[i].buffer, config);
+ results.push({ result, name: files[i].name });
+
+ if (onProgress) {
+ onProgress(i + 1, files.length);
+ }
+ }
+
+ return results;
+}
+
+/**
+ * Calculate recommended quality based on desired compression ratio
+ */
+export function calculateQualityForTargetRatio(
+ targetRatio: number,
+ currentRatio?: number,
+ currentQuality?: number
+): number {
+ // If we have current data, adjust based on difference
+ if (currentRatio !== undefined && currentQuality !== undefined) {
+ const difference = targetRatio - currentRatio;
+ const adjustment = difference * 2; // Adjust by 2x the difference
+ return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment)));
+ }
+
+ // Default heuristic: higher target ratio = lower quality
+ return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5)));
+}
+
+/**
+ * Validate ImageCompressConfig
+ */
+export function validateCompressConfig(config: ImageCompressConfig): {
+ valid: boolean;
+ error?: string;
+} {
+ if (!config.quality || typeof config.quality !== "number") {
+ return { valid: false, error: "Quality is required and must be a number" };
+ }
+
+ if (config.quality < 1 || config.quality > 100) {
+ return { valid: false, error: "Quality must be between 1 and 100" };
+ }
+
+ const validFormats = ["original", "jpeg", "jpg", "png", "webp", "gif", "bmp", "tiff", "tif"];
+ if (!validFormats.includes(config.format)) {
+ return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
+ }
+
+ if (config.resize) {
+ if (config.resize.width !== undefined) {
+ if (
+ typeof config.resize.width !== "number" ||
+ config.resize.width < 1 ||
+ config.resize.width > 16384
+ ) {
+ return { valid: false, error: "Width must be between 1 and 16384" };
+ }
+ }
+
+ if (config.resize.height !== undefined) {
+ if (
+ typeof config.resize.height !== "number" ||
+ config.resize.height < 1 ||
+ config.resize.height > 16384
+ ) {
+ return { valid: false, error: "Height must be between 1 and 16384" };
+ }
+ }
+
+ if (!config.resize.width && !config.resize.height) {
+ return { valid: false, error: "At least one of width or height must be specified" };
+ }
+
+ const validFits = ["contain", "cover", "fill"];
+ if (!validFits.includes(config.resize.fit)) {
+ return { valid: false, error: `Invalid fit. Allowed: ${validFits.join(", ")}` };
+ }
+ }
+
+ return { valid: true };
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index bf559a4..f036f71 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -268,6 +268,13 @@
"file_other": "files",
"saved": "Saved {{ratio}}%"
},
+ "preview": {
+ "title": "Image Comparison",
+ "original": "Original",
+ "compressed": "Compressed",
+ "dragHint": "Drag slider or click to compare",
+ "filename": "Filename"
+ },
"footer": {
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.",
"note": "Inspired by modern product storytelling—centered on your workflow, not UI noise.",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 56225b7..c7efc36 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -268,6 +268,13 @@
"file_other": "文件",
"saved": "节省 {{ratio}}%"
},
+ "preview": {
+ "title": "图片对比",
+ "original": "原图",
+ "compressed": "压缩后",
+ "dragHint": "拖动滑块或点击来对比",
+ "filename": "文件名"
+ },
"footer": {
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",
diff --git a/src/types/index.ts b/src/types/index.ts
index 9c6bdd8..1868adf 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -35,6 +35,9 @@ export interface ProcessMetadata {
duration?: number;
frames?: number;
compressionRatio?: number;
+ originalSize?: number;
+ compressedSize?: number;
+ filename?: string;
}
export interface ProcessingResult {