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-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",
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
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,
|
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 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 最佳实践
|
||||||
|
*/
|
||||||
// Calculate compression ratio
|
function getJpegOptions(
|
||||||
const compressionRatio = Math.round(
|
quality: number,
|
||||||
((buffer.length - compressedBuffer.length) / buffer.length) * 100
|
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 {
|
return {
|
||||||
buffer: compressedBuffer,
|
...baseOptions,
|
||||||
format: outputFormat,
|
quality: Math.max(1, quality - 10),
|
||||||
width: metadata.width || 0,
|
quantisationTable: 0, // 最激进的量化表
|
||||||
height: metadata.height || 0,
|
};
|
||||||
originalSize: buffer.length,
|
}
|
||||||
compressedSize: compressedBuffer.length,
|
|
||||||
compressionRatio,
|
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,
|
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
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",
|
"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",
|
||||||
|
|||||||
@@ -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": "未知错误",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user