feat: 实现图片压缩功能

This commit is contained in:
2026-01-25 22:37:40 +08:00
parent 081e2058bf
commit 54009163b1
18 changed files with 1603 additions and 167 deletions

78
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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 (
<div className="p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
<div className="mb-8">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
@@ -229,9 +325,11 @@ export default function ImageCompressPage() {
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">{getT("tools.imageCompression.features")}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{(getT("tools.imageCompression.featureList") as unknown as string[]).map((feature, index) => (
<li key={index}> {feature}</li>
))}
{(getT("tools.imageCompression.featureList") as unknown as string[]).map(
(feature, index) => (
<li key={index}> {feature}</li>
)
)}
</ul>
</div>
</div>

View File

@@ -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 }
);
}
}

View File

@@ -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 }
);
}

View File

@@ -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(),
});
}

View File

@@ -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 (
<Card className={className}>

View File

@@ -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[]) => {

View File

@@ -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<HTMLDivElement>(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 (
<div
ref={containerRef}
className={cn(
"relative overflow-hidden rounded-lg bg-muted/50 select-none",
isDragging && "cursor-col-resize",
className
)}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{/* Original Image (Background - Left Side) */}
<div className="relative w-full">
<img
src={originalSrc}
alt={texts.original}
className="w-full h-auto object-contain max-h-[60vh] block"
draggable={false}
/>
</div>
{/* Compressed Image (Foreground - Right Side with Clipping) */}
<div
className="absolute inset-0 overflow-hidden pointer-events-none"
style={{ clipPath: `inset(0 0 0 ${sliderPosition}%)` }}
>
<img
src={compressedSrc}
alt={texts.compressed}
className="w-full h-full object-contain max-h-[60vh]"
draggable={false}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
}}
/>
</div>
{/* Slider Handle */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-white cursor-col-resize pointer-events-none"
style={{ left: `${sliderPosition}%` }}
>
{/* Handle Button */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white shadow-lg flex items-center justify-center pointer-events-auto">
<div className="flex items-center gap-0.5">
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 2L8 6L4 10"
stroke="#1a1a1a"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 2L4 6L8 10"
stroke="#1a1a1a"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
</div>
{/* Labels */}
<div className="absolute top-3 left-3 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-black/60 backdrop-blur-sm text-white text-xs font-medium">
<Eye className="h-3.5 w-3.5" />
<span>{texts.original}</span>
{originalSize && <span className="text-white/70">({formatFileSize(originalSize)})</span>}
</div>
<div className="absolute top-3 right-3 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-primary/80 backdrop-blur-sm text-primary-foreground text-xs font-medium">
<EyeOff className="h-3.5 w-3.5" />
<span>{texts.compressed}</span>
{compressedSize && (
<span className="text-primary-foreground/80">({formatFileSize(compressedSize)})</span>
)}
</div>
{/* Hint text */}
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full bg-black/50 backdrop-blur-sm text-white/70 text-xs">
{texts.dragHint}
</div>
</div>
);
}

View File

@@ -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];

View File

