Compare commits

...

1 Commits

Author SHA1 Message Date
richarjiang
b29de1dd80 perf: 优化合图功能 2026-01-27 09:58:11 +08:00
15 changed files with 1117 additions and 493 deletions

10
package-lock.json generated
View File

@@ -30,6 +30,7 @@
"react-dropzone": "^14.3.5",
"sharp": "^0.33.5",
"tailwind-merge": "^2.6.0",
"upng-js": "^2.1.0",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},
@@ -8957,6 +8958,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/upng-js": {
"version": "2.1.0",
"resolved": "https://mirrors.tencent.com/npm/upng-js/-/upng-js-2.1.0.tgz",
"integrity": "sha512-d3xzZzpMP64YkjP5pr8gNyvBt7dLk/uGI67EctzDuVp4lCZyVMo0aJO6l/VDlgbInJYDY6cnClLoBp29eKWI6g==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.5"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View File

@@ -34,6 +34,7 @@
"react-dropzone": "^14.3.5",
"sharp": "^0.33.5",
"tailwind-merge": "^2.6.0",
"upng-js": "^2.1.0",
"zod": "^3.24.1",
"zustand": "^5.0.2"
},

View File

@@ -6,6 +6,7 @@ import {
ArrowRight,
ChevronDown,
Image as ImageIcon,
Layers,
Music,
ShieldCheck,
Sparkles,
@@ -136,7 +137,7 @@ function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
<span className="ml-3">{t("home.hero.previewTitle")}</span>
</div>
<div className="mt-6 grid gap-4 md:grid-cols-3">
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
{
icon: Video,
@@ -159,6 +160,13 @@ function Hero({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
href: "/tools/audio-compress",
tint: "from-emerald-500/20",
},
{
icon: Layers,
title: t("home.tools.textureAtlas.title"),
description: t("home.tools.textureAtlas.description"),
href: "/tools/texture-atlas",
tint: "from-orange-500/20",
},
].map((tool) => (
<motion.div
key={tool.href}
@@ -241,6 +249,13 @@ function ToolsShowcase({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
href: "/tools/audio-compress",
gradient: "from-emerald-500/20 via-white/[0.03] to-transparent",
},
{
icon: Layers,
title: t("home.tools.textureAtlas.title"),
description: t("home.tools.textureAtlas.description"),
href: "/tools/texture-atlas",
gradient: "from-orange-500/20 via-white/[0.03] to-transparent",
},
];
return (
@@ -252,7 +267,7 @@ function ToolsShowcase({ t, reduceMotion }: { t: TFn; reduceMotion: boolean }) {
description={t("home.showcase.description")}
/>
<div className="mt-12 grid gap-6 md:grid-cols-3">
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{items.map((item, index) => (
<motion.div
key={item.href}

View File

@@ -43,6 +43,7 @@ export function Header() {
{ name: displayT("sidebar.videoToFrames"), href: "/tools/video-frames" },
{ name: displayT("sidebar.imageCompression"), href: "/tools/image-compress" },
{ name: displayT("sidebar.audioCompression"), href: "/tools/audio-compress" },
{ name: displayT("sidebar.textureAtlas"), href: "/tools/texture-atlas" },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[mounted, locale]

View File

@@ -2,6 +2,7 @@
import { useCallback } from "react";
import JSZip from "jszip";
import UPNG from "upng-js";
import {
Settings2,
Box,
@@ -11,14 +12,17 @@ import {
Archive,
Play,
RefreshCw,
Layers
Layers,
Copy,
AlertTriangle,
Zap
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAtlasStore } from "@/store/atlasStore";
import { useAtlasStore, type SingleAtlasResult } from "@/store/atlasStore";
import { useAtlasWorker } from "@/hooks/useAtlasWorker";
import {
exportToCocos2dPlist,
@@ -131,6 +135,76 @@ function SelectOption<T extends string | number | boolean>({
);
}
/**
* Get metadata content and filename for an atlas
*/
function getMetadataForAtlas(
atlas: SingleAtlasResult,
config: TextureAtlasConfig,
atlasIndex: number,
totalAtlases: number
): { content: string; filename: string; mimeType: string } {
const suffix = totalAtlases > 1 ? `_${atlasIndex}` : "";
const imageFilename = `atlas${suffix}.${config.format}`;
let content: string;
let filename: string;
let mimeType: string;
if (config.outputFormat === "cocos2d") {
content = exportToCocos2dPlist(atlas.placements, atlas.width, atlas.height, imageFilename);
filename = `atlas${suffix}.plist`;
mimeType = "application/xml";
} else if (config.outputFormat === "cocos-creator") {
content = exportToCocosCreatorJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format);
filename = `atlas${suffix}.json`;
mimeType = "application/json";
} else {
content = exportToGenericJson(atlas.placements, atlas.width, atlas.height, imageFilename, config.format);
filename = `atlas${suffix}.json`;
mimeType = "application/json";
}
return { content, filename, mimeType };
}
/**
* Compress PNG data using UPNG quantization (similar to TinyPNG)
* @param dataUrl Original PNG data URL
* @param width Image width
* @param height Image height
* @returns Compressed PNG as Blob
*/
async function compressPng(dataUrl: string, width: number, height: number): Promise<Blob> {
// Create image from data URL
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = reject;
img.src = dataUrl;
});
// Draw to canvas to get ImageData
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Failed to get canvas context");
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, width, height);
// Compress using UPNG (256 colors = 8-bit PNG)
const compressed = UPNG.encode(
[imageData.data.buffer],
width,
height,
256 // 256 colors for best quality quantization
);
return new Blob([compressed], { type: "image/png" });
}
export function AtlasConfigPanel() {
const { t } = useSafeTranslation();
const { pack } = useAtlasWorker();
@@ -140,11 +214,20 @@ export function AtlasConfigPanel() {
config,
result,
status,
currentAtlasIndex,
enableMultiAtlas,
enableCompression,
updateConfig,
resetConfig,
setEnableMultiAtlas,
setEnableCompression,
openAnimationDialog,
} = useAtlasStore();
// Get current atlas
const currentAtlas = result?.atlases[currentAtlasIndex] || null;
const atlasCount = result?.atlases.length || 0;
// Config handlers
const handleConfigChange = useCallback(
<K extends keyof TextureAtlasConfig>(key: K, value: TextureAtlasConfig[K]) => {
@@ -160,76 +243,90 @@ export function AtlasConfigPanel() {
}
}, [sprites.length, pack]);
// Download functions
const downloadImage = useCallback(() => {
if (!result?.imageDataUrl) return;
// Download current atlas image
const downloadImage = useCallback(async () => {
if (!currentAtlas?.imageDataUrl) return;
const link = document.createElement("a");
link.href = result.imageDataUrl;
link.download = `atlas.${config.format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [result, config.format]);
const getMetadataInfo = useCallback(() => {
if (!result) return null;
let content: string;
let filename: string;
let mimeType: string;
const imageFilename = `atlas.${config.format}`;
if (config.outputFormat === "cocos2d") {
content = exportToCocos2dPlist(result.placements, result.width, result.height, imageFilename);
filename = "atlas.plist";
mimeType = "application/xml";
} else if (config.outputFormat === "cocos-creator") {
content = exportToCocosCreatorJson(result.placements, result.width, result.height, imageFilename, config.format);
filename = "atlas.json";
mimeType = "application/json";
const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : "";
const filename = `atlas${suffix}.${config.format}`;
let url: string;
let needRevoke = false;
// Apply compression if enabled and format is PNG
if (enableCompression && config.format === "png") {
const compressedBlob = await compressPng(
currentAtlas.imageDataUrl,
currentAtlas.width,
currentAtlas.height
);
url = URL.createObjectURL(compressedBlob);
needRevoke = true;
} else {
content = exportToGenericJson(result.placements, result.width, result.height, imageFilename, config.format);
filename = "atlas.json";
mimeType = "application/json";
url = currentAtlas.imageDataUrl;
}
return { content, filename, mimeType };
}, [result, config.format, config.outputFormat]);
const downloadMetadata = useCallback(() => {
const info = getMetadataInfo();
if (!info) return;
const { content, filename, mimeType } = info;
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
if (needRevoke) {
URL.revokeObjectURL(url);
}
}, [currentAtlas, currentAtlasIndex, atlasCount, config.format, enableCompression]);
// Download current atlas metadata
const downloadMetadata = useCallback(() => {
if (!currentAtlas) return;
const info = getMetadataForAtlas(currentAtlas, config, currentAtlasIndex, atlasCount);
const blob = new Blob([info.content], { type: info.mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = info.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, [getMetadataInfo]);
}, [currentAtlas, config, currentAtlasIndex, atlasCount]);
// Download all atlases as zip
const downloadAll = useCallback(async () => {
if (!result?.imageDataUrl) return;
const info = getMetadataInfo();
if (!info) return;
if (!result || result.atlases.length === 0) return;
try {
const zip = new JSZip();
const imageFilename = `atlas.${config.format}`;
const totalAtlases = result.atlases.length;
const shouldCompress = enableCompression && config.format === "png";
// Add image to zip
const base64Data = result.imageDataUrl.split(",")[1];
zip.file(imageFilename, base64Data, { base64: true });
// Add metadata to zip
zip.file(info.filename, info.content);
for (let i = 0; i < totalAtlases; i++) {
const atlas = result.atlases[i];
if (!atlas.imageDataUrl) continue;
const suffix = totalAtlases > 1 ? `_${i}` : "";
const imageFilename = `atlas${suffix}.${config.format}`;
// Add image to zip (with optional compression)
if (shouldCompress) {
const compressedBlob = await compressPng(
atlas.imageDataUrl,
atlas.width,
atlas.height
);
zip.file(imageFilename, compressedBlob);
} else {
const base64Data = atlas.imageDataUrl.split(",")[1];
zip.file(imageFilename, base64Data, { base64: true });
}
// Add metadata to zip
const metaInfo = getMetadataForAtlas(atlas, config, i, totalAtlases);
zip.file(metaInfo.filename, metaInfo.content);
}
// Generate and download zip
const blob = await zip.generateAsync({ type: "blob" });
@@ -244,11 +341,11 @@ export function AtlasConfigPanel() {
} catch (error) {
console.error("Failed to create zip:", error);
}
}, [result, config.format, getMetadataInfo]);
}, [result, config, enableCompression]);
// Check if can process
const canProcess = sprites.length > 0 && status !== "packing" && status !== "rendering";
const hasResult = !!result;
const hasResult = !!result && result.atlases.length > 0;
return (
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/[0.08] bg-[#1c1c1e]/80 backdrop-blur-xl shadow-xl">
@@ -257,7 +354,7 @@ export function AtlasConfigPanel() {
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">
{t("config.textureAtlas.title") || "合图设置"}
{t("config.textureAtlas.title")}
</span>
</div>
<Button
@@ -266,17 +363,17 @@ export function AtlasConfigPanel() {
className="h-7 text-xs"
onClick={resetConfig}
>
{t("common.reset") || "重置"}
{t("common.reset")}
</Button>
</div>
{/* Config content */}
<div className="flex-1 overflow-y-auto p-3 space-y-6">
{/* Size settings */}
<ConfigSection icon={Box} title={t("atlas.sizeSettings") || "尺寸设置"}>
<ConfigSection icon={Box} title={t("atlas.sizeSettings")}>
<SliderOption
id="maxWidth"
label={t("config.textureAtlas.maxWidth") || "最大宽度"}
label={t("config.textureAtlas.maxWidth")}
value={config.maxWidth}
min={256}
max={4096}
@@ -286,7 +383,7 @@ export function AtlasConfigPanel() {
/>
<SliderOption
id="maxHeight"
label={t("config.textureAtlas.maxHeight") || "最大高度"}
label={t("config.textureAtlas.maxHeight")}
value={config.maxHeight}
min={256}
max={4096}
@@ -296,7 +393,7 @@ export function AtlasConfigPanel() {
/>
<SliderOption
id="padding"
label={t("config.textureAtlas.padding") || "内边距"}
label={t("config.textureAtlas.padding")}
value={config.padding}
min={0}
max={16}
@@ -307,9 +404,9 @@ export function AtlasConfigPanel() {
</ConfigSection>
{/* Layout settings */}
<ConfigSection icon={LayoutGrid} title={t("atlas.layoutSettings") || "布局设置"}>
<ConfigSection icon={LayoutGrid} title={t("atlas.layoutSettings")}>
<SelectOption
label={t("config.textureAtlas.algorithm") || "打包算法"}
label={t("config.textureAtlas.algorithm")}
value={config.algorithm}
options={[
{ label: "MaxRects", value: "MaxRects" as const },
@@ -318,29 +415,29 @@ export function AtlasConfigPanel() {
onChange={(v) => handleConfigChange("algorithm", v)}
/>
<SelectOption
label={t("config.textureAtlas.allowRotation") || "允许旋转"}
label={t("config.textureAtlas.allowRotation")}
value={config.allowRotation}
options={[
{ label: t("common.no") || "否", value: false },
{ label: t("common.yes") || "是", value: true },
{ label: t("common.no"), value: false },
{ label: t("common.yes"), value: true },
]}
onChange={(v) => handleConfigChange("allowRotation", v)}
/>
<SelectOption
label={t("config.textureAtlas.pot") || "2的幂次"}
label={t("config.textureAtlas.pot")}
value={config.pot}
options={[
{ label: t("common.no") || "否", value: false },
{ label: t("common.yes") || "是", value: true },
{ label: t("common.no"), value: false },
{ label: t("common.yes"), value: true },
]}
onChange={(v) => handleConfigChange("pot", v)}
/>
</ConfigSection>
{/* Output settings */}
<ConfigSection icon={FileOutput} title={t("atlas.outputSettings") || "输出设置"}>
<ConfigSection icon={FileOutput} title={t("atlas.outputSettings")}>
<SelectOption
label={t("config.textureAtlas.format") || "图片格式"}
label={t("config.textureAtlas.format")}
value={config.format}
options={[
{ label: "PNG", value: "png" as const },
@@ -351,7 +448,7 @@ export function AtlasConfigPanel() {
{config.format === "webp" && (
<SliderOption
id="quality"
label={t("config.textureAtlas.quality") || "质量"}
label={t("config.textureAtlas.quality")}
value={config.quality}
min={1}
max={100}
@@ -361,7 +458,7 @@ export function AtlasConfigPanel() {
/>
)}
<SelectOption
label={t("config.textureAtlas.outputFormat") || "数据格式"}
label={t("config.textureAtlas.outputFormat")}
value={config.outputFormat}
options={[
{ label: "Cocos2d plist", value: "cocos2d" as const },
@@ -372,25 +469,106 @@ export function AtlasConfigPanel() {
/>
</ConfigSection>
{/* Advanced settings - Multi-atlas mode */}
<ConfigSection icon={Copy} title={t("atlas.advancedSettings")}>
<SelectOption
label={t("config.textureAtlas.multiAtlas")}
value={enableMultiAtlas}
options={[
{ label: t("common.off"), value: false },
{ label: t("common.on"), value: true },
]}
onChange={(v) => setEnableMultiAtlas(v)}
/>
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
{t("atlas.multiAtlasHint")}
</p>
</ConfigSection>
{/* Compression settings */}
<ConfigSection icon={Zap} title={t("atlas.compressionSettings")}>
<SelectOption
label={t("config.textureAtlas.compression")}
value={enableCompression}
options={[
{ label: t("common.off"), value: false },
{ label: t("common.on"), value: true },
]}
onChange={(v) => setEnableCompression(v)}
/>
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
{t("atlas.compressionHint")}
</p>
</ConfigSection>
{/* Unpacked sprites warning */}
{result && result.unpackedSpriteIds.length > 0 && !enableMultiAtlas && (
<div className="rounded-lg border border-amber-500/30 bg-amber-950/40 p-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-amber-200">
{t("atlas.unpackedCount", { count: result.unpackedSpriteIds.length })}
</p>
<p className="text-[10px] text-amber-200/60 mt-1 leading-relaxed">
{t("atlas.unpackedSuggestion")}
</p>
</div>
</div>
</div>
)}
{/* Result info */}
{result && (
{hasResult && (
<Card className="bg-primary/5 border-primary/20">
<CardHeader className="pb-2 pt-3 px-3">
<CardTitle className="flex items-center gap-2 text-sm">
<Layers className="h-4 w-4 text-primary" />
{t("atlas.resultInfo") || "合图信息"}
{t("atlas.resultInfo")}
</CardTitle>
</CardHeader>
<CardContent className="px-3 pb-3">
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions") || "尺寸"}:</span>
<p className="font-medium">{result.width} × {result.height}</p>
</div>
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites") || "精灵数"}:</span>
<p className="font-medium">{result.placements.length}</p>
</div>
{atlasCount > 1 ? (
<>
<div>
<span className="text-muted-foreground">{t("atlas.atlasCount")}:</span>
<p className="font-medium">{atlasCount}</p>
</div>
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
<p className="font-medium">{result.packedCount}</p>
</div>
{currentAtlas && (
<>
<div className="col-span-2 pt-1 border-t border-white/10">
<span className="text-muted-foreground">
{t("atlas.currentAtlas", { index: currentAtlasIndex + 1 })}:
</span>
</div>
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions")}:</span>
<p className="font-medium">{currentAtlas.width} × {currentAtlas.height}</p>
</div>
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
<p className="font-medium">{currentAtlas.placements.length}</p>
</div>
</>
)}
</>
) : currentAtlas && (
<>
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.dimensions")}:</span>
<p className="font-medium">{currentAtlas.width} × {currentAtlas.height}</p>
</div>
<div>
<span className="text-muted-foreground">{t("tools.textureAtlas.sprites")}:</span>
<p className="font-medium">{currentAtlas.placements.length}</p>
</div>
</>
)}
</div>
</CardContent>
</Card>
@@ -409,12 +587,12 @@ export function AtlasConfigPanel() {
{status === "packing" || status === "rendering" ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
{t("common.processing") || "处理中..."}
{t("common.processing")}
</>
) : (
<>
<Layers className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.createAtlas") || "生成合图"}
{t("tools.textureAtlas.createAtlas")}
</>
)}
</Button>
@@ -427,7 +605,7 @@ export function AtlasConfigPanel() {
disabled={sprites.length < 2}
>
<Play className="mr-2 h-4 w-4" />
{t("atlas.previewAnimation") || "预览动画"}
{t("atlas.previewAnimation")}
</Button>
{/* Download buttons */}
@@ -439,7 +617,7 @@ export function AtlasConfigPanel() {
onClick={downloadImage}
>
<Download className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.downloadImage") || "图片"}
{t("tools.textureAtlas.downloadImage")}
</Button>
<Button
variant="outline"
@@ -447,7 +625,7 @@ export function AtlasConfigPanel() {
onClick={downloadMetadata}
>
<Download className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.downloadData") || "数据"}
{t("tools.textureAtlas.downloadData")}
</Button>
</div>
)}
@@ -459,7 +637,10 @@ export function AtlasConfigPanel() {
onClick={downloadAll}
>
<Archive className="mr-2 h-4 w-4" />
{t("tools.textureAtlas.downloadAll") || "打包下载"}
{atlasCount > 1
? t("atlas.downloadAllAtlases", { count: atlasCount })
: t("tools.textureAtlas.downloadAll")
}
</Button>
)}
</div>

View File

@@ -9,7 +9,10 @@ import {
Play,
Layers,
Move,
Download
Download,
AlertTriangle,
ChevronLeft,
ChevronRight
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -37,7 +40,7 @@ export function CanvasPreview() {
const { t } = useSafeTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageCacheRef = useRef<{ url: string; image: HTMLImageElement } | null>(null);
const imageCacheRef = useRef<Map<number, { url: string; image: HTMLImageElement }>>(new Map());
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [isPanning, setIsPanning] = useState(false);
const [hasMoved, setHasMoved] = useState(false);
@@ -48,18 +51,24 @@ export function CanvasPreview() {
result,
status,
progress,
currentAtlasIndex,
previewScale,
previewOffset,
selectedSpriteIds,
setPreviewScale,
setPreviewOffset,
setCurrentAtlasIndex,
selectSprite,
openAnimationDialog,
} = useAtlasStore();
// Get current atlas
const currentAtlas = result?.atlases[currentAtlasIndex] || null;
const atlasCount = result?.atlases.length || 0;
// Calculate dimensions
const atlasWidth = result?.width || 0;
const atlasHeight = result?.height || 0;
const atlasWidth = currentAtlas?.width || 0;
const atlasHeight = currentAtlas?.height || 0;
// Update container size on resize
useEffect(() => {
@@ -103,7 +112,7 @@ export function CanvasPreview() {
ctx.fillStyle = "#0f0f11";
ctx.fillRect(0, 0, cw, ch);
if (result && result.imageDataUrl) {
if (currentAtlas && currentAtlas.imageDataUrl) {
const drawImage = (img: HTMLImageElement) => {
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = "#0f0f11";
@@ -132,7 +141,7 @@ export function CanvasPreview() {
// Draw selection highlight for all selected sprites
if (selectedSpriteIds.length > 0) {
result.placements.forEach(p => {
currentAtlas.placements.forEach(p => {
if (selectedSpriteIds.includes(p.id)) {
const pw = (p.rotated ? p.height : p.width) * previewScale;
const ph = (p.rotated ? p.width : p.height) * previewScale;
@@ -194,15 +203,16 @@ export function CanvasPreview() {
};
// Use cached image if URL matches
if (imageCacheRef.current && imageCacheRef.current.url === result.imageDataUrl) {
drawImage(imageCacheRef.current.image);
const cached = imageCacheRef.current.get(currentAtlasIndex);
if (cached && cached.url === currentAtlas.imageDataUrl) {
drawImage(cached.image);
} else {
// Load and draw the atlas image
const img = new Image();
img.src = result.imageDataUrl;
img.src = currentAtlas.imageDataUrl;
img.onload = () => {
imageCacheRef.current = { url: result.imageDataUrl!, image: img };
imageCacheRef.current.set(currentAtlasIndex, { url: currentAtlas.imageDataUrl!, image: img });
drawImage(img);
};
}
@@ -213,38 +223,48 @@ export function CanvasPreview() {
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(
t("atlas.emptyPreview") || "上传精灵图后预览合图效果",
t("atlas.emptyPreview"),
cw / 2,
ch / 2
);
}
}, [containerSize, result, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, t]);
}, [containerSize, currentAtlas, currentAtlasIndex, sprites.length, previewScale, previewOffset, atlasWidth, atlasHeight, selectedSpriteIds, t]);
// Render loop for animation (highlights)
// Render loop for animation (selection highlight pulsing)
useEffect(() => {
if (selectedSpriteIds.length === 0) return;
let animationFrame: number;
let mounted = true;
const render = () => {
// Re-trigger the main render useEffect by some means or just call a separate draw function
// For simplicity, we can just use a dummy state to force re-render if needed,
// but here we already have selectedSpriteIds in the dependency array of the main render effect.
// To get smooth pulsing, we can just request another frame.
if (selectedSpriteIds.length > 0) {
// This is a bit hacky but works for a canvas in React
// A better way would be to move drawing logic to a separate function
setContainerSize(s => ({ ...s }));
}
if (!mounted) return;
// Force re-render by updating containerSize (triggers canvas redraw)
setContainerSize(s => ({ ...s }));
animationFrame = requestAnimationFrame(render);
};
animationFrame = requestAnimationFrame(render);
return () => cancelAnimationFrame(animationFrame);
}, [selectedSpriteIds]);
return () => {
mounted = false;
cancelAnimationFrame(animationFrame);
};
}, [selectedSpriteIds.length > 0]); // Only care about whether there are selections
// Handle wheel zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setPreviewScale(previewScale + delta);
// Handle wheel zoom - use native event listener with passive: false
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
setPreviewScale(previewScale + delta);
};
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, [previewScale, setPreviewScale]);
// Handle mouse down for panning and picking
@@ -274,7 +294,7 @@ export function CanvasPreview() {
// Handle mouse up (Picking logic)
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (isPanning && !hasMoved && result && containerRef.current) {
if (isPanning && !hasMoved && currentAtlas && containerRef.current) {
// Pick sprite
const rect = containerRef.current.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
@@ -291,7 +311,7 @@ export function CanvasPreview() {
const atlasY = (mouseY - centerY) / previewScale;
// Find sprite under cursor
const clickedSprite = result.placements.find(p => {
const clickedSprite = currentAtlas.placements.find(p => {
const pw = p.rotated ? p.height : p.width;
const ph = p.rotated ? p.width : p.height;
return atlasX >= p.x && atlasX <= p.x + pw &&
@@ -305,11 +325,11 @@ export function CanvasPreview() {
}
}
setIsPanning(false);
}, [isPanning, hasMoved, result, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]);
}, [isPanning, hasMoved, currentAtlas, containerSize, atlasWidth, atlasHeight, previewScale, previewOffset, selectSprite]);
// Fit to view
const fitToView = useCallback(() => {
if (!result || containerSize.width === 0) return;
if (!currentAtlas || containerSize.width === 0) return;
const padding = 40;
const availableWidth = containerSize.width - padding * 2;
@@ -321,7 +341,7 @@ export function CanvasPreview() {
setPreviewScale(newScale);
setPreviewOffset({ x: 0, y: 0 });
}, [result, containerSize, atlasWidth, atlasHeight, setPreviewScale, setPreviewOffset]);
}, [currentAtlas, containerSize, atlasWidth, atlasHeight, setPreviewScale, setPreviewOffset]);
// Zoom controls
const zoomIn = useCallback(() => setPreviewScale(previewScale + 0.1), [previewScale, setPreviewScale]);
@@ -329,15 +349,29 @@ export function CanvasPreview() {
// Download image
const downloadImage = useCallback(() => {
if (!result?.imageDataUrl) return;
if (!currentAtlas?.imageDataUrl) return;
const link = document.createElement("a");
link.href = result.imageDataUrl;
link.download = `atlas_${atlasWidth}x${atlasHeight}.png`;
link.href = currentAtlas.imageDataUrl;
const suffix = atlasCount > 1 ? `_${currentAtlasIndex}` : "";
link.download = `atlas${suffix}_${atlasWidth}x${atlasHeight}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [result, atlasWidth, atlasHeight]);
}, [currentAtlas, currentAtlasIndex, atlasCount, atlasWidth, atlasHeight]);
// Navigate atlases
const goToPrevAtlas = useCallback(() => {
if (currentAtlasIndex > 0) {
setCurrentAtlasIndex(currentAtlasIndex - 1);
}
}, [currentAtlasIndex, setCurrentAtlasIndex]);
const goToNextAtlas = useCallback(() => {
if (currentAtlasIndex < atlasCount - 1) {
setCurrentAtlasIndex(currentAtlasIndex + 1);
}
}, [currentAtlasIndex, atlasCount, setCurrentAtlasIndex]);
// Scale percentage
const scalePercent = Math.round(previewScale * 100);
@@ -349,9 +383,9 @@ export function CanvasPreview() {
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{t("atlas.preview") || "预览"}
{t("atlas.preview")}
</span>
{result && (
{currentAtlas && (
<Badge variant="secondary" className="text-xs">
{atlasWidth} × {atlasHeight}
</Badge>
@@ -359,6 +393,37 @@ export function CanvasPreview() {
</div>
<div className="flex items-center gap-1">
{/* Atlas navigation (only show when multiple atlases) */}
{atlasCount > 1 && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={goToPrevAtlas}
disabled={currentAtlasIndex === 0}
>
<ChevronLeft className="h-3.5 w-3.5" />
</Button>
<div className="px-2 text-xs text-muted-foreground min-w-[60px] text-center">
{currentAtlasIndex + 1} / {atlasCount}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={goToNextAtlas}
disabled={currentAtlasIndex === atlasCount - 1}
>
<ChevronRight className="h-3.5 w-3.5" />
</Button>
<div className="mx-1 h-4 w-px bg-border/40" />
</>
)}
{/* Zoom controls */}
<Button
variant="ghost"
@@ -391,7 +456,7 @@ export function CanvasPreview() {
size="icon"
className="h-7 w-7"
onClick={fitToView}
disabled={!result}
disabled={!currentAtlas}
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
@@ -405,7 +470,7 @@ export function CanvasPreview() {
className="h-7 w-7"
onClick={openAnimationDialog}
disabled={sprites.length < 2}
title={t("atlas.previewAnimation") || "预览动画"}
title={t("atlas.previewAnimation")}
>
<Play className="h-3.5 w-3.5" />
</Button>
@@ -416,8 +481,8 @@ export function CanvasPreview() {
size="icon"
className="h-7 w-7"
onClick={downloadImage}
disabled={!result?.imageDataUrl}
title={t("common.download") || "下载"}
disabled={!currentAtlas?.imageDataUrl}
title={t("common.download")}
>
<Download className="h-3.5 w-3.5" />
</Button>
@@ -428,7 +493,6 @@ export function CanvasPreview() {
<div
ref={containerRef}
className="relative flex-1 cursor-grab overflow-hidden bg-[#0a0a0b] active:cursor-grabbing"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
@@ -450,9 +514,9 @@ export function CanvasPreview() {
>
<div className="mb-4 h-10 w-10 animate-spin rounded-full border-3 border-primary border-t-transparent" />
<p className="mb-2 text-sm font-medium shadow-sm">
{status === "packing" && (t("atlas.packing") || "打包中...")}
{status === "rendering" && (t("atlas.rendering") || "渲染中...")}
{status === "loading" && (t("common.loading") || "加载中...")}
{status === "packing" && t("atlas.packing")}
{status === "rendering" && t("atlas.rendering")}
{status === "loading" && t("common.loading")}
</p>
{progress > 0 && (
<div className="h-1.5 w-32 overflow-hidden rounded-full bg-white/10 shadow-inner">
@@ -473,19 +537,45 @@ export function CanvasPreview() {
<Layers className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="mb-1 text-sm font-medium text-muted-foreground">
{t("atlas.emptyPreview") || "上传精灵图后预览合图效果"}
{t("atlas.emptyPreview")}
</p>
<p className="text-xs text-muted-foreground/50">
{t("atlas.dragHint") || "拖拽文件或文件夹到左侧面板"}
{t("atlas.dragHint")}
</p>
</div>
)}
{/* Pan hint */}
{result && !isPanning && (
{currentAtlas && !isPanning && result && result.unpackedSpriteIds.length === 0 && (
<div className="absolute bottom-4 left-4 flex items-center gap-1.5 rounded-lg bg-black/60 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-sm">
<Move className="h-3 w-3" />
{t("atlas.panHint") || "拖拽平移,滚轮缩放"}
{t("atlas.panHint")}
</div>
)}
{/* Multi-atlas indicator */}
{atlasCount > 1 && (
<div className="absolute top-4 left-4 flex items-center gap-2 rounded-lg bg-primary/20 border border-primary/30 px-3 py-1.5 text-xs text-primary backdrop-blur-sm">
<Layers className="h-3 w-3" />
{t("atlas.atlasIndex", { current: currentAtlasIndex + 1, total: atlasCount })}
</div>
)}
{/* Unpacked sprites warning */}
{result && result.unpackedSpriteIds.length > 0 && (
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-2 border-t border-amber-500/30 bg-amber-950/80 px-4 py-2.5 backdrop-blur-sm">
<AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-amber-200">
{t("atlas.unpackedWarning", { count: result.unpackedSpriteIds.length })}
</p>
<p className="text-[10px] text-amber-200/60 truncate">
{t("atlas.unpackedHint")}
</p>
</div>
<Badge variant="outline" className="shrink-0 border-amber-500/50 bg-amber-500/10 text-amber-300">
{result.unpackedSpriteIds.length} / {sprites.length}
</Badge>
</div>
)}
</div>

View File

@@ -10,7 +10,8 @@ import {
Upload,
X,
ChevronDown,
ChevronRight
ChevronRight,
AlertCircle
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAtlasStore, type BrowserSprite } from "@/store/atlasStore";
@@ -81,6 +82,7 @@ export function FileListPanel() {
const {
sprites,
folderName,
result,
addSprites,
removeSprite,
clearSprites,
@@ -89,6 +91,9 @@ export function FileListPanel() {
selectSprite,
setStatus,
} = useAtlasStore();
// Get unpacked sprite IDs
const unpackedSpriteIds = result?.unpackedSpriteIds || [];
/**
* Process uploaded files
@@ -363,6 +368,7 @@ export function FileListPanel() {
group flex items-center gap-2.5 rounded-xl px-2.5 py-2 cursor-pointer
transition-all duration-150 hover:bg-white/[0.06]
${selectedSpriteIds.includes(sprite.id) ? "bg-primary/15 ring-1 ring-primary/30" : ""}
${unpackedSpriteIds.includes(sprite.id) ? "bg-amber-500/10 border border-amber-500/30" : ""}
`}
>
{/* Thumbnail */}
@@ -389,11 +395,19 @@ export function FileListPanel() {
}}
className="h-full w-full"
/>
{/* Unpacked indicator */}
{unpackedSpriteIds.includes(sprite.id) && (
<div className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-amber-500 shadow-sm">
<AlertCircle className="h-3 w-3 text-amber-950" />
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{sprite.name}</p>
<p className={`truncate text-xs font-medium ${unpackedSpriteIds.includes(sprite.id) ? "text-amber-300" : ""}`}>
{sprite.name}
</p>
<p className="text-[10px] text-muted-foreground/70">
{sprite.width} × {sprite.height}
</p>

View File

@@ -1,24 +1,26 @@
/**
* Hook for managing Atlas Worker communication
* Supports multi-atlas mode and PNG compression
*/
import { useRef, useCallback, useEffect } from "react";
import { useAtlasStore, type BrowserSprite, type AtlasResult } from "@/store/atlasStore";
import { useAtlasStore, type BrowserSprite, type AtlasResult, type SingleAtlasResult } from "@/store/atlasStore";
import type { TextureAtlasConfig, AtlasFrame } from "@/types";
import type { PackerPlacement } from "@/lib/atlas-packer";
import type { PackerPlacement, SinglePackerResult } from "@/lib/atlas-packer";
interface WorkerInputMessage {
type: "pack";
sprites: { id: string; name: string; width: number; height: number }[];
config: TextureAtlasConfig;
enableMultiAtlas: boolean;
}
interface WorkerOutputMessage {
type: "result" | "progress" | "error";
result?: {
width: number;
height: number;
placements: PackerPlacement[];
atlases: SinglePackerResult[];
packedCount: number;
unpackedSprites: { id: string; name: string; width: number; height: number }[];
};
progress?: number;
error?: string;
@@ -26,6 +28,7 @@ interface WorkerOutputMessage {
/**
* Render sprites to canvas and get data URL
* Always returns uncompressed data URL for preview
*/
async function renderAtlasToCanvas(
sprites: BrowserSprite[],
@@ -91,7 +94,7 @@ function buildFrames(placements: PackerPlacement[]): AtlasFrame[] {
export function useAtlasWorker() {
const workerRef = useRef<Worker | null>(null);
const { sprites, config, setStatus, setProgress, setError, setResult } = useAtlasStore();
const { sprites, config, enableMultiAtlas, setStatus, setProgress, setError, setResult } = useAtlasStore();
// Initialize worker
useEffect(() => {
@@ -115,23 +118,44 @@ export function useAtlasWorker() {
setProgress(50);
try {
// Render to canvas
const currentSprites = useAtlasStore.getState().sprites;
const imageDataUrl = await renderAtlasToCanvas(
currentSprites,
result.placements,
result.width,
result.height
);
const atlasCount = result.atlases.length;
const atlasResults: SingleAtlasResult[] = [];
// Render each atlas
for (let i = 0; i < atlasCount; i++) {
const atlas = result.atlases[i];
const imageDataUrl = await renderAtlasToCanvas(
currentSprites,
atlas.placements,
atlas.width,
atlas.height
);
const frames = buildFrames(result.placements);
const frames = buildFrames(atlas.placements);
atlasResults.push({
index: atlas.index,
width: atlas.width,
height: atlas.height,
placements: atlas.placements,
frames,
imageDataUrl,
spriteIds: atlas.spriteIds,
});
// Update progress for rendering
setProgress(50 + ((i + 1) / atlasCount) * 50);
}
// Extract unpacked sprite IDs
const unpackedSpriteIds = result.unpackedSprites.map(s => s.id);
const atlasResult: AtlasResult = {
width: result.width,
height: result.height,
placements: result.placements,
frames,
imageDataUrl,
atlases: atlasResults,
packedCount: result.packedCount,
unpackedSpriteIds,
};
setResult(atlasResult);
@@ -176,10 +200,11 @@ export function useAtlasWorker() {
height: s.height,
})),
config,
enableMultiAtlas,
};
workerRef.current.postMessage(message);
}, [sprites, config, setStatus, setProgress, setError]);
}, [sprites, config, enableMultiAtlas, setStatus, setProgress, setError]);
return { pack };
}
@@ -371,148 +396,196 @@ class ShelfPacker {
}
}
function packWithMaxRects(sprites, config, postProgress) {
function packSingleAtlas(sprites, config, atlasIndex) {
const padding = config.padding;
const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation);
const placements = new Map();
const sorted = sortSpritesBySize(sprites);
let packWidth = config.maxWidth;
let packHeight = config.maxHeight;
for (let i = 0; i < sorted.length; i++) {
const sprite = sorted[i];
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
const position = packer.insert(paddedWidth, paddedHeight);
if (!position) return null;
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
postProgress(((i + 1) / sorted.length) * 100);
if (config.pot) {
packWidth = adjustSizeForPot(packWidth, true);
packHeight = adjustSizeForPot(packHeight, true);
packWidth = Math.min(packWidth, config.maxWidth);
packHeight = Math.min(packHeight, config.maxHeight);
}
const sortedSprites = sortSpritesBySize(sprites);
const placements = new Map();
const unpackedSprites = [];
return placements;
if (config.algorithm === "MaxRects") {
const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation);
for (const sprite of sortedSprites) {
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
if (paddedWidth > packWidth || paddedHeight > packHeight) {
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
const position = packer.insert(paddedHeight, paddedWidth);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: true,
});
continue;
}
}
unpackedSprites.push(sprite);
continue;
}
const position = packer.insert(paddedWidth, paddedHeight);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
} else {
unpackedSprites.push(sprite);
}
}
} else {
const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding);
for (const sprite of sortedSprites) {
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
if (paddedWidth > packWidth || paddedHeight > packHeight) {
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
const position = packer.insert(sprite.height, sprite.width);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: true,
});
continue;
}
}
unpackedSprites.push(sprite);
continue;
}
const position = packer.insert(sprite.width, sprite.height);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
} else {
unpackedSprites.push(sprite);
}
}
}
let maxX = 0, maxY = 0;
for (const p of placements.values()) {
const effectiveWidth = p.rotated ? p.height : p.width;
const effectiveHeight = p.rotated ? p.width : p.height;
maxX = Math.max(maxX, p.x + effectiveWidth + padding);
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
}
let finalWidth, finalHeight;
if (config.pot) {
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
} else {
finalWidth = Math.ceil(maxX);
finalHeight = Math.ceil(maxY);
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
}
const resultPlacements = [];
const spriteIds = [];
for (const sprite of sprites) {
const placement = placements.get(sprite.id);
if (placement) {
resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement });
spriteIds.push(sprite.id);
}
}
return {
result: {
index: atlasIndex,
width: finalWidth || 1,
height: finalHeight || 1,
placements: resultPlacements,
spriteIds,
},
unpackedSprites,
};
}
function packWithShelf(sprites, config, postProgress) {
const padding = config.padding;
const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding);
const placements = new Map();
const sorted = sortSpritesBySize(sprites);
for (let i = 0; i < sorted.length; i++) {
const sprite = sorted[i];
const position = packer.insert(sprite.width, sprite.height);
if (!position) return null;
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
postProgress(((i + 1) / sorted.length) * 100);
}
return placements;
}
function packSprites(sprites, config, postProgress) {
function packSprites(sprites, config, enableMultiAtlas, postProgress) {
if (sprites.length === 0) return null;
const padding = config.padding;
const maxSpriteWidth = Math.max(...sprites.map(s => s.width));
const maxSpriteHeight = Math.max(...sprites.map(s => s.height));
const totalArea = sprites.reduce((sum, s) => sum + (s.width + padding * 2) * (s.height + padding * 2), 0);
const minSide = Math.ceil(Math.sqrt(totalArea / 0.85));
const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
const sizeAttempts = [];
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 });
}
}
const atlases = [];
let remainingSprites = [...sprites];
let atlasIndex = 0;
let totalProcessed = 0;
while (remainingSprites.length > 0) {
const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex);
if (result.placements.length === 0) {
break;
}
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
} else {
sizeAttempts.push(
{ w: estimatedWidth, h: estimatedHeight },
{ 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 }
);
}
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;
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
let placements;
if (config.algorithm === "MaxRects") {
placements = packWithMaxRects(sprites, testConfig, postProgress);
} else {
placements = packWithShelf(sprites, testConfig, postProgress);
atlases.push(result);
totalProcessed += result.placements.length;
postProgress((totalProcessed / sprites.length) * 100);
if (!enableMultiAtlas) {
return {
atlases,
packedCount: result.placements.length,
unpackedSprites,
};
}
if (placements) {
let maxX = 0, maxY = 0;
for (const p of placements.values()) {
const effectiveWidth = p.rotated ? p.height : p.width;
const effectiveHeight = p.rotated ? p.width : p.height;
maxX = Math.max(maxX, p.x + effectiveWidth + padding);
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
}
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
finalWidth = Math.min(finalWidth, attemptWidth);
finalHeight = Math.min(finalHeight, attemptHeight);
const resultPlacements = [];
for (const sprite of sprites) {
const placement = placements.get(sprite.id);
if (placement) {
resultPlacements.push({ id: sprite.id, name: sprite.name, ...placement });
}
}
return { width: finalWidth, height: finalHeight, placements: resultPlacements };
remainingSprites = unpackedSprites;
atlasIndex++;
if (atlasIndex > 100) {
console.warn("Max atlas limit reached");
break;
}
}
if (atlases.length === 0) {
return null;
}
return null;
const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0);
return {
atlases,
packedCount: totalPacked,
unpackedSprites: remainingSprites,
};
}
self.onmessage = function(event) {
const { type, sprites, config } = event.data;
const { type, sprites, config, enableMultiAtlas } = event.data;
if (type === "pack") {
try {
@@ -520,12 +593,12 @@ self.onmessage = function(event) {
self.postMessage({ type: "progress", progress });
};
const result = packSprites(sprites, config, postProgress);
const result = packSprites(sprites, config, enableMultiAtlas, postProgress);
if (result) {
self.postMessage({ type: "result", result });
} else {
self.postMessage({ type: "error", error: "Failed to pack all sprites. Try increasing max size or enabling rotation." });
self.postMessage({ type: "error", error: "No sprites could be packed. Check sprite sizes and max dimensions." });
}
} catch (error) {
self.postMessage({ type: "error", error: error.message || "Unknown error" });

View File

@@ -1,6 +1,7 @@
/**
* Browser-side Texture Atlas Packing Algorithms
* Implements MaxRects and Shelf algorithms for packing sprites
* Supports multi-atlas mode (like TexturePacker)
*/
import type { TextureAtlasConfig } from "@/types";
@@ -39,12 +40,24 @@ export interface PackerPlacement {
}
/**
* Complete packing result
* Single atlas packing result
*/
export interface PackerResult {
export interface SinglePackerResult {
index: number;
width: number;
height: number;
placements: PackerPlacement[];
spriteIds: string[];
}
/**
* Complete packing result (supports multiple atlases)
*/
export interface PackerResult {
atlases: SinglePackerResult[];
packedCount: number;
/** Sprites that couldn't fit (only when multi-atlas is disabled) */
unpackedSprites: PackerSprite[];
}
/**
@@ -346,202 +359,233 @@ class ShelfPacker {
}
/**
* Pack sprites using MaxRects algorithm
* Pack sprites into a single atlas
* Returns placements and unpacked sprites
*/
function packWithMaxRects(
function packSingleAtlas(
sprites: PackerSprite[],
config: TextureAtlasConfig
): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
config: TextureAtlasConfig,
atlasIndex: number
): { result: SinglePackerResult; unpackedSprites: PackerSprite[] } {
const padding = config.padding;
const packer = new MaxRectsPacker(config.maxWidth, config.maxHeight, config.allowRotation);
const placements = new Map<string, { x: number; y: number; width: number; height: number; 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) {
return null; // Failed to pack
}
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
// Use the max dimensions directly for packing
let packWidth = config.maxWidth;
let packHeight = config.maxHeight;
// For POT mode, ensure dimensions are power of two
if (config.pot) {
packWidth = adjustSizeForPot(packWidth, true);
packHeight = adjustSizeForPot(packHeight, true);
// Clamp to max
packWidth = Math.min(packWidth, config.maxWidth);
packHeight = Math.min(packHeight, config.maxHeight);
}
return placements;
}
/**
* Pack sprites using Shelf algorithm
*/
function packWithShelf(
sprites: PackerSprite[],
config: TextureAtlasConfig
): Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null {
const padding = config.padding;
const packer = new ShelfPacker(config.maxWidth, config.maxHeight, config.allowRotation, padding);
// Sort sprites by size (largest first) for better packing
const sortedSprites = sortSpritesBySize(sprites);
// Pack sprites one by one, tracking which ones fit
const placements = new Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }>();
for (const sprite of sortSpritesBySize(sprites)) {
const position = packer.insert(sprite.width, sprite.height);
if (!position) {
return null;
const unpackedSprites: PackerSprite[] = [];
// Create packer with max dimensions
if (config.algorithm === "MaxRects") {
const packer = new MaxRectsPacker(packWidth, packHeight, config.allowRotation);
for (const sprite of sortedSprites) {
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
// Check if sprite is too large even without packing
if (paddedWidth > packWidth || paddedHeight > packHeight) {
// Try rotated if allowed
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
const position = packer.insert(paddedHeight, paddedWidth);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: true,
});
continue;
}
}
unpackedSprites.push(sprite);
continue;
}
const position = packer.insert(paddedWidth, paddedHeight);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
} else {
unpackedSprites.push(sprite);
}
}
} else {
// Shelf algorithm
const packer = new ShelfPacker(packWidth, packHeight, config.allowRotation, padding);
for (const sprite of sortedSprites) {
const paddedWidth = sprite.width + padding * 2;
const paddedHeight = sprite.height + padding * 2;
// Check if sprite is too large
if (paddedWidth > packWidth || paddedHeight > packHeight) {
if (config.allowRotation && paddedHeight <= packWidth && paddedWidth <= packHeight) {
const position = packer.insert(sprite.height, sprite.width);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: true,
});
continue;
}
}
unpackedSprites.push(sprite);
continue;
}
const position = packer.insert(sprite.width, sprite.height);
if (position) {
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
} else {
unpackedSprites.push(sprite);
}
}
placements.set(sprite.id, {
x: position.x + padding,
y: position.y + padding,
width: sprite.width,
height: sprite.height,
rotated: position.rotated,
});
}
return placements;
// Calculate actual dimensions based on sprite placements
let maxX = 0;
let maxY = 0;
for (const placement of placements.values()) {
const effectiveWidth = placement.rotated ? placement.height : placement.width;
const effectiveHeight = placement.rotated ? placement.width : placement.height;
maxX = Math.max(maxX, placement.x + effectiveWidth + padding);
maxY = Math.max(maxY, placement.y + effectiveHeight + padding);
}
// Calculate the minimum required dimensions
let finalWidth: number;
let finalHeight: number;
if (config.pot) {
finalWidth = nextPowerOfTwo(Math.ceil(maxX));
finalHeight = nextPowerOfTwo(Math.ceil(maxY));
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
} else {
finalWidth = Math.ceil(maxX);
finalHeight = Math.ceil(maxY);
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
}
// Build result placements in original order
const resultPlacements: PackerPlacement[] = [];
const spriteIds: string[] = [];
for (const sprite of sprites) {
const placement = placements.get(sprite.id);
if (placement) {
resultPlacements.push({
id: sprite.id,
name: sprite.name,
...placement,
});
spriteIds.push(sprite.id);
}
}
return {
result: {
index: atlasIndex,
width: finalWidth || 1,
height: finalHeight || 1,
placements: resultPlacements,
spriteIds,
},
unpackedSprites,
};
}
/**
* Main packing function
* Supports multi-atlas mode
*/
export function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig): PackerResult | null {
export function packSprites(
sprites: PackerSprite[],
config: TextureAtlasConfig,
enableMultiAtlas: boolean = false
): PackerResult | null {
if (sprites.length === 0) {
return null;
}
const padding = config.padding;
const maxSpriteWidth = Math.max(...sprites.map((s) => s.width));
const maxSpriteHeight = Math.max(...sprites.map((s) => s.height));
// Calculate total area for estimation
const totalArea = sprites.reduce((sum, s) => {
const pw = s.width + padding * 2;
const ph = s.height + padding * 2;
return sum + pw * ph;
}, 0);
// Start with estimated size
const minSide = Math.ceil(Math.sqrt(totalArea / 0.85));
const estimatedWidth = Math.max(maxSpriteWidth + padding * 2, minSide);
const estimatedHeight = Math.max(maxSpriteHeight + padding * 2, minSide);
// Build size attempts
const sizeAttempts: { w: number; h: number }[] = [];
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 });
}
}
}
sizeAttempts.sort((a, b) => a.w * a.h - b.w * b.h);
} else {
sizeAttempts.push(
{ w: estimatedWidth, h: estimatedHeight },
{ 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
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
);
});
// Try each size
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;
}
const testConfig = { ...config, maxWidth: attemptWidth, maxHeight: attemptHeight };
const atlases: SinglePackerResult[] = [];
let remainingSprites = [...sprites];
let atlasIndex = 0;
// Pack into atlases
while (remainingSprites.length > 0) {
const { result, unpackedSprites } = packSingleAtlas(remainingSprites, config, atlasIndex);
let placements: Map<string, { x: number; y: number; width: number; height: number; rotated: boolean }> | null;
if (config.algorithm === "MaxRects") {
placements = packWithMaxRects(sprites, testConfig);
} else {
placements = packWithShelf(sprites, testConfig);
// If nothing was packed, we have sprites that are too large
if (result.placements.length === 0) {
break;
}
if (placements) {
// Calculate actual dimensions
let maxX = 0;
let maxY = 0;
for (const placement of placements.values()) {
const effectiveWidth = placement.rotated ? placement.height : placement.width;
const effectiveHeight = placement.rotated ? placement.width : placement.height;
maxX = Math.max(maxX, placement.x + effectiveWidth + padding);
maxY = Math.max(maxY, placement.y + effectiveHeight + padding);
}
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
finalWidth = Math.min(finalWidth, attemptWidth);
finalHeight = Math.min(finalHeight, attemptHeight);
// Build result
const resultPlacements: PackerPlacement[] = [];
for (const sprite of sprites) {
const placement = placements.get(sprite.id);
if (placement) {
resultPlacements.push({
id: sprite.id,
name: sprite.name,
...placement,
});
}
}
atlases.push(result);
// If multi-atlas is disabled, stop after first atlas
if (!enableMultiAtlas) {
// Return with unpacked sprites info
return {
width: finalWidth,
height: finalHeight,
placements: resultPlacements,
atlases,
packedCount: result.placements.length,
unpackedSprites,
};
}
// Continue with remaining sprites
remainingSprites = unpackedSprites;
atlasIndex++;
// Safety limit to prevent infinite loops
if (atlasIndex > 100) {
console.warn("Max atlas limit reached");
break;
}
}
if (atlases.length === 0) {
return null;
}
return null;
const totalPacked = atlases.reduce((sum, a) => sum + a.placements.length, 0);
return {
atlases,
packedCount: totalPacked,
unpackedSprites: remainingSprites,
};
}
/**

View File

@@ -358,10 +358,25 @@ function packSprites(sprites: PackerSprite[], config: TextureAtlasConfig, postPr
maxY = Math.max(maxY, p.y + effectiveHeight + padding);
}
let finalWidth = config.pot ? adjustSizeForPot(maxX, true) : Math.ceil(maxX);
let finalHeight = config.pot ? adjustSizeForPot(maxY, true) : Math.ceil(maxY);
finalWidth = Math.min(finalWidth, attemptWidth);
finalHeight = Math.min(finalHeight, attemptHeight);
// Calculate the minimum required dimensions based on actual content
let finalWidth: number;
let finalHeight: number;
if (config.pot) {
// Find smallest POT size that fits the actual content
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
// Ensure we don't exceed max limits
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
} else {
// For non-POT, use exact dimensions needed
finalWidth = Math.ceil(maxX);
finalHeight = Math.ceil(maxY);
// Ensure we don't exceed max limits
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
}
const resultPlacements: PackerPlacement[] = [];
for (const sprite of sprites) {

View File

@@ -540,16 +540,25 @@ export async function createTextureAtlas(
maxY = Math.max(maxY, placement.y + placement.height + padding);
}
// Adjust final dimensions based on actual usage if POT
// Calculate the minimum required dimensions based on actual content
// For POT mode: find the smallest power of two that fits the actual content
// For non-POT mode: use the actual content size
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);
// Find smallest POT size that fits the actual content
finalWidth = adjustSizeForPot(Math.ceil(maxX), true);
finalHeight = adjustSizeForPot(Math.ceil(maxY), true);
// Ensure we don't exceed max limits
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
} else {
// For non-POT, use exact dimensions needed
finalWidth = Math.ceil(maxX);
finalHeight = Math.ceil(maxY);
// Ensure we don't exceed max limits
finalWidth = Math.min(finalWidth, config.maxWidth);
finalHeight = Math.min(finalHeight, config.maxHeight);
}
success = true;

View File

@@ -31,7 +31,9 @@
"file": "File",
"files": "files",
"yes": "Yes",
"no": "No"
"no": "No",
"on": "On",
"off": "Off"
},
"nav": {
"tools": "Tools",
@@ -120,6 +122,10 @@
"title": "Audio Compression",
"description": "Compress and convert audio files to various formats. Adjust bitrate and sample rate."
},
"textureAtlas": {
"title": "Texture Atlas",
"description": "Combine multiple sprites into optimized texture atlases. Perfect for game performance."
},
"aiTools": {
"title": "More Tools",
"description": "Additional utilities for game development. Coming soon."
@@ -240,7 +246,11 @@
"outputCocosCreator": "Cocos Creator JSON",
"outputGeneric": "Generic JSON",
"algorithmMaxRects": "MaxRects (Best)",
"algorithmShelf": "Shelf (Fast)"
"algorithmShelf": "Shelf (Fast)",
"multiAtlas": "Multi-Atlas",
"multiAtlasDescription": "Auto pack overflow sprites into multiple atlases",
"compression": "PNG Compression",
"compressionDescription": "Compress PNG using quantization algorithm"
}
},
"tools": {
@@ -355,7 +365,19 @@
"fps": "FPS",
"spriteSize": "Sprite Size",
"totalFrames": "Total Frames",
"duration": "Duration"
"duration": "Duration",
"advancedSettings": "Advanced Settings",
"multiAtlasHint": "When enabled, sprites that exceed the max size will be packed into multiple atlases",
"unpackedCount": "{{count}} sprites could not fit",
"unpackedSuggestion": "Try increasing max size or enable multi-atlas mode",
"unpackedWarning": "{{count}} sprites could not fit in the atlas",
"unpackedHint": "Try increasing max size or enable multi-atlas mode",
"atlasCount": "Atlas Count",
"atlasIndex": "Atlas {{current}} / {{total}}",
"currentAtlas": "Current Atlas #{{index}}",
"downloadAllAtlases": "Download All ({{count}} atlases)",
"compressionSettings": "Compression",
"compressionHint": "Enable PNG quantization to significantly reduce file size (similar to TinyPNG)"
},
"footer": {
"tagline": "Media processing tools for game developers. Extract frames, compress images, optimize audio.",

View File

@@ -31,7 +31,9 @@
"file": "文件",
"files": "文件",
"yes": "是",
"no": "否"
"no": "否",
"on": "开启",
"off": "关闭"
},
"nav": {
"tools": "工具",
@@ -120,6 +122,10 @@
"title": "音频压缩",
"description": "压缩并转换音频文件为多种格式。调整比特率和采样率。"
},
"textureAtlas": {
"title": "合图工具",
"description": "将多张精灵图合并为优化的纹理图集。提升游戏性能的利器。"
},
"aiTools": {
"title": "更多工具",
"description": "更多游戏开发实用工具,敬请期待。"
@@ -240,7 +246,11 @@
"outputCocosCreator": "Cocos Creator JSON",
"outputGeneric": "通用 JSON",
"algorithmMaxRects": "MaxRects最优",
"algorithmShelf": "Shelf快速"
"algorithmShelf": "Shelf快速",
"multiAtlas": "多图打包",
"multiAtlasDescription": "超出尺寸的精灵自动打包到多张图片",
"compression": "PNG 压缩",
"compressionDescription": "使用量化算法压缩 PNG 图片"
}
},
"tools": {
@@ -355,7 +365,19 @@
"fps": "帧率",
"spriteSize": "精灵尺寸",
"totalFrames": "总帧数",
"duration": "时长"
"duration": "时长",
"advancedSettings": "高级设置",
"multiAtlasHint": "开启后,超出单张合图尺寸的精灵将自动打包到多张图片中",
"unpackedCount": "有 {{count}} 张图片未能放入",
"unpackedSuggestion": "建议增大最大尺寸,或开启多图打包功能",
"unpackedWarning": "{{count}} 张精灵图未能放入合图",
"unpackedHint": "请增大最大尺寸,或开启多图打包功能",
"atlasCount": "合图数量",
"atlasIndex": "合图 {{current}} / {{total}}",
"currentAtlas": "当前合图 #{{index}}",
"downloadAllAtlases": "打包下载全部 ({{count}} 张)",
"compressionSettings": "压缩设置",
"compressionHint": "开启后使用 PNG 量化压缩,可大幅减小文件体积(类似 TinyPNG"
},
"footer": {
"tagline": "面向游戏开发者的媒体处理工具。视频抽帧、图片压缩、音频优化。",

View File

@@ -15,14 +15,41 @@ export interface BrowserSprite {
}
/**
* Complete atlas result
* Single atlas result
*/
export interface AtlasResult {
export interface SingleAtlasResult {
index: number;
width: number;
height: number;
placements: PackerPlacement[];
frames: AtlasFrame[];
imageDataUrl: string | null;
/** Sprite IDs in this atlas */
spriteIds: string[];
}
/**
* Complete atlas result (supports multiple atlases)
*/
export interface AtlasResult {
/** All atlases */
atlases: SingleAtlasResult[];
/** Total number of packed sprites */
packedCount: number;
/** Sprites that couldn't fit in any atlas (only when multi-atlas is disabled) */
unpackedSpriteIds: string[];
}
/**
* Legacy single result format for backward compatibility
*/
export interface LegacyAtlasResult {
width: number;
height: number;
placements: PackerPlacement[];
frames: AtlasFrame[];
imageDataUrl: string | null;
unpackedSpriteIds: string[];
}
/**
@@ -41,14 +68,23 @@ interface AtlasState {
// Configuration
config: TextureAtlasConfig;
// Multi-atlas mode
enableMultiAtlas: boolean;
// Compression mode (PNG quantization)
enableCompression: boolean;
// Processing state
status: AtlasProcessStatus;
progress: number;
errorMessage: string | null;
// Result
// Result (supports multiple atlases)
result: AtlasResult | null;
// Current preview atlas index
currentAtlasIndex: number;
// Preview state
previewScale: number;
previewOffset: { x: number; y: number };
@@ -67,11 +103,15 @@ interface AtlasState {
updateConfig: (config: Partial<TextureAtlasConfig>) => void;
resetConfig: () => void;
setEnableMultiAtlas: (enable: boolean) => void;
setEnableCompression: (enable: boolean) => void;
setStatus: (status: AtlasProcessStatus) => void;
setProgress: (progress: number) => void;
setError: (message: string | null) => void;
setResult: (result: AtlasResult | null) => void;
setCurrentAtlasIndex: (index: number) => void;
setPreviewScale: (scale: number) => void;
setPreviewOffset: (offset: { x: number; y: number }) => void;
@@ -81,6 +121,9 @@ interface AtlasState {
openAnimationDialog: () => void;
closeAnimationDialog: () => void;
setAnimationFps: (fps: number) => void;
// Computed helpers
getCurrentAtlas: () => SingleAtlasResult | null;
}
/**
@@ -91,7 +134,7 @@ const defaultConfig: TextureAtlasConfig = {
maxHeight: 1024,
padding: 2,
allowRotation: false,
pot: true,
pot: false,
format: "png",
quality: 90,
outputFormat: "cocos2d",
@@ -106,10 +149,13 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
sprites: [],
folderName: "",
config: { ...defaultConfig },
enableMultiAtlas: false,
enableCompression: false,
status: "idle",
progress: 0,
errorMessage: null,
result: null,
currentAtlasIndex: 0,
previewScale: 1,
previewOffset: { x: 0, y: 0 },
selectedSpriteIds: [],
@@ -128,7 +174,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" })
);
return { sprites: allSprites, result: null };
return { sprites: allSprites, result: null, currentAtlasIndex: 0 };
});
},
@@ -137,6 +183,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
sprites: state.sprites.filter((s) => s.id !== id),
selectedSpriteIds: state.selectedSpriteIds.filter((sid) => sid !== id),
result: null,
currentAtlasIndex: 0,
}));
},
@@ -149,6 +196,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
sprites: [],
folderName: "",
result: null,
currentAtlasIndex: 0,
selectedSpriteIds: [],
status: "idle",
progress: 0,
@@ -163,10 +211,17 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
set((state) => ({
config: { ...state.config, ...partialConfig },
result: null, // Clear result when config changes
currentAtlasIndex: 0,
}));
},
resetConfig: () => set({ config: { ...defaultConfig }, result: null }),
resetConfig: () => set({ config: { ...defaultConfig }, result: null, currentAtlasIndex: 0 }),
// Multi-atlas mode
setEnableMultiAtlas: (enable) => set({ enableMultiAtlas: enable, result: null, currentAtlasIndex: 0 }),
// Compression mode (only affects download, not preview)
setEnableCompression: (enable) => set({ enableCompression: enable }),
// Status actions
setStatus: (status) => set({ status }),
@@ -174,7 +229,14 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
setError: (message) => set({ errorMessage: message, status: message ? "error" : "idle" }),
// Result actions
setResult: (result) => set({ result, status: result ? "completed" : "idle" }),
setResult: (result) => set({ result, status: result ? "completed" : "idle", currentAtlasIndex: 0 }),
setCurrentAtlasIndex: (index) => {
const { result } = get();
if (result && index >= 0 && index < result.atlases.length) {
set({ currentAtlasIndex: index, previewOffset: { x: 0, y: 0 } });
}
},
// Preview actions
setPreviewScale: (scale) => set({ previewScale: Math.max(0.1, Math.min(4, scale)) }),
@@ -191,7 +253,7 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
: [...state.selectedSpriteIds, id],
};
}
return { selectedSpriteIds: [id] };
return { selectedSpriteIds: id ? [id] : [] };
});
},
@@ -201,6 +263,13 @@ export const useAtlasStore = create<AtlasState>((set, get) => ({
openAnimationDialog: () => set({ isAnimationDialogOpen: true }),
closeAnimationDialog: () => set({ isAnimationDialogOpen: false }),
setAnimationFps: (fps) => set({ animationFps: Math.max(1, Math.min(60, fps)) }),
// Computed helpers
getCurrentAtlas: () => {
const { result, currentAtlasIndex } = get();
if (!result || result.atlases.length === 0) return null;
return result.atlases[currentAtlasIndex] || null;
},
}));
/**
@@ -218,3 +287,7 @@ export const useAtlasPreview = () => useAtlasStore((state) => ({
scale: state.previewScale,
offset: state.previewOffset,
}));
export const useCurrentAtlas = () => useAtlasStore((state) => {
if (!state.result || state.result.atlases.length === 0) return null;
return state.result.atlases[state.currentAtlasIndex] || null;
});

54
src/types/upng-js.d.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
declare module "upng-js" {
/**
* Encode RGBA image data to PNG
* @param imgs Array of ArrayBuffer containing RGBA data
* @param w Width of the image
* @param h Height of the image
* @param cnum Number of colors (0 = lossless, 256 = 8-bit quantization)
* @param dels Optional delays for APNG frames
* @returns ArrayBuffer containing PNG data
*/
export function encode(
imgs: ArrayBuffer[],
w: number,
h: number,
cnum: number,
dels?: number[]
): ArrayBuffer;
/**
* Decode PNG to RGBA image data
* @param buffer PNG data
* @returns Decoded image info
*/
export function decode(buffer: ArrayBuffer): {
width: number;
height: number;
depth: number;
ctype: number;
frames: Array<{
rect: { x: number; y: number; width: number; height: number };
delay: number;
dispose: number;
blend: number;
}>;
tabs: Record<string, unknown>;
data: Uint8Array;
};
/**
* Convert decoded PNG to RGBA format
* @param img Decoded image from decode()
* @param frameIndex Frame index for APNG (default 0)
* @returns Uint8Array of RGBA data
*/
export function toRGBA8(img: ReturnType<typeof decode>, frameIndex?: number): Uint8Array[];
const UPNG: {
encode: typeof encode;
decode: typeof decode;
toRGBA8: typeof toRGBA8;
};
export default UPNG;
}