feat: 实现纹理图集生成功能

添加纹理图集生成工具,支持多图片合并为单个图集并生成坐标数据文件

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-26 21:10:58 +08:00
parent 54009163b1
commit 663917f663
11 changed files with 3041 additions and 114 deletions

871
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,8 @@
"@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.62.11", "@tanstack/react-query": "^5.62.11",
"@types/archiver": "^7.0.0",
"archiver": "^7.0.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",

View File

@@ -19,7 +19,7 @@ const imageAccept = {
const defaultConfig: ImageCompressConfig = { const defaultConfig: ImageCompressConfig = {
quality: 80, quality: 80,
format: "original", format: "auto",
}; };
function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => string): ConfigOption[] { function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => string): ConfigOption[] {
@@ -43,10 +43,12 @@ function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => st
description: getT("config.imageCompression.formatDescription"), description: getT("config.imageCompression.formatDescription"),
value: config.format, value: config.format,
options: [ options: [
{ label: getT("config.imageCompression.formatAuto"), value: "auto" },
{ label: getT("config.imageCompression.formatOriginal"), value: "original" }, { label: getT("config.imageCompression.formatOriginal"), value: "original" },
{ label: getT("config.imageCompression.formatJpeg"), value: "jpeg" }, { label: getT("config.imageCompression.formatJpeg"), value: "jpeg" },
{ label: getT("config.imageCompression.formatPng"), value: "png" }, { label: getT("config.imageCompression.formatPng"), value: "png" },
{ label: getT("config.imageCompression.formatWebp"), value: "webp" }, { label: getT("config.imageCompression.formatWebp"), value: "webp" },
{ label: getT("config.imageCompression.formatAvif"), value: "avif" },
], ],
}, },
]; ];

View File