@@ -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<ProcessedFile | null>(null);
const [originalImageUrl, setOriginalImageUrl] = useState<string | null>(null);
const objectUrlRef = useRef<string | null>(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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={className}
>
<div className="mb-4">
<h3 className="text-lg font-semibold">{t("results.processingComplete")}</h3>
<p className="text-sm text-muted-foreground">
{t("results.filesReady", {
count: results.length,
file: plural("results.file", results.length),
})}
</p>
</div>
<>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className={className}
>
<div className="mb-4">
<h3 className="text-lg font-semibold">{t("results.processingComplete")}</h3>
<p className="text-sm text-muted-foreground">
{t("results.filesReady", {
count: results.length,
file: plural("results.file", results.length),
})}
</p>
</div>
<div className="space-y-3">
{results.map((result, index) => {
const Icon = getFileIcon(result.originalFile.file.type);
const badges = getMetadataBadge(result);
<div className="space-y-3">
{results.map((result, index) => {
const Icon = getFileIcon(result.originalFile.file.type);
const badges = getMetadataBadge(result);
const showPreview = isImageFile(result);
return (
<motion.div
key={result.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card>
<CardContent className="flex items-center gap-4 p-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
return (
<motion.div
key={result.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<Card className="group overflow-hidden">
<CardContent className="flex items-center gap-4 p-4">
{/* Thumbnail preview for images */}
{showPreview && (
<button
onClick={() => handlePreview(result)}
className="relative shrink-0 h-12 w-12 rounded-lg overflow-hidden bg-muted/50 hover:ring-2 hover:ring-primary/50 transition-all cursor-zoom-in"
>
<img
src={result.processedUrl}
alt={result.originalFile.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-center justify-center opacity-0 group-hover:opacity-100">
<Eye className="h-5 w-5 text-white" />
</div>
</button>
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{result.originalFile.name}
</p>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{formatFileSize(result.originalFile.size)}
</span>
{result.metadata.resolution && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
{result.metadata.resolution}
</span>
</>
)}
</div>
{badges.length > 0 && (
<div className="mt-2 flex gap-2">
{badges.map((badge, idx) => (
<Badge key={idx} variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
))}
{!showPreview && (
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<Icon className="h-6 w-6 text-primary" />
</div>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="icon"
onClick={() => onDownload(result.id)}
title={t("common.download")}
>
<Download className="h-4 w-4" />
</Button>
{onShare && (
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{result.originalFile.name}
</p>
<div className="mt-1 flex items-center gap-2">
{result.metadata.originalSize && result.metadata.compressedSize ? (
<>
<span className="text-xs text-muted-foreground line-through">
{formatFileSize(result.metadata.originalSize)}
</span>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-foreground font-medium">
{formatFileSize(result.metadata.compressedSize)}
</span>
</>
) : (
<span className="text-xs text-muted-foreground">
{formatFileSize(result.originalFile.size)}
</span>
)}
{result.metadata.resolution && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
{result.metadata.resolution}
</span>
</>
)}
{result.metadata.quality && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">
Q: {result.metadata.quality}%
</span>
</>
)}
</div>
{badges.length > 0 && (
<div className="mt-2 flex gap-2">
{badges.map((badge, idx) => (
<Badge key={idx} variant={badge.variant} className="text-xs">
{badge.label}
</Badge>
))}
</div>
)}
</div>
<div className="flex gap-2">
{showPreview && (
<Button
variant="ghost"
size="icon"
onClick={() => handlePreview(result)}
title={t("preview.title")}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button
variant="outline"
size="icon"
onClick={() => onShare(result.id)}
title={t("common.share")}
onClick={() => onDownload(result.id)}
title={t("common.download")}
>
<Share2 className="h-4 w-4" />
<Download className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
{onShare && (
<Button
variant="outline"
size="icon"
onClick={() => onShare(result.id)}
title={t("common.share")}
>
<Share2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
</motion.div>
);
})}
</div>
</motion.div>
{/* Preview Dialog */}
<AnimatePresence>
{previewFile && originalImageUrl && (
<Dialog open={!!previewFile} onOpenChange={handleClosePreview}>
<DialogContent className="p-0 gap-0 max-w-5xl w-[95vw] bg-background/95 backdrop-blur-xl border-border/40">
<DialogHeader className="p-4 pb-0">
<DialogTitle className="text-base">{t("preview.title")}</DialogTitle>
</DialogHeader>
<div className="p-4 pt-2">
<ImageCompareSlider
originalSrc={originalImageUrl}
compressedSrc={previewFile.processedUrl}
originalSize={previewFile.metadata.originalSize}
compressedSize={previewFile.metadata.compressedSize}
texts={previewTexts}
/>
</div>
<div className="p-4 pt-0 flex items-center justify-between text-xs text-muted-foreground">
<span>
{t("preview.filename")}: {previewFile.originalFile.name}
</span>
<span>
{previewFile.metadata.compressionRatio && (
<>{t("results.saved", { ratio: previewFile.metadata.compressionRatio })}</>
)}
</span>
</div>
</DialogContent>
</Dialog>
)}
</AnimatePresence>
</>
);
}

View File

@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-4xl translate-x-[-50%] translate-y-[-50%] gap-4 border border-border/40 bg-background/95 p-6 shadow-lg backdrop-blur-md duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-5 w-5" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

305
src/lib/file-storage.ts Normal file
View File

@@ -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<string, NodeJS.Timeout>();
/**
* Initialize temporary directories
*/
export async function initFileStorage(): Promise<void> {
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<ArrayBuffer> }
): 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<Buffer | null> {
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<string, string> = {
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<void> {
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;
}

View File

@@ -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 {

289
src/lib/image-processor.ts Normal file
View File

@@ -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<ImageMetadata> {
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<boolean> {
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<ProcessedImageResult> {
// 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<Array<{ result: ProcessedImageResult; name: string }>> {
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 };
}

View File

@@ -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.",

View File

@@ -268,6 +268,13 @@
"file_other": "文件",
"saved": "节省 {{ratio}}%"
},
"preview": {
"title": "图片对比",
"original": "原图",
"compressed": "压缩后",
"dragHint": "拖动滑块或点击来对比",
"filename": "文件名"
},
"footer": {
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",
"note": "灵感来自现代产品网站的信息密度与克制动效,但以你自己的产品为中心。",

View File

@@ -35,6 +35,9 @@ export interface ProcessMetadata {
duration?: number;
frames?: number;
compressionRatio?: number;
originalSize?: number;
compressedSize?: number;
filename?: string;
}
export interface ProcessingResult {