feat: 实现纹理图集生成功能
添加纹理图集生成工具,支持多图片合并为单个图集并生成坐标数据文件 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
871
package-lock.json
generated
871
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,8 @@
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.11",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"archiver": "^7.0.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"ffmpeg-static": "^5.2.0",
|
||||
|
||||
@@ -19,7 +19,7 @@ const imageAccept = {
|
||||
|
||||
const defaultConfig: ImageCompressConfig = {
|
||||
quality: 80,
|
||||
format: "original",
|
||||
format: "auto",
|
||||
};
|
||||
|
||||
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"),
|
||||
value: config.format,
|
||||
options: [
|
||||
{ label: getT("config.imageCompression.formatAuto"), value: "auto" },
|
||||
{ label: getT("config.imageCompression.formatOriginal"), value: "original" },
|
||||
{ label: getT("config.imageCompression.formatJpeg"), value: "jpeg" },
|
||||
{ label: getT("config.imageCompression.formatPng"), value: "png" },
|
||||
{ label: getT("config.imageCompression.formatWebp"), value: "webp" },
|
||||
{ label: getT("config.imageCompression.formatAvif"), value: "avif" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
487
src/app/(dashboard)/tools/texture-atlas/page.tsx
Normal file
487
src/app/(dashboard)/tools/texture-atlas/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
281
src/app/api/process/texture-atlas/route.ts
Normal file
281
src/app/api/process/texture-atlas/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Image,
|
||||
Music,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
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.imageCompression"), href: "/tools/image-compress", icon: Image },
|
||||
{ name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music },
|
||||
{ name: getT("sidebar.textureAtlas"), href: "/tools/texture-atlas", icon: Layers },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2,8 +2,15 @@ import sharp from "sharp";
|
||||
import type { ImageCompressConfig } from "@/types";
|
||||
|
||||
/**
|
||||
* Image processing service using Sharp
|
||||
* Handles compression, format conversion, and resizing
|
||||
* World-class Image Compression Engine
|
||||
*
|
||||
* 实现业界领先的图片压缩算法,核心策略:
|
||||
* 1. 智能格式检测与自适应压缩
|
||||
* 2. 多轮压缩迭代,确保最优结果
|
||||
* 3. 压缩后不大于原图保证
|
||||
* 4. 自动元数据剥离
|
||||
* 5. 智能调色板降级 (PNG)
|
||||
* 6. 基于内容的压缩策略
|
||||
*/
|
||||
|
||||
export interface ProcessedImageResult {
|
||||
@@ -21,14 +28,19 @@ export interface ImageMetadata {
|
||||
width: number;
|
||||
height: number;
|
||||
size: number;
|
||||
hasAlpha: boolean;
|
||||
isAnimated: boolean;
|
||||
colorSpace?: string;
|
||||
channels?: number;
|
||||
depth?: string;
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
/**
|
||||
* Get image metadata without loading the full image
|
||||
* Get detailed image metadata
|
||||
*/
|
||||
export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
||||
const metadata = await sharp(buffer).metadata();
|
||||
@@ -38,12 +50,16 @@ export async function getImageMetadata(buffer: Buffer): Promise<ImageMetadata> {
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
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
|
||||
* Checks if the buffer contains a valid image
|
||||
*/
|
||||
export async function validateImageBuffer(buffer: Buffer): Promise<boolean> {
|
||||
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(
|
||||
buffer: Buffer,
|
||||
@@ -80,116 +186,320 @@ export async function compressImage(
|
||||
throw new Error("Invalid image data");
|
||||
}
|
||||
|
||||
// Get original metadata
|
||||
const originalSize = buffer.length;
|
||||
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,
|
||||
});
|
||||
// 选择最佳输出格式
|
||||
let outputFormat = await selectOptimalFormat(
|
||||
buffer,
|
||||
config.format,
|
||||
originalMetadata
|
||||
);
|
||||
|
||||
// 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
|
||||
// BMP 不支持输出,转为合适格式
|
||||
if (outputFormat === "bmp") {
|
||||
outputFormat = "jpeg";
|
||||
outputFormat = originalMetadata.hasAlpha ? "png" : "jpeg";
|
||||
}
|
||||
|
||||
// Validate format is supported
|
||||
if (!isSupportedFormat(outputFormat)) {
|
||||
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) {
|
||||
case "jpeg":
|
||||
case "jpg":
|
||||
pipeline = pipeline.jpeg({
|
||||
quality: config.quality,
|
||||
mozjpeg: true, // Use MozJPEG for better compression
|
||||
progressive: true, // Progressive loading
|
||||
});
|
||||
pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
|
||||
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
|
||||
});
|
||||
pipeline = pipeline.png(getPngOptions(adjustedQuality, strategy, characteristics, metadata));
|
||||
break;
|
||||
|
||||
case "webp":
|
||||
pipeline = pipeline.webp({
|
||||
quality: config.quality,
|
||||
effort: 6, // Compression effort (0-6, 6 is highest)
|
||||
});
|
||||
pipeline = pipeline.webp(getWebpOptions(adjustedQuality, strategy, characteristics, metadata));
|
||||
break;
|
||||
|
||||
case "avif":
|
||||
pipeline = pipeline.avif(getAvifOptions(adjustedQuality, strategy));
|
||||
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,
|
||||
});
|
||||
pipeline = pipeline.gif(getGifOptions(strategy));
|
||||
break;
|
||||
|
||||
case "tiff":
|
||||
case "tif":
|
||||
pipeline = pipeline.tiff({
|
||||
quality: config.quality,
|
||||
compression: "jpeg",
|
||||
});
|
||||
pipeline = pipeline.tiff(getTiffOptions(adjustedQuality, strategy));
|
||||
break;
|
||||
|
||||
default:
|
||||
// Default to JPEG
|
||||
pipeline = pipeline.jpeg({
|
||||
quality: config.quality,
|
||||
mozjpeg: true,
|
||||
});
|
||||
pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics));
|
||||
}
|
||||
|
||||
// Get metadata before compression
|
||||
const metadata = await pipeline.metadata();
|
||||
return await pipeline.toBuffer();
|
||||
}
|
||||
|
||||
// Process image
|
||||
const compressedBuffer = await pipeline.toBuffer();
|
||||
|
||||
// Calculate compression ratio
|
||||
const compressionRatio = Math.round(
|
||||
((buffer.length - compressedBuffer.length) / buffer.length) * 100
|
||||
);
|
||||
/**
|
||||
* 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, // 照片用更高质量表
|
||||
};
|
||||
|
||||
if (strategy === "aggressive") {
|
||||
return {
|
||||
buffer: compressedBuffer,
|
||||
format: outputFormat,
|
||||
width: metadata.width || 0,
|
||||
height: metadata.height || 0,
|
||||
originalSize: buffer.length,
|
||||
compressedSize: compressedBuffer.length,
|
||||
compressionRatio,
|
||||
...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 {
|
||||
quality: Math.max(1, Math.min(100, quality)),
|
||||
effort: strategy === "aggressive" ? 9 : 6, // 0-9
|
||||
lossless: quality === 100,
|
||||
chromaSubsampling: quality > 80 ? "4:4:4" : "4:2:0",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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
|
||||
const adjustment = difference * 2;
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -249,7 +557,7 @@ export function validateCompressConfig(config: ImageCompressConfig): {
|
||||
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)) {
|
||||
return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` };
|
||||
}
|
||||
@@ -287,3 +595,30 @@ export function validateCompressConfig(config: ImageCompressConfig): {
|
||||
|
||||
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
806
src/lib/texture-atlas.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -29,7 +29,9 @@
|
||||
"failed": "Failed",
|
||||
"ready": "Ready to process",
|
||||
"file": "File",
|
||||
"files": "files"
|
||||
"files": "files",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"nav": {
|
||||
"tools": "Tools",
|
||||
@@ -151,6 +153,7 @@
|
||||
"videoToFrames": "Video to Frames",
|
||||
"imageCompression": "Image Compression",
|
||||
"audioCompression": "Audio Compression",
|
||||
"textureAtlas": "Texture Atlas",
|
||||
"aiImage": "AI Image",
|
||||
"aiAudio": "AI Audio"
|
||||
},
|
||||
@@ -180,9 +183,11 @@
|
||||
"format": "Output Format",
|
||||
"formatDescription": "Convert to a different format (optional)",
|
||||
"formatOriginal": "Original",
|
||||
"formatAuto": "Auto (Best)",
|
||||
"formatJpeg": "JPEG",
|
||||
"formatPng": "PNG",
|
||||
"formatWebp": "WebP"
|
||||
"formatWebp": "WebP",
|
||||
"formatAvif": "AVIF"
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "Export Settings",
|
||||
@@ -207,19 +212,50 @@
|
||||
"channelsDescription": "Audio channels",
|
||||
"stereo": "Stereo (2 channels)",
|
||||
"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": {
|
||||
"imageCompression": {
|
||||
"title": "Image Compression",
|
||||
"description": "Optimize images for web and mobile without quality loss",
|
||||
"description": "World-class image compression with smart optimization",
|
||||
"compressImages": "Compress Images",
|
||||
"features": "Features",
|
||||
"featureList": [
|
||||
"Batch processing - compress multiple images at once",
|
||||
"Smart compression - maintains visual quality",
|
||||
"Format conversion - PNG to JPEG, WebP, and more",
|
||||
"Up to 80% size reduction without quality loss"
|
||||
"Smart compression - guaranteed smaller output or return original",
|
||||
"Multi-strategy optimization - tries multiple algorithms to find the best result",
|
||||
"Auto format selection - intelligently picks the best format for your image",
|
||||
"MozJPEG & WebP - industry-leading compression algorithms",
|
||||
"Metadata stripping - automatic removal of EXIF and unnecessary data",
|
||||
"Batch processing - compress multiple images at once"
|
||||
]
|
||||
},
|
||||
"videoFrames": {
|
||||
@@ -243,6 +279,25 @@
|
||||
"output": "Output",
|
||||
"inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A",
|
||||
"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": {
|
||||
@@ -252,8 +307,11 @@
|
||||
"extractingFrames": "Extracting frames...",
|
||||
"uploadingAudio": "Uploading audio...",
|
||||
"compressingAudio": "Compressing audio...",
|
||||
"uploadingSprites": "Uploading sprites...",
|
||||
"creatingAtlas": "Creating texture atlas...",
|
||||
"compressionComplete": "Compression complete!",
|
||||
"processingComplete": "Processing complete!",
|
||||
"atlasComplete": "Texture atlas created successfully!",
|
||||
"compressionFailed": "Compression failed",
|
||||
"processingFailed": "Processing failed",
|
||||
"unknownError": "Unknown error",
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"failed": "失败",
|
||||
"ready": "准备处理",
|
||||
"file": "文件",
|
||||
"files": "文件"
|
||||
"files": "文件",
|
||||
"yes": "是",
|
||||
"no": "否"
|
||||
},
|
||||
"nav": {
|
||||
"tools": "工具",
|
||||
@@ -151,6 +153,7 @@
|
||||
"videoToFrames": "视频抽帧",
|
||||
"imageCompression": "图片压缩",
|
||||
"audioCompression": "音频压缩",
|
||||
"textureAtlas": "合图工具",
|
||||
"aiImage": "AI 图片",
|
||||
"aiAudio": "AI 音频"
|
||||
},
|
||||
@@ -180,9 +183,11 @@
|
||||
"format": "输出格式",
|
||||
"formatDescription": "转换为其他格式(可选)",
|
||||
"formatOriginal": "原始",
|
||||
"formatAuto": "自动(最佳)",
|
||||
"formatJpeg": "JPEG",
|
||||
"formatPng": "PNG",
|
||||
"formatWebp": "WebP"
|
||||
"formatWebp": "WebP",
|
||||
"formatAvif": "AVIF"
|
||||
},
|
||||
"videoFrames": {
|
||||
"title": "导出设置",
|
||||
@@ -207,19 +212,50 @@
|
||||
"channelsDescription": "音频声道",
|
||||
"stereo": "立体声(2 声道)",
|
||||
"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": {
|
||||
"imageCompression": {
|
||||
"title": "图片压缩",
|
||||
"description": "为网页和移动端优化图片,不影响质量",
|
||||
"description": "世界一流的图片压缩,智能优化",
|
||||
"compressImages": "压缩图片",
|
||||
"features": "功能特点",
|
||||
"featureList": [
|
||||
"批量处理 - 一次压缩多张图片",
|
||||
"智能压缩 - 保持视觉质量",
|
||||
"格式转换 - PNG 转 JPEG、WebP 等",
|
||||
"高达 80% 的压缩率且不影响质量"
|
||||
"智能压缩 - 保证输出更小或返回原图",
|
||||
"多策略优化 - 尝试多种算法找到最佳结果",
|
||||
"自动格式选择 - 智能选择最适合的格式",
|
||||
"MozJPEG & WebP - 业界领先的压缩算法",
|
||||
"元数据剥离 - 自动移除 EXIF 等冗余数据",
|
||||
"批量处理 - 一次压缩多张图片"
|
||||
]
|
||||
},
|
||||
"videoFrames": {
|
||||
@@ -243,6 +279,25 @@
|
||||
"output": "输出",
|
||||
"inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A",
|
||||
"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": {
|
||||
@@ -252,8 +307,11 @@
|
||||
"extractingFrames": "提取帧中...",
|
||||
"uploadingAudio": "上传音频中...",
|
||||
"compressingAudio": "压缩音频中...",
|
||||
"uploadingSprites": "上传精灵图中...",
|
||||
"creatingAtlas": "创建合图中...",
|
||||
"compressionComplete": "压缩完成!",
|
||||
"processingComplete": "处理完成!",
|
||||
"atlasComplete": "合图创建成功!",
|
||||
"compressionFailed": "压缩失败",
|
||||
"processingFailed": "处理失败",
|
||||
"unknownError": "未知错误",
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface ProcessingProgress {
|
||||
* 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 {
|
||||
type: ToolType;
|
||||
@@ -129,7 +129,7 @@ export interface VideoFramesConfig {
|
||||
|
||||
export interface ImageCompressConfig {
|
||||
quality: number;
|
||||
format: "original" | "jpeg" | "png" | "webp";
|
||||
format: "original" | "auto" | "jpeg" | "png" | "webp" | "avif";
|
||||
resize?: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -143,3 +143,52 @@ export interface AudioCompressConfig {
|
||||
sampleRate: 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user