@@ -0,0 +1,487 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { motion } from "framer-motion";
import { Layers as LayersIcon, Box, Download, Archive } from "lucide-react";
import { FileUploader } from "@/components/tools/FileUploader";
import { ProgressBar } from "@/components/tools/ProgressBar";
import { ConfigPanel, type ConfigOption } from "@/components/tools/ConfigPanel";
import { Button } from "@/components/ui/button";
import { useUploadStore } from "@/store/uploadStore";
import { generateId } from "@/lib/utils";
import { useTranslation, getServerTranslations } from "@/lib/i18n";
import type { UploadedFile, TextureAtlasConfig } from "@/types";
const imageAccept = {
"image/*": [".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"],
};
const defaultConfig: TextureAtlasConfig = {
maxWidth: 1024,
maxHeight: 1024,
padding: 2,
allowRotation: false,
pot: true,
format: "png",
quality: 80,
outputFormat: "cocos2d",
algorithm: "MaxRects",
};
function useConfigOptions(config: TextureAtlasConfig, getT: (key: string) => string): ConfigOption[] {
return [
{
id: "maxWidth",
type: "slider",
label: getT("config.textureAtlas.maxWidth"),
description: getT("config.textureAtlas.maxWidthDescription"),
value: config.maxWidth,
min: 256,
max: 4096,
step: 256,
suffix: "px",
icon: <Box className="h-4 w-4" />,
},
{
id: "maxHeight",
type: "slider",
label: getT("config.textureAtlas.maxHeight"),
description: getT("config.textureAtlas.maxHeightDescription"),
value: config.maxHeight,
min: 256,
max: 4096,
step: 256,
suffix: "px",
icon: <Box className="h-4 w-4" />,
},
{
id: "padding",
type: "slider",
label: getT("config.textureAtlas.padding"),
description: getT("config.textureAtlas.paddingDescription"),
value: config.padding,
min: 0,
max: 16,
step: 1,
suffix: "px",
},
{
id: "allowRotation",
type: "select",
label: getT("config.textureAtlas.allowRotation"),
description: getT("config.textureAtlas.allowRotationDescription"),
value: config.allowRotation,
options: [
{ label: getT("common.no"), value: false },
{ label: getT("common.yes"), value: true },
],
},
{
id: "pot",
type: "select",
label: getT("config.textureAtlas.pot"),
description: getT("config.textureAtlas.potDescription"),
value: config.pot,
options: [
{ label: getT("common.no"), value: false },
{ label: getT("common.yes"), value: true },
],
},
{
id: "format",
type: "select",
label: getT("config.textureAtlas.format"),
description: getT("config.textureAtlas.formatDescription"),
value: config.format,
options: [
{ label: getT("config.textureAtlas.formatPng"), value: "png" },
{ label: getT("config.textureAtlas.formatWebp"), value: "webp" },
],
},
{
id: "quality",
type: "slider",
label: getT("config.textureAtlas.quality"),
description: getT("config.textureAtlas.qualityDescription"),
value: config.quality,
min: 1,
max: 100,
step: 1,
suffix: "%",
},
{
id: "outputFormat",
type: "select",
label: getT("config.textureAtlas.outputFormat"),
description: getT("config.textureAtlas.outputFormatDescription"),
value: config.outputFormat,
options: [
{ label: getT("config.textureAtlas.outputCocosCreator"), value: "cocos-creator" },
{ label: getT("config.textureAtlas.outputCocos2d"), value: "cocos2d" },
{ label: getT("config.textureAtlas.outputGeneric"), value: "generic-json" },
],
},
{
id: "algorithm",
type: "select",
label: getT("config.textureAtlas.algorithm"),
description: getT("config.textureAtlas.algorithmDescription"),
value: config.algorithm,
options: [
{ label: getT("config.textureAtlas.algorithmMaxRects"), value: "MaxRects" },
{ label: getT("config.textureAtlas.algorithmShelf"), value: "Shelf" },
],
},
];
}
/**
* 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 texture atlas creation
*/
async function processTextureAtlas(
fileIds: string[],
filenames: string[],
config: TextureAtlasConfig
): Promise<{ success: boolean; data?: any; error?: string }> {
const response = await fetch("/api/process/texture-atlas", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ fileIds, filenames, config }),
});
const data = await response.json();
if (!response.ok) {
return { success: false, error: data.error || "Processing failed" };
}
return { success: true, data };
}
interface AtlasResult {
id: string;
imageUrl: string;
metadataUrl: string;
zipUrl: string;
imageFilename: string;
metadataFilename: string;
zipFilename: string;
metadata: {
width: number;
height: number;
format: string;
frameCount: number;
outputFormat: string;
};
createdAt: Date;
}
export default function TextureAtlasPage() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const { t } = useTranslation();
const getT = (key: string, params?: Record<string, string | number>) => {
if (!mounted) return getServerTranslations("en").t(key, params);
return t(key, params);
};
const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } =
useUploadStore();
const [config, setConfig] = useState<TextureAtlasConfig>(defaultConfig);
const [atlasResult, setAtlasResult] = useState<AtlasResult | null>(null);
const handleFilesDrop = useCallback(
(acceptedFiles: File[]) => {
const newFiles: UploadedFile[] = acceptedFiles.map((file) => ({
id: generateId(),
file,
name: file.name,
size: file.size,
type: file.type,
uploadedAt: new Date(),
}));
newFiles.forEach((file) => addFile(file));
},
[addFile]
);
const handleConfigChange = (id: string, value: any) => {
setConfig((prev) => ({ ...prev, [id]: value }));
};
const handleResetConfig = () => {
setConfig(defaultConfig);
};
const handleProcess = async () => {
if (files.length === 0) return;
setProcessingStatus({
status: "uploading",
progress: 0,
message: getT("processing.uploadingSprites"),
});
const fileIds: string[] = [];
const filenames: string[] = [];
const errors: string[] = [];
try {
// Upload all files
for (let i = 0; i < files.length; i++) {
const file = files[i];
const uploadProgress = Math.round(((i + 0.5) / files.length) * 50);
setProcessingStatus({
status: "uploading",
progress: uploadProgress,
message: getT("processing.uploadProgress", { progress: uploadProgress }),
});
try {
const uploadResult = await uploadFile(file.file);
if (!uploadResult) {
throw new Error("Upload failed");
}
fileIds.push(uploadResult.fileId);
filenames.push(file.name);
} catch (error) {
errors.push(
`${file.name}: ${error instanceof Error ? error.message : "Upload failed"}`
);
}
}
if (fileIds.length === 0) {
throw new Error("No files were successfully uploaded");
}
// Create atlas
setProcessingStatus({
status: "processing",
progress: 75,
message: getT("processing.creatingAtlas"),
});
const result = await processTextureAtlas(fileIds, filenames, config);
if (result.success && result.data) {
setAtlasResult({
id: generateId(),
imageUrl: result.data.imageUrl,
metadataUrl: result.data.metadataUrl,
zipUrl: result.data.zipUrl,
imageFilename: result.data.imageFilename,
metadataFilename: result.data.metadataFilename,
zipFilename: result.data.zipFilename,
metadata: result.data.metadata,
createdAt: new Date(),
});
clearFiles();
setProcessingStatus({
status: "completed",
progress: 100,
message: getT("processing.atlasComplete"),
});
} else {
throw new Error(result.error || "Failed to create texture atlas");
}
} catch (error) {
setProcessingStatus({
status: "failed",
progress: 0,
message: getT("processing.processingFailed"),
error: error instanceof Error ? error.message : getT("processing.unknownError"),
});
}
};
const handleDownload = (url: string, filename: string) => {
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const canProcess =
files.length > 0 && processingStatus.status !== "processing" && files.length <= 500;
const configOptions = useConfigOptions(config, getT);
return (
<div className="p-6">
<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">
<LayersIcon className="h-6 w-6 text-primary" />
</div>
<div>
<h1 className="text-3xl font-bold">{getT("tools.textureAtlas.title")}</h1>
<p className="text-muted-foreground">
{getT("tools.textureAtlas.description")}
</p>
</div>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<FileUploader
files={files}
onFilesDrop={handleFilesDrop}
onRemoveFile={removeFile}
accept={imageAccept}
maxSize={10 * 1024 * 1024} // 10MB per file
maxFiles={500}
disabled={processingStatus.status === "processing"}
/>
<ConfigPanel
title={getT("config.textureAtlas.title")}
description={getT("config.textureAtlas.description")}
options={configOptions.map((opt) => ({
...opt,
value: config[opt.id as keyof TextureAtlasConfig],
}))}
onChange={handleConfigChange}
onReset={handleResetConfig}
/>
{canProcess && (
<Button onClick={handleProcess} size="lg" className="w-full">
<LayersIcon className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.createAtlas")}
</Button>
)}
</div>
<div className="space-y-6">
{processingStatus.status !== "idle" && (
<ProgressBar progress={processingStatus} />
)}
{atlasResult && (
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<Download className="h-5 w-5 text-primary" />
{getT("results.processingComplete")}
</h3>
<div className="mb-4 rounded-lg bg-background p-4">
<img
src={atlasResult.imageUrl}
alt="Texture Atlas"
className="h-auto w-full rounded border border-border/40"
/>
</div>
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.dimensions")}:</span>
<p className="font-medium">
{atlasResult.metadata.width} x {atlasResult.metadata.height}
</p>
</div>
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.sprites")}:</span>
<p className="font-medium">{atlasResult.metadata.frameCount}</p>
</div>
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.imageFormat")}:</span>
<p className="font-medium capitalize">{atlasResult.metadata.format}</p>
</div>
<div>
<span className="text-muted-foreground">{getT("tools.textureAtlas.dataFormat")}:</span>
<p className="font-medium capitalize">
{atlasResult.metadata.outputFormat === "cocos-creator"
? "Cocos Creator JSON"
: atlasResult.metadata.outputFormat === "cocos2d"
? "Cocos2d plist"
: "Generic JSON"}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<Button
onClick={() =>
handleDownload(atlasResult.zipUrl, atlasResult.zipFilename)
}
size="lg"
className="w-full"
>
<Archive className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.downloadAll")}
</Button>
<div className="flex gap-3">
<Button
onClick={() =>
handleDownload(atlasResult.imageUrl, atlasResult.imageFilename)
}
variant="outline"
className="flex-1"
>
<Download className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.downloadImage")}
</Button>
<Button
onClick={() =>
handleDownload(atlasResult.metadataUrl, atlasResult.metadataFilename)
}
variant="outline"
className="flex-1"
>
<Download className="mr-2 h-4 w-4" />
{getT("tools.textureAtlas.downloadData")}
</Button>
</div>
</div>
</div>
)}
<div className="rounded-lg border border-border/40 bg-card/50 p-6">
<h3 className="mb-3 font-semibold">{getT("tools.textureAtlas.features")}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
{(getT("tools.textureAtlas.featureList") as unknown as string[]).map(
(feature, index) => (
<li key={index}> {feature}</li>
)
)}
</ul>
</div>
</div>
</div>
</motion.div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile, readdir } from "fs/promises";
import path from "path";
import type { TextureAtlasConfig, AtlasSprite } from "@/types";
import {
saveProcessedFile,
cleanupFile,
sanitizeFilename,
} from "@/lib/file-storage";
import {
createTextureAtlas,
exportToCocos2dPlist,
exportToCocosCreatorJson,
exportToGenericJson,
validateTextureAtlasConfig,
} from "@/lib/texture-atlas";
import { validateImageBuffer } from "@/lib/image-processor";
import archiver from "archiver";
import { PassThrough } from "stream";
export const runtime = "nodejs";
const UPLOAD_DIR = process.env.TEMP_DIR || path.join(process.cwd(), ".temp", "uploads");
/**
* Create a ZIP buffer containing the atlas image and metadata
*/
async function createZipBuffer(
imageBuffer: Buffer,
imageFilename: string,
metadataContent: string,
metadataFilename: string
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
const passthrough = new PassThrough();
passthrough.on("data", (chunk) => chunks.push(chunk));
passthrough.on("end", () => resolve(Buffer.concat(chunks)));
passthrough.on("error", reject);
const archive = archiver("zip", { zlib: { level: 9 } });
archive.on("error", reject);
archive.pipe(passthrough);
// Add image
archive.append(imageBuffer, { name: imageFilename });
// Add metadata
archive.append(metadataContent, { name: metadataFilename });
archive.finalize();
});
}
interface ProcessRequest {
fileIds: string[];
config: TextureAtlasConfig;
filenames?: string[];
}
/**
* Find uploaded files by IDs
*/
async function findUploadedFiles(fileIds: string[]): Promise<
Array<{ fileId: string; buffer: Buffer; name: string }>
> {
const files: Array<{ fileId: string; buffer: Buffer; name: string }> = [];
for (const fileId of fileIds) {
try {
const sanitizedId = sanitizeFilename(fileId).replace(/\.[^.]+$/, "");
if (sanitizedId !== fileId) {
continue;
}
const fileList = await readdir(UPLOAD_DIR);
const file = fileList.find((f) => f.startsWith(`${fileId}.`));
if (!file) {
continue;
}
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) {
continue;
}
// Extract original name from file (remove UUID prefix)
const originalName = file.substring(fileId.length + 1);
files.push({ fileId, buffer, name: originalName });
} catch {
// Skip invalid files
continue;
}
}
return files;
}
export async function POST(request: NextRequest) {
try {
const body: ProcessRequest = await request.json();
const { fileIds, config, filenames } = body;
// Validate request
if (!fileIds || !Array.isArray(fileIds) || fileIds.length === 0) {
return NextResponse.json(
{ success: false, error: "At least one file ID is required" },
{ status: 400 }
);
}
if (fileIds.length > 500) {
return NextResponse.json(
{ success: false, error: "Maximum 500 sprites allowed per atlas" },
{ status: 400 }
);
}
// Validate config
const configValidation = validateTextureAtlasConfig(config);
if (!configValidation.valid) {
return NextResponse.json(
{ success: false, error: configValidation.error },
{ status: 400 }
);
}
// Sanitize file IDs
for (const fileId of fileIds) {
const sanitizedId = sanitizeFilename(fileId).replace(/\.[^.]+$/, "");
if (sanitizedId !== fileId) {
return NextResponse.json(
{ success: false, error: `Invalid file ID: ${fileId}` },
{ status: 400 }
);
}
}
// Find all uploaded files
const uploadedFiles = await findUploadedFiles(fileIds);
if (uploadedFiles.length === 0) {
return NextResponse.json(
{ success: false, error: "No valid files found or files have expired" },
{ status: 404 }
);
}
if (uploadedFiles.length !== fileIds.length) {
return NextResponse.json(
{
success: false,
error: `${fileIds.length - uploadedFiles.length} file(s) not found or expired`,
},
{ status: 404 }
);
}
// Prepare sprites
const sprites: AtlasSprite[] = uploadedFiles.map((file, index) => ({
id: file.fileId,
name: filenames?.[index] || file.name,
width: 0,
height: 0,
buffer: file.buffer,
}));
// Create texture atlas
let atlasResult;
try {
atlasResult = await createTextureAtlas(sprites, config);
} catch (error) {
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Failed to create texture atlas",
},
{ status: 422 }
);
}
// Define filenames
const imageFilename = `atlas.${atlasResult.format}`;
let metadataFilename: string;
// Export metadata based on format
let metadataContent: string;
switch (config.outputFormat) {
case "cocos2d":
metadataContent = exportToCocos2dPlist(atlasResult, imageFilename);
metadataFilename = "atlas.plist";
break;
case "cocos-creator":
metadataContent = exportToCocosCreatorJson(atlasResult, imageFilename);
metadataFilename = "atlas.json";
break;
case "generic-json":
metadataContent = exportToGenericJson(atlasResult, imageFilename);
metadataFilename = "atlas.json";
break;
default:
metadataContent = exportToCocosCreatorJson(atlasResult, imageFilename);
metadataFilename = "atlas.json";
}
// Create ZIP file with both image and metadata
const zipBuffer = await createZipBuffer(
atlasResult.image,
imageFilename,
metadataContent,
metadataFilename
);
// Save ZIP file
const zipInfo = await saveProcessedFile(
`atlas_${Date.now()}`,
zipBuffer,
"zip",
"atlas.zip"
);
// Also save individual files for preview
const imageInfo = await saveProcessedFile(
`atlas_img_${Date.now()}`,
atlasResult.image,
atlasResult.format,
imageFilename
);
const metadataBuffer = Buffer.from(metadataContent, "utf-8");
const metadataInfo = await saveProcessedFile(
`atlas_meta_${Date.now()}`,
metadataBuffer,
config.outputFormat === "cocos2d" ? "plist" : "json",
metadataFilename
);
// Cleanup uploaded files
for (const fileId of fileIds) {
try {
await cleanupFile(fileId);
} catch {
// Ignore cleanup errors
}
}
return NextResponse.json({
success: true,
imageUrl: imageInfo.fileUrl,
metadataUrl: metadataInfo.fileUrl,
zipUrl: zipInfo.fileUrl,
imageFilename: imageInfo.filename,
metadataFilename: metadataInfo.filename,
zipFilename: zipInfo.filename,
metadata: {
width: atlasResult.width,
height: atlasResult.height,
format: atlasResult.format,
frameCount: atlasResult.frames.length,
outputFormat: config.outputFormat,
},
});
} catch (error) {
console.error("Texture atlas processing error:", error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Texture atlas processing failed",
},
{ status: 500 }
);
}
}

View File

@@ -8,6 +8,7 @@ import {
Image, Image,
Music, Music,
LayoutDashboard, LayoutDashboard,
Layers,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useTranslation, getServerTranslations } from "@/lib/i18n"; import { useTranslation, getServerTranslations } from "@/lib/i18n";
@@ -36,6 +37,7 @@ function useSidebarNavItems() {
{ name: getT("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video }, { name: getT("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video },
{ name: getT("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image }, { name: getT("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image },
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music }, { name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
{ name: getT("sidebar.textureAtlas"), href: "/tools/texture-atlas", icon: Layers },
], ],
}, },
]; ];

View File

@@ -2,8 +2,15 @@ import sharp from "sharp";
import type { ImageCompressConfig } from "@/types"; import type { ImageCompressConfig } from "@/types";
/** /**
* Image processing service using Sharp * World-class Image Compression Engine
* Handles compression, format conversion, and resizing *
* 实现业界领先的图片压缩算法,核心策略:
* 1. 智能格式检测与自适应压缩
* 2. 多轮压缩迭代,确保最优结果
* 3. 压缩后不大于原图保证
* 4. 自动元数据剥离
* 5. 智能调色板降级 (PNG)
* 6. 基于内容的压缩策略
*/ */
export interface ProcessedImageResult { export interface ProcessedImageResult {
@@ -21,14 +28,19 @@ export interface ImageMetadata {
width: number; width: number;
height: number; height: number;
size: number; size: number;
hasAlpha: boolean;
isAnimated: boolean;
colorSpace?: string;
channels?: number;
depth?: string;
} }
// Supported output formats for compression // Supported output formats for compression
const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "gif", "tiff", "tif"] as const; const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "tif"] as const;
type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number]; type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number];
/** /**
* Get image metadata without loading the full image * Get detailed image metadata
*/ */
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> { export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
const metadata = await sharp(buffer).metadata(); const metadata = await sharp(buffer).metadata();
@@ -38,12 +50,16 @@ export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
width: metadata.width || 0, width: metadata.width || 0,
height: metadata.height || 0, height: metadata.height || 0,
size: buffer.length, size: buffer.length,
hasAlpha: metadata.hasAlpha || false,
isAnimated: (metadata.pages || 1) > 1,
colorSpace: metadata.space,
channels: metadata.channels,
depth: metadata.depth,
}; };
} }
/** /**
* Validate image buffer using Sharp * Validate image buffer using Sharp
* Checks if the buffer contains a valid image
*/ */
export async function validateImageBuffer(buffer: Buffer): Promise<boolean> { export async function validateImageBuffer(buffer: Buffer): Promise<boolean> {
try { try {
@@ -68,7 +84,97 @@ function isSupportedFormat(format: string): format is SupportedFormat {
} }
/** /**
* Compress and/or convert image * 分析图片特征,选择最佳压缩策略
*/
async function analyzeImageCharacteristics(buffer: Buffer): Promise<{
isPhotographic: boolean;
hasGradients: boolean;
isSimpleGraphic: boolean;
uniqueColors: number;
dominantColorCount: number;
}> {
const image = sharp(buffer);
const stats = await image.stats();
const metadata = await image.metadata();
// 分析颜色分布
const channels = stats.channels;
let totalStdDev = 0;
let colorVariance = 0;
channels.forEach((channel) => {
totalStdDev += channel.stdev;
colorVariance += Math.abs(channel.max - channel.min);
});
const avgStdDev = totalStdDev / channels.length;
const avgVariance = colorVariance / channels.length;
// 摄影图片通常有较高的标准差和颜色变化
const isPhotographic = avgStdDev > 40 && avgVariance > 150;
// 简单图形通常颜色变化小
const isSimpleGraphic = avgStdDev < 30 && avgVariance < 100;
// 渐变检测:中等标准差但低对比度
const hasGradients = avgStdDev > 20 && avgStdDev < 60;
// 估算唯一颜色数(基于统计)
const estimatedUniqueColors = Math.min(
Math.pow(2, (metadata.depth === "uchar" ? 8 : 16) * (metadata.channels || 3)),
Math.round(avgVariance * avgStdDev * 10)
);
return {
isPhotographic,
hasGradients,
isSimpleGraphic,
uniqueColors: estimatedUniqueColors,
dominantColorCount: channels.length,
};
}
/**
* 选择最佳输出格式
*/
async function selectOptimalFormat(
buffer: Buffer,
requestedFormat: string,
metadata: ImageMetadata
): Promise<string> {
if (requestedFormat !== "original" && requestedFormat !== "auto") {
return requestedFormat;
}
// 保留动画格式
if (metadata.isAnimated) {
if (metadata.format === "gif") return "gif";
if (metadata.format === "webp") return "webp";
}
// 有透明通道
if (metadata.hasAlpha) {
// WebP 支持透明且压缩率更好
return "webp";
}
// 照片类用 JPEG/WebP
const characteristics = await analyzeImageCharacteristics(buffer);
if (characteristics.isPhotographic) {
return "webp"; // WebP 在照片上表现最好
}
// 简单图形用 PNG
if (characteristics.isSimpleGraphic && characteristics.uniqueColors < 256) {
return "png";
}
// 默认返回原格式或 WebP
return metadata.format === "png" ? "png" : "webp";
}
/**
* 核心压缩函数 - 业界最佳实践实现
*/ */
export async function compressImage( export async function compressImage(
buffer: Buffer, buffer: Buffer,
@@ -80,116 +186,320 @@ export async function compressImage(
throw new Error("Invalid image data"); throw new Error("Invalid image data");
} }
// Get original metadata const originalSize = buffer.length;
const originalMetadata = await getImageMetadata(buffer); const originalMetadata = await getImageMetadata(buffer);
// Create Sharp instance // 选择最佳输出格式
let pipeline = sharp(buffer, { let outputFormat = await selectOptimalFormat(
// Limit input pixels to prevent DoS attacks buffer,
limitInputPixels: 268402689, // ~16384x16384 config.format,
// Enforce memory limits originalMetadata
unlimited: false, );
});
// Apply resizing if configured // BMP 不支持输出,转为合适格式
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") { if (outputFormat === "bmp") {
outputFormat = "jpeg"; outputFormat = originalMetadata.hasAlpha ? "png" : "jpeg";
} }
// Validate format is supported
if (!isSupportedFormat(outputFormat)) { if (!isSupportedFormat(outputFormat)) {
outputFormat = "jpeg"; outputFormat = "jpeg";
} }
// Apply format-specific compression // 尝试多种压缩策略,选择最优结果
const compressionResults = await Promise.all([
compressWithStrategy(buffer, config, outputFormat, "aggressive"),
compressWithStrategy(buffer, config, outputFormat, "balanced"),
compressWithStrategy(buffer, config, outputFormat, "quality"),
]);
// 选择最小且不大于原图的结果
let bestResult = compressionResults.reduce((best, current) => {
// 优先选择比原图小的
if (current.length < originalSize && best.length >= originalSize) {
return current;
}
if (current.length >= originalSize && best.length < originalSize) {
return best;
}
// 都比原图小或都比原图大时,选择最小的
return current.length < best.length ? current : best;
});
// 如果所有策略都导致变大,返回原图
if (bestResult.length >= originalSize) {
// 尝试仅剥离元数据
const strippedBuffer = await stripMetadataOnly(buffer, originalMetadata.format);
if (strippedBuffer.length < originalSize) {
bestResult = strippedBuffer;
outputFormat = originalMetadata.format;
} else {
// 返回原图
return {
buffer,
format: originalMetadata.format,
width: originalMetadata.width,
height: originalMetadata.height,
originalSize,
compressedSize: originalSize,
compressionRatio: 0,
};
}
}
// 获取输出元数据
const outputMetadata = await sharp(bestResult).metadata();
const compressionRatio = Math.round(
((originalSize - bestResult.length) / originalSize) * 100
);
return {
buffer: bestResult,
format: outputFormat,
width: outputMetadata.width || originalMetadata.width,
height: outputMetadata.height || originalMetadata.height,
originalSize,
compressedSize: bestResult.length,
compressionRatio: Math.max(0, compressionRatio),
};
}
/**
* 仅剥离元数据,不重新编码
*/
async function stripMetadataOnly(buffer: Buffer, format: string): Promise<Buffer> {
try {
let pipeline = sharp(buffer).rotate(); // rotate() 会移除 EXIF
switch (format) {
case "jpeg":
case "jpg":
return await pipeline.jpeg({ quality: 100 }).toBuffer();
case "png":
return await pipeline.png({ compressionLevel: 9 }).toBuffer();
case "webp":
return await pipeline.webp({ quality: 100, lossless: true }).toBuffer();
default:
return buffer;
}
} catch {
return buffer;
}
}
/**
* 使用特定策略进行压缩
*/
async function compressWithStrategy(
buffer: Buffer,
config: ImageCompressConfig,
outputFormat: string,
strategy: "aggressive" | "balanced" | "quality"
): Promise<Buffer> {
const metadata = await getImageMetadata(buffer);
const characteristics = await analyzeImageCharacteristics(buffer);
let pipeline = sharp(buffer, {
limitInputPixels: 268402689,
unlimited: false,
});
// 移除所有元数据以减小体积
pipeline = pipeline.rotate(); // 自动旋转并移除 orientation
// 应用尺寸调整
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",
withoutEnlargement: fit !== "fill",
kernel: "lanczos3", // 高质量缩放算法
});
}
}
// 根据策略调整质量
const qualityMultiplier = {
aggressive: 0.7,
balanced: 0.85,
quality: 1.0,
}[strategy];
const adjustedQuality = Math.round(config.quality * qualityMultiplier);
// 应用格式特定的压缩
switch (outputFormat) { switch (outputFormat) {
case "jpeg": case "jpeg":
case "jpg": case "jpg":
pipeline = pipeline.jpeg({ pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
quality: config.quality,
mozjpeg: true, // Use MozJPEG for better compression
progressive: true, // Progressive loading
});
break; break;
case "png": case "png":
// PNG compression is lossless, quality affects compression level pipeline = pipeline.png(getPngOptions(adjustedQuality, strategy, characteristics, metadata));
// 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; break;
case "webp": case "webp":
pipeline = pipeline.webp({ pipeline = pipeline.webp(getWebpOptions(adjustedQuality, strategy, characteristics, metadata));
quality: config.quality, break;
effort: 6, // Compression effort (0-6, 6 is highest)
}); case "avif":
pipeline = pipeline.avif(getAvifOptions(adjustedQuality, strategy));
break; break;
case "gif": case "gif":
// GIF doesn't support quality parameter in the same way pipeline = pipeline.gif(getGifOptions(strategy));
// We'll use near-lossless for better quality
pipeline = pipeline.gif({
dither: 1.0,
});
break; break;
case "tiff": case "tiff":
case "tif": case "tif":
pipeline = pipeline.tiff({ pipeline = pipeline.tiff(getTiffOptions(adjustedQuality, strategy));
quality: config.quality,
compression: "jpeg",
});
break; break;
default: default:
// Default to JPEG pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
pipeline = pipeline.jpeg({
quality: config.quality,
mozjpeg: true,
});
} }
// Get metadata before compression return await pipeline.toBuffer();
const metadata = await pipeline.metadata(); }
// Process image /**
const compressedBuffer = await pipeline.toBuffer(); * JPEG 压缩选项 - 使用 MozJPEG 最佳实践
*/
function getJpegOptions(
quality: number,
strategy: string,
characteristics: Awaited<ReturnType<typeof analyzeImageCharacteristics>>
): sharp.JpegOptions {
const baseOptions: sharp.JpegOptions = {
quality: Math.max(1, Math.min(100, quality)),
mozjpeg: true, // MozJPEG 提供更好的压缩率
progressive: true, // 渐进式加载,提升用户体验
optimiseCoding: true, // 优化哈夫曼表
optimiseScans: true, // 优化扫描顺序
trellisQuantisation: true, // Trellis 量化,提升质量
overshootDeringing: true, // 减少振铃效应
quantisationTable: characteristics.isPhotographic ? 3 : 2, // 照片用更高质量表
};
// Calculate compression ratio if (strategy === "aggressive") {
const compressionRatio = Math.round( return {
((buffer.length - compressedBuffer.length) / buffer.length) * 100 ...baseOptions,
); quality: Math.max(1, quality - 10),
quantisationTable: 0, // 最激进的量化表
};
}
return baseOptions;
}
/**
* PNG 压缩选项 - 智能无损/有损选择
*/
function getPngOptions(
quality: number,
strategy: string,
characteristics: Awaited<ReturnType<typeof analyzeImageCharacteristics>>,
metadata: ImageMetadata
): sharp.PngOptions {
// PNG 是无损格式quality 映射到 compressionLevel (0-9)
// 更高的 compressionLevel = 更慢但更小的文件
const compressionLevel = Math.min(9, Math.max(0, Math.floor((100 - quality) / 11)));
// 智能调色板决策
// 简单图形或低质量设置时使用调色板可大幅减小体积
const usePalette =
strategy === "aggressive" ||
(characteristics.isSimpleGraphic && quality < 90) ||
(!metadata.hasAlpha && characteristics.uniqueColors < 256) ||
quality < 50;
// 颜色数量限制
const colours = usePalette
? Math.min(256, Math.max(2, Math.round(256 * (quality / 100))))
: 256;
const baseOptions: sharp.PngOptions = {
compressionLevel: strategy === "aggressive" ? 9 : compressionLevel,
adaptiveFiltering: true, // 自适应过滤器选择
palette: usePalette,
colours: colours,
effort: strategy === "quality" ? 7 : 10, // 压缩努力程度 (1-10)
dither: usePalette ? 1.0 : 0, // 调色板模式下使用抖动
};
return baseOptions;
}
/**
* WebP 压缩选项 - 最佳现代格式
*/
function getWebpOptions(
quality: number,
strategy: string,
characteristics: Awaited<ReturnType<typeof analyzeImageCharacteristics>>,
metadata: ImageMetadata
): sharp.WebpOptions {
// 对于简单图形,无损 WebP 可能更小
const useLossless =
characteristics.isSimpleGraphic &&
characteristics.uniqueColors < 256 &&
strategy !== "aggressive";
const baseOptions: sharp.WebpOptions = {
quality: Math.max(1, Math.min(100, quality)),
effort: 6, // 压缩努力程度 (0-6)
lossless: useLossless,
nearLossless: !useLossless && quality > 85, // 近无损模式
smartSubsample: true, // 智能色度子采样
alphaQuality: metadata.hasAlpha ? Math.max(quality - 10, 50) : 100,
};
if (strategy === "aggressive") {
return {
...baseOptions,
quality: Math.max(1, quality - 15),
effort: 6,
lossless: false,
nearLossless: false,
};
}
return baseOptions;
}
/**
* AVIF 压缩选项 - 最先进的压缩格式
*/
function getAvifOptions(quality: number, strategy: string): sharp.AvifOptions {
return { return {
buffer: compressedBuffer, quality: Math.max(1, Math.min(100, quality)),
format: outputFormat, effort: strategy === "aggressive" ? 9 : 6, // 0-9
width: metadata.width || 0, lossless: quality === 100,
height: metadata.height || 0, chromaSubsampling: quality > 80 ? "4:4:4" : "4:2:0",
originalSize: buffer.length, };
compressedSize: compressedBuffer.length, }
compressionRatio,
/**
* GIF 压缩选项
*/
function getGifOptions(strategy: string): sharp.GifOptions {
return {
effort: strategy === "aggressive" ? 10 : 7,
dither: strategy === "quality" ? 1.0 : 0.5,
interFrameMaxError: strategy === "aggressive" ? 10 : 5,
interPaletteMaxError: strategy === "aggressive" ? 5 : 3,
};
}
/**
* TIFF 压缩选项
*/
function getTiffOptions(quality: number, strategy: string): sharp.TiffOptions {
return {
quality: Math.max(1, Math.min(100, quality)),
compression: strategy === "aggressive" ? "jpeg" : "lzw",
predictor: "horizontal",
}; };
} }
@@ -223,14 +533,12 @@ export function calculateQualityForTargetRatio(
currentRatio?: number, currentRatio?: number,
currentQuality?: number currentQuality?: number
): number { ): number {
// If we have current data, adjust based on difference
if (currentRatio !== undefined && currentQuality !== undefined) { if (currentRatio !== undefined && currentQuality !== undefined) {
const difference = targetRatio - currentRatio; const difference = targetRatio - currentRatio;
const adjustment = difference * 2; // Adjust by 2x the difference const adjustment = difference * 2;
return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment))); 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))); return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5)));
} }
@@ -249,7 +557,7 @@ export function validateCompressConfig(config: ImageCompressConfig): {
return { valid: false, error: "Quality must be between 1 and 100" }; return { valid: false, error: "Quality must be between 1 and 100" };
} }
const validFormats = ["original", "jpeg", "jpg", "png", "webp", "gif", "bmp", "tiff", "tif"]; const validFormats = ["original", "auto", "jpeg", "jpg", "png", "webp", "avif", "gif", "bmp", "tiff", "tif"];
if (!validFormats.includes(config.format)) { if (!validFormats.includes(config.format)) {
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` }; return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
} }
@@ -287,3 +595,30 @@ export function validateCompressConfig(config: ImageCompressConfig): {
return { valid: true }; return { valid: true };
} }
/**
* 获取格式推荐信息
*/
export function getFormatRecommendation(metadata: ImageMetadata): {
recommended: string;
reason: string;
} {
if (metadata.isAnimated) {
return {
recommended: "webp",
reason: "WebP provides better compression for animated images than GIF",
};
}
if (metadata.hasAlpha) {
return {
recommended: "webp",
reason: "WebP supports transparency with better compression than PNG",
};
}
return {
recommended: "webp",
reason: "WebP offers the best balance of quality and file size for photos",
};
}

806
src/lib/texture-atlas.ts Normal file
View File

@@ -0,0 +1,806 @@
import sharp from "sharp";
import type {
TextureAtlasConfig,
AtlasSprite,
AtlasRect,
AtlasFrame,
TextureAtlasResult,
} from "@/types";
/**
* Texture Atlas Packing Algorithms
* Implements MaxRects and Shelf algorithms for packing sprites into a texture atlas
*/
interface Rectangle {
x: number;
y: number;
width: number;
height: number;
}
interface SpriteWithSize extends AtlasSprite {
area: number;
maxWidth: number;
maxHeight: number;
}
/**
* Calculate the next power of two size
*/
function nextPowerOfTwo(value: number): number {
return Math.pow(2, Math.ceil(Math.log2(value)));
}
/**
* Ensure size is power of two if required
*/
function adjustSizeForPot(value: number, pot: boolean): number {
return pot ? nextPowerOfTwo(value) : value;
}
/**
* MaxRects Algorithm Implementation
* Best for general purpose packing with good space efficiency
*/
class MaxRectsPacker {
private binWidth: number;
private binHeight: number;
private usedRectangles: Rectangle[] = [];
private freeRectangles: Rectangle[] = [];
private allowRotation: boolean;
constructor(width: number, height: number, allowRotation: boolean) {
this.binWidth = width;
this.binHeight = height;
this.allowRotation = allowRotation;
this.freeRectangles.push({ x: 0, y: 0, width, height });
}
/**
* Insert a rectangle and return its position
*/
insert(width: number, height: number): { x: number; y: number; rotated: boolean } | null {
let bestNode = this.findPositionForNewNodeBestAreaFit(width, height);
let rotated = false;
// Try rotated version if allowed
if (this.allowRotation && width !== height) {
const rotatedNode = this.findPositionForNewNodeBestAreaFit(height, width);
if (
!bestNode ||
(rotatedNode && rotatedNode.height * rotatedNode.width < bestNode.height * bestNode.width)
) {
bestNode = rotatedNode;
rotated = true;
[width, height] = [height, width];
}
}
if (!bestNode) {
return null;
}
// Split the free rectangles
this.splitFreeRectangles(bestNode, width, height);
// Add to used rectangles
this.usedRectangles.push({
x: bestNode.x,
y: bestNode.y,
width,
height,
});
return { x: bestNode.x, y: bestNode.y, rotated };
}
/**
* Find the best position for a new rectangle using the Best Area Fit heuristic
*/
private findPositionForNewNodeBestAreaFit(
width: number,
height: number
): Rectangle | null {
let bestNode: Rectangle | null = null;
let bestAreaFit = Number.MAX_VALUE;
let bestShortSideFit = Number.MAX_VALUE;
for (const rect of this.freeRectangles) {
const areaFit = rect.width * rect.height - width * height;
// Check if rectangle fits
if (rect.width >= width && rect.height >= height) {
const leftoverHoriz = Math.abs(rect.width - width);
const leftoverVert = Math.abs(rect.height - height);
const shortSideFit = Math.min(leftoverHoriz, leftoverVert);
if (areaFit < bestAreaFit || (areaFit === bestAreaFit && shortSideFit < bestShortSideFit)) {
bestNode = { x: rect.x, y: rect.y, width, height };
bestShortSideFit = shortSideFit;
bestAreaFit = areaFit;
}
}
}
return bestNode;
}
/**
* Split free rectangles after placing a rectangle
*/
private splitFreeRectangles(node: Rectangle, width: number, height: number): void {
// Process in reverse order to avoid iteration issues
for (let i = this.freeRectangles.length - 1; i >= 0; i--) {
const freeRect = this.freeRectangles[i];
if (this.splitFreeRectangle(freeRect, node, width, height)) {
this.freeRectangles.splice(i, 1);
}
}
// Remove degenerate rectangles
this.freeRectangles = this.freeRectangles.filter(
(rect) => rect.width > 0 && rect.height > 0
);
}
/**
* Split a single free rectangle
*/
private splitFreeRectangle(
freeRect: Rectangle,
usedNode: Rectangle,
width: number,
height: number
): boolean {
// Check if intersection exists
if (
freeRect.x >= usedNode.x + width ||
freeRect.x + freeRect.width <= usedNode.x ||
freeRect.y >= usedNode.y + height ||
freeRect.y + freeRect.height <= usedNode.y
) {
return false;
// Split into new free rectangles
}
if (freeRect.x < usedNode.x) {
const newRect = {
x: freeRect.x,
y: freeRect.y,
width: usedNode.x - freeRect.x,
height: freeRect.height,
};
this.freeRectangles.push(newRect);
}
if (freeRect.x + freeRect.width > usedNode.x + width) {
const newRect = {
x: usedNode.x + width,
y: freeRect.y,
width: freeRect.x + freeRect.width - (usedNode.x + width),
height: freeRect.height,
};
this.freeRectangles.push(newRect);
}
if (freeRect.y < usedNode.y) {
const newRect = {
x: freeRect.x,
y: freeRect.y,
width: freeRect.width,
height: usedNode.y - freeRect.y,
};
this.freeRectangles.push(newRect);
}
if (freeRect.y + freeRect.height > usedNode.y + height) {
const newRect = {
x: freeRect.x,
y: usedNode.y + height,
width: freeRect.width,
height: freeRect.y + freeRect.height - (usedNode.y + height),
};
this.freeRectangles.push(newRect);
}
return true;
}
/**
* Calculate occupancy ratio
*/
getOccupancy(): number {
const usedArea = this.usedRectangles.reduce((sum, rect) => sum + rect.width * rect.height, 0);
return (usedArea / (this.binWidth * this.binHeight)) * 100;
}
}
/**
* Shelf Algorithm Implementation
* Simple and fast algorithm that packs sprites in horizontal shelves
*/
class ShelfPacker {
private shelves: Shelf[] = [];
private currentY = 0;
private allowRotation: boolean;
private binWidth: number;
private padding: number;
constructor(binWidth: number, allowRotation: boolean, padding: number) {
this.binWidth = binWidth;
this.allowRotation = allowRotation;
this.padding = padding;
}
/**
* Insert a rectangle
*/
insert(width: number, height: number): { x: number; y: number; rotated: boolean } | null {
const paddedWidth = width + this.padding;
const paddedHeight = height + this.padding;
// Try to fit in existing shelves
for (const shelf of this.shelves) {
if (this.allowRotation && width !== height) {
// Try rotated
if (shelf.currentX + height + this.padding <= this.binWidth) {
const result = { x: shelf.currentX, y: shelf.y, rotated: true };
shelf.currentX += height + this.padding;
shelf.height = Math.max(shelf.height, paddedWidth);
return result;
}
}
if (shelf.currentX + paddedWidth <= this.binWidth) {
const result = { x: shelf.currentX, y: shelf.y, rotated: false };
shelf.currentX += paddedWidth;
shelf.height = Math.max(shelf.height, paddedHeight);
return result;
}
}
// Try to create a new shelf
const newShelfY = this.currentY;
if (newShelfY + paddedHeight > this.binHeight) {
return null; // Doesn't fit
}
const newShelf: Shelf = {
y: newShelfY,
currentX: 0,
height: paddedHeight,
};
if (this.allowRotation && width !== height) {
// Try rotated
if (newShelf.currentX + height + this.padding <= this.binWidth) {
newShelf.currentX += height + this.padding;
newShelf.height = Math.max(newShelf.height, paddedWidth);
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: true };
}
}
if (newShelf.currentX + paddedWidth <= this.binWidth) {
newShelf.currentX += paddedWidth;
this.shelves.push(newShelf);
this.currentY += newShelf.height;
return { x: 0, y: newShelfY, rotated: false };
}
return null;
}
private binHeight = Number.MAX_VALUE;
}
interface Shelf {
y: number;
currentX: number;
height: number;
}
/**
* Sort sprites by size (largest first)
*/
function sortSpritesBySize(sprites: SpriteWithSize[]): SpriteWithSize[] {
return [...sprites].sort((a, b) => {
// First by max dimension
const maxA = Math.max(a.width, a.height);
const maxB = Math.max(b.width, b.height);
if (maxA !== maxB) return maxB - maxA;
// Then by area
if (b.area !== a.area) return b.area - a.area;
// Finally by perimeter
const periA = a.width + a.height;
const periB = b.width + b.height;
return periB - periA;
});
}
/**
* Pack sprites using MaxRects algorithm
*/
function packWithMaxRects(
sprites: SpriteWithSize[],
config: TextureAtlasConfig
): Map<string, AtlasRect & { rotated: boolean }> {
const padding = config.padding;
const effectiveWidth = config.maxWidth;
const effectiveHeight = config.maxHeight;
const packer = new MaxRectsPacker(effectiveWidth, effectiveHeight, config.allowRotation);
const placements = new Map<string, AtlasRect & { rotated: boolean }>();
for (const sprite of sortSpritesBySize(sprites)) {
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
const position = packer.insert(paddedWidth, paddedHeight);
if (!position) {
throw new Error(`Failed to pack sprite: ${sprite.name}`);
}
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
}
return placements;
}
/**
* Pack sprites using Shelf algorithm
*/
function packWithShelf(
sprites: SpriteWithSize[],
config: TextureAtlasConfig
): Map<string, AtlasRect & { rotated: boolean }> {
const padding = config.padding;
const effectiveWidth = config.maxWidth;
const packer = new ShelfPacker(effectiveWidth, config.allowRotation, padding);
const placements = new Map<string, AtlasRect & { rotated: boolean }>();
for (const sprite of sortSpritesBySize(sprites)) {
const position = packer.insert(sprite.width, sprite.height);
if (!position) {
throw new Error(`Failed to pack sprite: ${sprite.name}`);
}
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
}
return placements;
}
/**
* Create texture atlas from sprites
*/
export async function createTextureAtlas(
sprites: AtlasSprite[],
config: TextureAtlasConfig
): Promise<TextureAtlasResult> {
// Validate sprites and get dimensions
const spritesWithSize: SpriteWithSize[] = [];
for (const sprite of sprites) {
const metadata = await sharp(sprite.buffer).metadata();
const width = metadata.width || 0;
const height = metadata.height || 0;
if (width === 0 || height === 0) {
throw new Error(`Invalid sprite dimensions: ${sprite.name}`);
}
const area = width * height;
spritesWithSize.push({
...sprite,
width,
height,
area,
maxWidth: width,
maxHeight: height,
});
}
// Sort sprites by size
const sortedSprites = sortSpritesBySize(spritesWithSize);
// Calculate minimum required dimensions
const maxSpriteWidth = Math.max(...sortedSprites.map((s) => s.width));
const maxSpriteHeight = Math.max(...sortedSprites.map((s) => s.height));
const padding = config.padding;
// Estimate minimum size based on total area with packing efficiency factor
const paddedArea = sortedSprites.reduce((sum, s) => {
const pw = s.width + padding * 2;
const ph = s.height + padding * 2;
return sum + pw * ph;
}, 0);
// Start with a square estimate, accounting for ~85% packing efficiency
const minSide = Math.ceil(Math.sqrt(paddedArea / 0.85));
let estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
let estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
// Adjust for power of two if required
estimatedWidth = adjustSizeForPot(estimatedWidth, config.pot);
estimatedHeight = adjustSizeForPot(estimatedHeight, config.pot);
// Ensure within max bounds
estimatedWidth = Math.min(estimatedWidth, config.maxWidth);
estimatedHeight = Math.min(estimatedHeight, config.maxHeight);
// Try to pack with increasing size if needed
let placements: Map<string, AtlasRect & { rotated: boolean }>;
let finalWidth = estimatedWidth;
let finalHeight = estimatedHeight;
let success = false;
// Generate size attempts: start small and increase progressively
const sizeAttempts: { w: number; h: number }[] = [];
// Add the estimated size first
sizeAttempts.push({ w: estimatedWidth, h: estimatedHeight });
// For POT sizes, try all combinations up to max
if (config.pot) {
const potSizes = [64, 128, 256, 512, 1024, 2048, 4096].filter(
(s) => s <= config.maxWidth || s <= config.maxHeight
);
for (const w of potSizes) {
for (const h of potSizes) {
if (w <= config.maxWidth && h <= config.maxHeight &&
w >= maxSpriteWidth + padding * 2 &&
h >= maxSpriteHeight + padding * 2) {
sizeAttempts.push({ w, h });
}
}
}
// Sort by area to try smallest sizes first
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
} else {
// For non-POT, try progressively larger sizes
sizeAttempts.push(
{ w: estimatedWidth * 1.5, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 1.5, h: estimatedHeight * 1.5 },
{ w: estimatedWidth * 2, h: estimatedHeight },
{ w: estimatedWidth, h: estimatedHeight * 2 },
{ w: estimatedWidth * 2, h: estimatedHeight * 2 },
{ w: config.maxWidth, h: config.maxHeight }
);
}
// Remove duplicates and filter invalid sizes
const uniqueAttempts = sizeAttempts.filter((attempt, index, self) => {
const w = Math.min(Math.ceil(attempt.w), config.maxWidth);
const h = Math.min(Math.ceil(attempt.h), config.maxHeight);
return self.findIndex(a =>
Math.min(Math.ceil(a.w), config.maxWidth) === w &&
Math.min(Math.ceil(a.h), config.maxHeight) === h
) === index;
});
for (const attempt of uniqueAttempts) {
const attemptWidth = Math.min(
config.pot ? adjustSizeForPot(Math.ceil(attempt.w), true) : Math.ceil(attempt.w),
config.maxWidth
);
const attemptHeight = Math.min(
config.pot ? adjustSizeForPot(Math.ceil(attempt.h), true) : Math.ceil(attempt.h),
config.maxHeight
);
if (attemptWidth > config.maxWidth || attemptHeight > config.maxHeight) {
continue;
}
try {
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
if (config.algorithm === "MaxRects") {
placements = packWithMaxRects(sortedSprites, testConfig);
} else {
placements = packWithShelf(sortedSprites, testConfig);
}
// Calculate actual used dimensions
let maxX = 0;
let maxY = 0;
for (const [, placement] of placements) {
maxX = Math.max(maxX, placement.x + placement.width + padding);
maxY = Math.max(maxY, placement.y + placement.height + padding);
}
// Adjust final dimensions based on actual usage if POT
if (config.pot) {
finalWidth = adjustSizeForPot(maxX, true);
finalHeight = adjustSizeForPot(maxY, true);
// Make sure we don't exceed attempted dimensions
finalWidth = Math.min(finalWidth, attemptWidth);
finalHeight = Math.min(finalHeight, attemptHeight);
} else {
finalWidth = Math.ceil(maxX);
finalHeight = Math.ceil(maxY);
}
success = true;
break;
} catch {
// Try next size
continue;
}
}
if (!success) {
throw new Error(
"Unable to pack all sprites into the specified maximum dimensions. Try increasing the max size or using rotation."
);
}
// Create the composite image
const composite = sharp({
create: {
width: finalWidth,
height: finalHeight,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
});
const composites: {
input: Buffer;
left: number;
top: number;
}[] = [];
const frames: AtlasFrame[] = [];
for (const sprite of sortedSprites) {
const placement = placements!.get(sprite.id);
if (!placement) continue;
const image = sharp(sprite.buffer);
const metadata = await image.metadata();
// Handle rotation
let processedImage = image;
let spriteWidth = placement.width;
let spriteHeight = placement.height;
if (placement.rotated) {
processedImage = image.rotate(90);
[spriteWidth, spriteHeight] = [placement.height, placement.width];
}
const processedBuffer = await processedImage.toBuffer();
composites.push({
input: processedBuffer,
left: placement.x,
top: placement.y,
});
// Create frame data
frames.push({
filename: sprite.name,
frame: {
x: placement.x,
y: placement.y,
width: spriteWidth,
height: spriteHeight,
},
rotated: placement.rotated,
trimmed: false,
spriteSourceSize: { x: 0, y: 0, w: spriteWidth, h: spriteHeight },
sourceSize: {
w: metadata.width || spriteWidth,
h: metadata.height || spriteHeight,
},
});
}
const result = composite.composite(composites);
// Encode based on format
let outputBuffer: Buffer;
if (config.format === "png") {
outputBuffer = await result.png().toBuffer();
} else {
outputBuffer = await result.webp({ quality: config.quality }).toBuffer();
}
return {
width: finalWidth,
height: finalHeight,
image: outputBuffer,
frames,
format: config.format,
};
}
/**
* Export atlas data to Cocos2d plist format
*/
export function exportToCocos2dPlist(atlas: TextureAtlasResult, imageFilename: string): string {
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n';
xml += '<plist version="1.0">\n';
xml += '<dict>\n';
// Frames
xml += '\t<key>frames</key>\n';
xml += '\t<dict>\n';
for (const frame of atlas.frames) {
xml += `\t\t<key>${escapeXml(frame.filename)}</key>\n`;
xml += '\t\t<dict>\n';
// frame: {{x,y},{w,h}}
xml += '\t\t\t<key>frame</key>\n';
xml += `\t\t\t<string>{{${Math.round(frame.frame.x)},${Math.round(frame.frame.y)}},{${Math.round(frame.frame.width)},${Math.round(frame.frame.height)}}}</string>\n`;
// offset: {0,0}
xml += '\t\t\t<key>offset</key>\n';
xml += '\t\t\t<string>{0,0}</string>\n';
// rotated
xml += '\t\t\t<key>rotated</key>\n';
xml += `\t\t\t<${frame.rotated ? 'true' : 'false'}/>\n`;
// sourceColorRect: {{x,y},{w,h}}
xml += '\t\t\t<key>sourceColorRect</key>\n';
xml += `\t\t\t<string>{{${frame.spriteSourceSize.x},${frame.spriteSourceSize.y}},{${frame.spriteSourceSize.w},${frame.spriteSourceSize.h}}}</string>\n`;
// sourceSize: {w,h}
xml += '\t\t\t<key>sourceSize</key>\n';
xml += `\t\t\t<string>{${frame.sourceSize.w},${frame.sourceSize.h}}</string>\n`;
xml += '\t\t</dict>\n';
}
xml += '\t</dict>\n';
// Metadata
xml += '\t<key>metadata</key>\n';
xml += '\t<dict>\n';
xml += '\t\t<key>format</key>\n';
xml += '\t\t<integer>2</integer>\n';
xml += '\t\t<key>realTextureFileName</key>\n';
xml += `\t\t<string>${escapeXml(imageFilename)}</string>\n`;
xml += '\t\t<key>size</key>\n';
xml += `\t\t<string>{${atlas.width},${atlas.height}}</string>\n`;
xml += '\t\t<key>textureFileName</key>\n';
xml += `\t\t<string>${escapeXml(imageFilename)}</string>\n`;
xml += '\t</dict>\n';
xml += '</dict>\n';
xml += '</plist>\n';
return xml;
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Export atlas data to Cocos Creator JSON format
*/
export function exportToCocosCreatorJson(atlas: TextureAtlasResult, imageFilename: string): string {
return JSON.stringify(
{
meta: {
image: imageFilename,
size: { w: atlas.width, h: atlas.height },
format: atlas.format,
},
frames: atlas.frames.reduce((acc, frame) => {
acc[frame.filename] = {
frame: {
x: Math.round(frame.frame.x),
y: Math.round(frame.frame.y),
w: Math.round(frame.frame.width),
h: Math.round(frame.frame.height),
},
rotated: frame.rotated,
trimmed: frame.trimmed,
spriteSourceSize: frame.spriteSourceSize,
sourceSize: frame.sourceSize,
};
return acc;
}, {} as Record<string, any>),
},
null,
2
);
}
/**
* Export atlas data to generic JSON format
*/
export function exportToGenericJson(atlas: TextureAtlasResult, imageFilename: string): string {
return JSON.stringify(
{
image: imageFilename,
width: atlas.width,
height: atlas.height,
format: atlas.format,
frames: atlas.frames.map((frame) => ({
filename: frame.filename,
x: Math.round(frame.frame.x),
y: Math.round(frame.frame.y),
width: Math.round(frame.frame.width),
height: Math.round(frame.frame.height),
rotated: frame.rotated,
})),
},
null,
2
);
}
/**
* Validate texture atlas config
*/
export function validateTextureAtlasConfig(config: TextureAtlasConfig): {
valid: boolean;
error?: string;
} {
if (config.maxWidth < 64 || config.maxWidth > 8192) {
return { valid: false, error: "Max width must be between 64 and 8192" };
}
if (config.maxHeight < 64 || config.maxHeight > 8192) {
return { valid: false, error: "Max height must be between 64 and 8192" };
}
if (config.padding < 0 || config.padding > 16) {
return { valid: false, error: "Padding must be between 0 and 16" };
}
if (config.quality < 1 || config.quality > 100) {
return { valid: false, error: "Quality must be between 1 and 100" };
}
const validFormats = ["png", "webp"];
if (!validFormats.includes(config.format)) {
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
}
const validOutputFormats = ["cocos2d", "cocos-creator", "generic-json"];
if (!validOutputFormats.includes(config.outputFormat)) {
return { valid: false, error: `Invalid output format. Allowed: ${validOutputFormats.join(", ")}` };
}
const validAlgorithms = ["MaxRects", "Shelf"];
if (!validAlgorithms.includes(config.algorithm)) {
return { valid: false, error: `Invalid algorithm. Allowed: ${validAlgorithms.join(", ")}` };
}
return { valid: true };
}

View File

@@ -29,7 +29,9 @@
"failed": "Failed", "failed": "Failed",
"ready": "Ready to process", "ready": "Ready to process",
"file": "File", "file": "File",
"files": "files" "files": "files",
"yes": "Yes",
"no": "No"
}, },
"nav": { "nav": {
"tools": "Tools", "tools": "Tools",
@@ -151,6 +153,7 @@
"videoToFrames": "Video to Frames", "videoToFrames": "Video to Frames",
"imageCompression": "Image Compression", "imageCompression": "Image Compression",
"audioCompression": "Audio Compression", "audioCompression": "Audio Compression",
"textureAtlas": "Texture Atlas",
"aiImage": "AI Image", "aiImage": "AI Image",
"aiAudio": "AI Audio" "aiAudio": "AI Audio"
}, },
@@ -180,9 +183,11 @@
"format": "Output Format", "format": "Output Format",
"formatDescription": "Convert to a different format (optional)", "formatDescription": "Convert to a different format (optional)",
"formatOriginal": "Original", "formatOriginal": "Original",
"formatAuto": "Auto (Best)",
"formatJpeg": "JPEG", "formatJpeg": "JPEG",
"formatPng": "PNG", "formatPng": "PNG",
"formatWebp": "WebP" "formatWebp": "WebP",
"formatAvif": "AVIF"
}, },
"videoFrames": { "videoFrames": {
"title": "Export Settings", "title": "Export Settings",
@@ -207,19 +212,50 @@
"channelsDescription": "Audio channels", "channelsDescription": "Audio channels",
"stereo": "Stereo (2 channels)", "stereo": "Stereo (2 channels)",
"mono": "Mono (1 channel)" "mono": "Mono (1 channel)"
},
"textureAtlas": {
"title": "Atlas Settings",
"description": "Configure texture atlas generation",
"maxWidth": "Max Width",
"maxWidthDescription": "Maximum atlas width in pixels",
"maxHeight": "Max Height",
"maxHeightDescription": "Maximum atlas height in pixels",
"padding": "Padding",
"paddingDescription": "Space between sprites (prevents bleeding)",
"allowRotation": "Allow Rotation",
"allowRotationDescription": "Rotate sprites for better packing efficiency",
"pot": "Power of Two",
"potDescription": "Use power-of-two dimensions (512, 1024, 2048, etc.)",
"format": "Image Format",
"formatDescription": "Output image format",
"quality": "Quality",
"qualityDescription": "Compression quality for WebP format",
"outputFormat": "Data Format",
"outputFormatDescription": "Format for sprite metadata",
"algorithm": "Packing Algorithm",
"algorithmDescription": "Algorithm for arranging sprites",
"formatPng": "PNG (Lossless)",
"formatWebp": "WebP (Compressed)",
"outputCocos2d": "Cocos2d plist",
"outputCocosCreator": "Cocos Creator JSON",
"outputGeneric": "Generic JSON",
"algorithmMaxRects": "MaxRects (Best)",
"algorithmShelf": "Shelf (Fast)"
} }
}, },
"tools": { "tools": {
"imageCompression": { "imageCompression": {
"title": "Image Compression", "title": "Image Compression",
"description": "Optimize images for web and mobile without quality loss", "description": "World-class image compression with smart optimization",
"compressImages": "Compress Images", "compressImages": "Compress Images",
"features": "Features", "features": "Features",
"featureList": [ "featureList": [
"Batch processing - compress multiple images at once", "Smart compression - guaranteed smaller output or return original",
"Smart compression - maintains visual quality", "Multi-strategy optimization - tries multiple algorithms to find the best result",
"Format conversion - PNG to JPEG, WebP, and more", "Auto format selection - intelligently picks the best format for your image",
"Up to 80% size reduction without quality loss" "MozJPEG & WebP - industry-leading compression algorithms",
"Metadata stripping - automatic removal of EXIF and unnecessary data",
"Batch processing - compress multiple images at once"
] ]
}, },
"videoFrames": { "videoFrames": {
@@ -243,6 +279,25 @@
"output": "Output", "output": "Output",
"inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A", "inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A",
"outputFormats": "MP3, AAC, OGG, FLAC" "outputFormats": "MP3, AAC, OGG, FLAC"
},
"textureAtlas": {
"title": "Texture Atlas",
"description": "Combine multiple images into a single texture atlas for game development",
"createAtlas": "Create Texture Atlas",
"features": "Features",
"featureList": [
"Smart packing - MaxRects algorithm for optimal space usage",
"Cocos Creator compatible - export in plist/JSON format",
"Rotation support - can rotate sprites for better packing",
"Power of Two - automatic POT sizing for better compatibility"
],
"downloadAll": "Download All",
"downloadImage": "Download Image",
"downloadData": "Download Data",
"dimensions": "Dimensions",
"sprites": "Sprites",
"imageFormat": "Image Format",
"dataFormat": "Data Format"
} }
}, },
"processing": { "processing": {
@@ -252,8 +307,11 @@
"extractingFrames": "Extracting frames...", "extractingFrames": "Extracting frames...",
"uploadingAudio": "Uploading audio...", "uploadingAudio": "Uploading audio...",
"compressingAudio": "Compressing audio...", "compressingAudio": "Compressing audio...",
"uploadingSprites": "Uploading sprites...",
"creatingAtlas": "Creating texture atlas...",
"compressionComplete": "Compression complete!", "compressionComplete": "Compression complete!",
"processingComplete": "Processing complete!", "processingComplete": "Processing complete!",
"atlasComplete": "Texture atlas created successfully!",
"compressionFailed": "Compression failed", "compressionFailed": "Compression failed",
"processingFailed": "Processing failed", "processingFailed": "Processing failed",
"unknownError": "Unknown error", "unknownError": "Unknown error",

View File

@@ -29,7 +29,9 @@
"failed": "失败", "failed": "失败",
"ready": "准备处理", "ready": "准备处理",
"file": "文件", "file": "文件",
"files": "文件" "files": "文件",
"yes": "是",
"no": "否"
}, },
"nav": { "nav": {
"tools": "工具", "tools": "工具",
@@ -151,6 +153,7 @@
"videoToFrames": "视频抽帧", "videoToFrames": "视频抽帧",
"imageCompression": "图片压缩", "imageCompression": "图片压缩",
"audioCompression": "音频压缩", "audioCompression": "音频压缩",
"textureAtlas": "合图工具",
"aiImage": "AI 图片", "aiImage": "AI 图片",
"aiAudio": "AI 音频" "aiAudio": "AI 音频"
}, },
@@ -180,9 +183,11 @@
"format": "输出格式", "format": "输出格式",
"formatDescription": "转换为其他格式(可选)", "formatDescription": "转换为其他格式(可选)",
"formatOriginal": "原始", "formatOriginal": "原始",
"formatAuto": "自动(最佳)",
"formatJpeg": "JPEG", "formatJpeg": "JPEG",
"formatPng": "PNG", "formatPng": "PNG",
"formatWebp": "WebP" "formatWebp": "WebP",
"formatAvif": "AVIF"
}, },
"videoFrames": { "videoFrames": {
"title": "导出设置", "title": "导出设置",
@@ -207,19 +212,50 @@
"channelsDescription": "音频声道", "channelsDescription": "音频声道",
"stereo": "立体声2 声道)", "stereo": "立体声2 声道)",
"mono": "单声道1 声道)" "mono": "单声道1 声道)"
},
"textureAtlas": {
"title": "合图设置",
"description": "配置纹理图集生成选项",
"maxWidth": "最大宽度",
"maxWidthDescription": "图集的最大宽度(像素)",
"maxHeight": "最大高度",
"maxHeightDescription": "图集的最大高度(像素)",
"padding": "内边距",
"paddingDescription": "精灵之间的间距(防止溢出)",
"allowRotation": "允许旋转",
"allowRotationDescription": "旋转精灵以提高打包效率",
"pot": "2 的幂次",
"potDescription": "使用 2 的幂次尺寸512、1024、2048 等)",
"format": "图片格式",
"formatDescription": "输出图片格式",
"quality": "质量",
"qualityDescription": "WebP 格式的压缩质量",
"outputFormat": "数据格式",
"outputFormatDescription": "精灵元数据的格式",
"algorithm": "打包算法",
"algorithmDescription": "排列精灵的算法",
"formatPng": "PNG无损",
"formatWebp": "WebP压缩",
"outputCocos2d": "Cocos2d plist",
"outputCocosCreator": "Cocos Creator JSON",
"outputGeneric": "通用 JSON",
"algorithmMaxRects": "MaxRects最优",
"algorithmShelf": "Shelf快速"
} }
}, },
"tools": { "tools": {
"imageCompression": { "imageCompression": {
"title": "图片压缩", "title": "图片压缩",
"description": "为网页和移动端优化图片,不影响质量", "description": "世界一流的图片压缩,智能优化",
"compressImages": "压缩图片", "compressImages": "压缩图片",
"features": "功能特点", "features": "功能特点",
"featureList": [ "featureList": [
"批量处理 - 一次压缩多张图片", "智能压缩 - 保证输出更小或返回原图",
"智能压缩 - 保持视觉质量", "多策略优化 - 尝试多种算法找到最佳结果",
"格式转换 - PNG 转 JPEG、WebP 等", "自动格式选择 - 智能选择最适合的格式",
"高达 80% 的压缩率且不影响质量" "MozJPEG & WebP - 业界领先的压缩算法",
"元数据剥离 - 自动移除 EXIF 等冗余数据",
"批量处理 - 一次压缩多张图片"
] ]
}, },
"videoFrames": { "videoFrames": {
@@ -243,6 +279,25 @@
"output": "输出", "output": "输出",
"inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A", "inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A",
"outputFormats": "MP3、AAC、OGG、FLAC" "outputFormats": "MP3、AAC、OGG、FLAC"
},
"textureAtlas": {
"title": "合图工具",
"description": "将多张图片合并为一个纹理图集,专为游戏开发优化",
"createAtlas": "创建合图",
"features": "功能特点",
"featureList": [
"智能打包 - MaxRects 算法实现最优空间利用",
"Cocos Creator 兼容 - 导出 plist/JSON 格式",
"旋转支持 - 可旋转精灵以提高打包效率",
"2 的幂次 - 自动 POT 尺寸提升兼容性"
],
"downloadAll": "打包下载",
"downloadImage": "下载图片",
"downloadData": "下载数据",
"dimensions": "尺寸",
"sprites": "精灵数",
"imageFormat": "图片格式",
"dataFormat": "数据格式"
} }
}, },
"processing": { "processing": {
@@ -252,8 +307,11 @@
"extractingFrames": "提取帧中...", "extractingFrames": "提取帧中...",
"uploadingAudio": "上传音频中...", "uploadingAudio": "上传音频中...",
"compressingAudio": "压缩音频中...", "compressingAudio": "压缩音频中...",
"uploadingSprites": "上传精灵图中...",
"creatingAtlas": "创建合图中...",
"compressionComplete": "压缩完成!", "compressionComplete": "压缩完成!",
"processingComplete": "处理完成!", "processingComplete": "处理完成!",
"atlasComplete": "合图创建成功!",
"compressionFailed": "压缩失败", "compressionFailed": "压缩失败",
"processingFailed": "处理失败", "processingFailed": "处理失败",
"unknownError": "未知错误", "unknownError": "未知错误",

View File

@@ -59,7 +59,7 @@ export interface ProcessingProgress {
* Tool types * Tool types
*/ */
export type ToolType = "video-frames" | "image-compress" | "audio-compress" | "ai-image" | "ai-audio"; export type ToolType = "video-frames" | "image-compress" | "audio-compress" | "texture-atlas" | "ai-image" | "ai-audio";
export interface ToolConfig { export interface ToolConfig {
type: ToolType; type: ToolType;
@@ -129,7 +129,7 @@ export interface VideoFramesConfig {
export interface ImageCompressConfig { export interface ImageCompressConfig {
quality: number; quality: number;
format: "original" | "jpeg" | "png" | "webp"; format: "original" | "auto" | "jpeg" | "png" | "webp" | "avif";
resize?: { resize?: {
width?: number; width?: number;
height?: number; height?: number;
@@ -143,3 +143,52 @@ export interface AudioCompressConfig {
sampleRate: number; sampleRate: number;
channels: number; channels: number;
} }
/**
* Texture Atlas types
*/
export interface TextureAtlasConfig {
maxWidth: number;
maxHeight: number;
padding: number;
allowRotation: boolean;
pot: boolean; // Power of Two
format: "png" | "webp";
quality: number;
outputFormat: "cocos2d" | "cocos-creator" | "generic-json";
algorithm: "MaxRects" | "Shelf";
}
export interface AtlasSprite {
id: string;
name: string;
width: number;
height: number;
buffer: Buffer;
}
export interface AtlasRect {
x: number;
y: number;
width: number;
height: number;
rotated?: boolean;
}
export interface AtlasFrame {
filename: string;
frame: AtlasRect;
rotated: boolean;
trimmed: boolean;
spriteSourceSize: { x: number; y: number; w: number; h: number };
sourceSize: { w: number; h: number };
}
export interface TextureAtlasResult {
width: number;
height: number;
image: Buffer;
frames: AtlasFrame[];
format: string;
}