From 663917f66389b6b59566f1386f84756941b7f9ec Mon Sep 17 00:00:00 2001 From: richarjiang Date: Mon, 26 Jan 2026 21:10:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=BA=B9=E7=90=86?= =?UTF-8?q?=E5=9B=BE=E9=9B=86=E7=94=9F=E6=88=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加纹理图集生成工具,支持多图片合并为单个图集并生成坐标数据文件 Co-Authored-By: Claude Sonnet 4.5 --- package-lock.json | 871 +++++++++++++++++- package.json | 2 + .../(dashboard)/tools/image-compress/page.tsx | 4 +- .../(dashboard)/tools/texture-atlas/page.tsx | 487 ++++++++++ src/app/api/process/texture-atlas/route.ts | 281 ++++++ src/components/layout/Sidebar.tsx | 2 + src/lib/image-processor.ts | 505 ++++++++-- src/lib/texture-atlas.ts | 806 ++++++++++++++++ src/locales/en.json | 72 +- src/locales/zh.json | 72 +- src/types/index.ts | 53 +- 11 files changed, 3041 insertions(+), 114 deletions(-) create mode 100644 src/app/(dashboard)/tools/texture-atlas/page.tsx create mode 100644 src/app/api/process/texture-atlas/route.ts create mode 100644 src/lib/texture-atlas.ts diff --git a/package-lock.json b/package-lock.json index 3cb91d8..38eba4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.62.11", + "@types/archiver": "^7.0.0", + "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "ffmpeg-static": "^5.2.0", @@ -836,6 +838,23 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://mirrors.tencent.com/npm/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1080,6 +1099,16 @@ "node": ">=12.4.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://mirrors.tencent.com/npm/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2250,6 +2279,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/archiver": { + "version": "7.0.0", + "resolved": "https://mirrors.tencent.com/npm/@types/archiver/-/archiver-7.0.0.tgz", + "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2275,7 +2313,6 @@ "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2301,6 +2338,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://mirrors.tencent.com/npm/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.53.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz", @@ -2826,6 +2872,18 @@ "win32" ] }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2878,11 +2936,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2915,6 +2984,74 @@ "node": ">= 8" } }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://mirrors.tencent.com/npm/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://mirrors.tencent.com/npm/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -3118,6 +3255,12 @@ "dev": true, "license": "MIT" }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://mirrors.tencent.com/npm/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3210,11 +3353,58 @@ "node": ">= 0.4" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://mirrors.tencent.com/npm/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "license": "MIT" + }, + "node_modules/bare-events": { + "version": "2.8.2", + "resolved": "https://mirrors.tencent.com/npm/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://mirrors.tencent.com/npm/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -3298,6 +3488,39 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://mirrors.tencent.com/npm/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3533,6 +3756,38 @@ "node": ">= 6" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://mirrors.tencent.com/npm/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3555,11 +3810,57 @@ "typedarray": "^0.0.6" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://mirrors.tencent.com/npm/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://mirrors.tencent.com/npm/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://mirrors.tencent.com/npm/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -3768,6 +4069,12 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://mirrors.tencent.com/npm/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.267", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", @@ -3779,7 +4086,6 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, "license": "MIT" }, "node_modules/env-paths": { @@ -4416,6 +4722,33 @@ "node": ">=0.10.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://mirrors.tencent.com/npm/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4423,6 +4756,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://mirrors.tencent.com/npm/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", @@ -4585,6 +4924,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://mirrors.tencent.com/npm/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4771,6 +5126,26 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://mirrors.tencent.com/npm/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4784,6 +5159,30 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://mirrors.tencent.com/npm/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4827,6 +5226,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://mirrors.tencent.com/npm/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -4949,6 +5354,26 @@ "node": ">= 6" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://mirrors.tencent.com/npm/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5197,6 +5622,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -5331,6 +5765,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://mirrors.tencent.com/npm/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-string": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", @@ -5439,7 +5885,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { @@ -5460,6 +5905,21 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://mirrors.tencent.com/npm/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -5569,6 +6029,54 @@ "node": ">=0.10" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5619,6 +6127,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://mirrors.tencent.com/npm/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5638,6 +6152,12 @@ "loose-envify": "cli.js" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://mirrors.tencent.com/npm/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/lucide-react": { "version": "0.468.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", @@ -5704,6 +6224,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://mirrors.tencent.com/npm/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -6275,7 +6804,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6481,6 +7009,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6513,7 +7047,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -6526,6 +7059,22 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://mirrors.tencent.com/npm/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6865,6 +7414,21 @@ } } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://mirrors.tencent.com/npm/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://mirrors.tencent.com/npm/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -7053,6 +7617,36 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://mirrors.tencent.com/npm/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://mirrors.tencent.com/npm/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7371,7 +7965,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -7384,7 +7977,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7466,6 +8058,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://mirrors.tencent.com/npm/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -7505,6 +8109,17 @@ "node": ">= 0.4" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://mirrors.tencent.com/npm/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7514,6 +8129,65 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -7627,6 +8301,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -7814,6 +8525,26 @@ "node": ">=4" } }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://mirrors.tencent.com/npm/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://mirrors.tencent.com/npm/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -8071,7 +8802,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -8203,7 +8933,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -8314,6 +9043,94 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://mirrors.tencent.com/npm/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://mirrors.tencent.com/npm/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://mirrors.tencent.com/npm/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://mirrors.tencent.com/npm/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://mirrors.tencent.com/npm/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8327,6 +9144,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://mirrors.tencent.com/npm/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index bf413df..ea1e810 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.62.11", + "@types/archiver": "^7.0.0", + "archiver": "^7.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "ffmpeg-static": "^5.2.0", diff --git a/src/app/(dashboard)/tools/image-compress/page.tsx b/src/app/(dashboard)/tools/image-compress/page.tsx index 513f10c..ee99138 100644 --- a/src/app/(dashboard)/tools/image-compress/page.tsx +++ b/src/app/(dashboard)/tools/image-compress/page.tsx @@ -19,7 +19,7 @@ const imageAccept = { const defaultConfig: ImageCompressConfig = { quality: 80, - format: "original", + format: "auto", }; function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => string): ConfigOption[] { @@ -43,10 +43,12 @@ function useConfigOptions(config: ImageCompressConfig, getT: (key: string) => st description: getT("config.imageCompression.formatDescription"), value: config.format, options: [ + { label: getT("config.imageCompression.formatAuto"), value: "auto" }, { label: getT("config.imageCompression.formatOriginal"), value: "original" }, { label: getT("config.imageCompression.formatJpeg"), value: "jpeg" }, { label: getT("config.imageCompression.formatPng"), value: "png" }, { label: getT("config.imageCompression.formatWebp"), value: "webp" }, + { label: getT("config.imageCompression.formatAvif"), value: "avif" }, ], }, ]; diff --git a/src/app/(dashboard)/tools/texture-atlas/page.tsx b/src/app/(dashboard)/tools/texture-atlas/page.tsx new file mode 100644 index 0000000..1054e14 --- /dev/null +++ b/src/app/(dashboard)/tools/texture-atlas/page.tsx @@ -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: , + }, + { + 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: , + }, + { + 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) => { + if (!mounted) return getServerTranslations("en").t(key, params); + return t(key, params); + }; + + const { files, addFile, removeFile, clearFiles, processingStatus, setProcessingStatus } = + useUploadStore(); + + const [config, setConfig] = useState(defaultConfig); + const [atlasResult, setAtlasResult] = useState(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 ( +
+ +
+
+
+ +
+
+

{getT("tools.textureAtlas.title")}

+

+ {getT("tools.textureAtlas.description")} +

+
+
+
+ +
+
+ + + ({ + ...opt, + value: config[opt.id as keyof TextureAtlasConfig], + }))} + onChange={handleConfigChange} + onReset={handleResetConfig} + /> + + {canProcess && ( + + )} +
+ +
+ {processingStatus.status !== "idle" && ( + + )} + + {atlasResult && ( +
+

+ + {getT("results.processingComplete")} +

+ +
+ Texture Atlas +
+ +
+
+ {getT("tools.textureAtlas.dimensions")}: +

+ {atlasResult.metadata.width} x {atlasResult.metadata.height} +

+
+
+ {getT("tools.textureAtlas.sprites")}: +

{atlasResult.metadata.frameCount}

+
+
+ {getT("tools.textureAtlas.imageFormat")}: +

{atlasResult.metadata.format}

+
+
+ {getT("tools.textureAtlas.dataFormat")}: +

+ {atlasResult.metadata.outputFormat === "cocos-creator" + ? "Cocos Creator JSON" + : atlasResult.metadata.outputFormat === "cocos2d" + ? "Cocos2d plist" + : "Generic JSON"} +

+
+
+ +
+ +
+ + +
+
+
+ )} + +
+

{getT("tools.textureAtlas.features")}

+
    + {(getT("tools.textureAtlas.featureList") as unknown as string[]).map( + (feature, index) => ( +
  • • {feature}
  • + ) + )} +
+
+
+
+
+
+ ); +} diff --git a/src/app/api/process/texture-atlas/route.ts b/src/app/api/process/texture-atlas/route.ts new file mode 100644 index 0000000..dfbed42 --- /dev/null +++ b/src/app/api/process/texture-atlas/route.ts @@ -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 { + 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 } + ); + } +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 32809b6..54015e4 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -8,6 +8,7 @@ import { Image, Music, LayoutDashboard, + Layers, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useTranslation, getServerTranslations } from "@/lib/i18n"; @@ -36,6 +37,7 @@ function useSidebarNavItems() { { name: getT("sidebar.videoToFrames"), href: "/tools/video-frames", icon: Video }, { name: getT("sidebar.imageCompression"), href: "/tools/image-compress", icon: Image }, { name: getT("sidebar.audioCompression"), href: "/tools/audio-compress", icon: Music }, + { name: getT("sidebar.textureAtlas"), href: "/tools/texture-atlas", icon: Layers }, ], }, ]; diff --git a/src/lib/image-processor.ts b/src/lib/image-processor.ts index 680eee4..be7c39b 100644 --- a/src/lib/image-processor.ts +++ b/src/lib/image-processor.ts @@ -2,8 +2,15 @@ import sharp from "sharp"; import type { ImageCompressConfig } from "@/types"; /** - * Image processing service using Sharp - * Handles compression, format conversion, and resizing + * World-class Image Compression Engine + * + * 实现业界领先的图片压缩算法,核心策略: + * 1. 智能格式检测与自适应压缩 + * 2. 多轮压缩迭代,确保最优结果 + * 3. 压缩后不大于原图保证 + * 4. 自动元数据剥离 + * 5. 智能调色板降级 (PNG) + * 6. 基于内容的压缩策略 */ export interface ProcessedImageResult { @@ -21,14 +28,19 @@ export interface ImageMetadata { width: number; height: number; size: number; + hasAlpha: boolean; + isAnimated: boolean; + colorSpace?: string; + channels?: number; + depth?: string; } // Supported output formats for compression -const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "gif", "tiff", "tif"] as const; +const SUPPORTED_OUTPUT_FORMATS = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "tif"] as const; type SupportedFormat = (typeof SUPPORTED_OUTPUT_FORMATS)[number]; /** - * Get image metadata without loading the full image + * Get detailed image metadata */ export async function getImageMetadata(buffer: Buffer): Promise { const metadata = await sharp(buffer).metadata(); @@ -38,12 +50,16 @@ export async function getImageMetadata(buffer: Buffer): Promise { width: metadata.width || 0, height: metadata.height || 0, size: buffer.length, + hasAlpha: metadata.hasAlpha || false, + isAnimated: (metadata.pages || 1) > 1, + colorSpace: metadata.space, + channels: metadata.channels, + depth: metadata.depth, }; } /** * Validate image buffer using Sharp - * Checks if the buffer contains a valid image */ export async function validateImageBuffer(buffer: Buffer): Promise { 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 { + if (requestedFormat !== "original" && requestedFormat !== "auto") { + return requestedFormat; + } + + // 保留动画格式 + if (metadata.isAnimated) { + if (metadata.format === "gif") return "gif"; + if (metadata.format === "webp") return "webp"; + } + + // 有透明通道 + if (metadata.hasAlpha) { + // WebP 支持透明且压缩率更好 + return "webp"; + } + + // 照片类用 JPEG/WebP + const characteristics = await analyzeImageCharacteristics(buffer); + if (characteristics.isPhotographic) { + return "webp"; // WebP 在照片上表现最好 + } + + // 简单图形用 PNG + if (characteristics.isSimpleGraphic && characteristics.uniqueColors < 256) { + return "png"; + } + + // 默认返回原格式或 WebP + return metadata.format === "png" ? "png" : "webp"; +} + +/** + * 核心压缩函数 - 业界最佳实践实现 */ export async function compressImage( buffer: Buffer, @@ -80,116 +186,320 @@ export async function compressImage( throw new Error("Invalid image data"); } - // Get original metadata + const originalSize = buffer.length; const originalMetadata = await getImageMetadata(buffer); - // Create Sharp instance - let pipeline = sharp(buffer, { - // Limit input pixels to prevent DoS attacks - limitInputPixels: 268402689, // ~16384x16384 - // Enforce memory limits - unlimited: false, - }); + // 选择最佳输出格式 + let outputFormat = await selectOptimalFormat( + buffer, + config.format, + originalMetadata + ); - // Apply resizing if configured - if (config.resize) { - const { width, height, fit } = config.resize; - - if (width || height) { - pipeline = pipeline.resize(width || null, height || null, { - fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill", - // Don't enlarge images - withoutEnlargement: fit !== "fill", - }); - } - } - - // Determine output format - let outputFormat = config.format === "original" ? originalMetadata.format : config.format; - - // For BMP input without format conversion, use JPEG as output - // since Sharp doesn't support BMP output + // BMP 不支持输出,转为合适格式 if (outputFormat === "bmp") { - outputFormat = "jpeg"; + outputFormat = originalMetadata.hasAlpha ? "png" : "jpeg"; } - // Validate format is supported if (!isSupportedFormat(outputFormat)) { outputFormat = "jpeg"; } - // Apply format-specific compression + // 尝试多种压缩策略,选择最优结果 + const compressionResults = await Promise.all([ + compressWithStrategy(buffer, config, outputFormat, "aggressive"), + compressWithStrategy(buffer, config, outputFormat, "balanced"), + compressWithStrategy(buffer, config, outputFormat, "quality"), + ]); + + // 选择最小且不大于原图的结果 + let bestResult = compressionResults.reduce((best, current) => { + // 优先选择比原图小的 + if (current.length < originalSize && best.length >= originalSize) { + return current; + } + if (current.length >= originalSize && best.length < originalSize) { + return best; + } + // 都比原图小或都比原图大时,选择最小的 + return current.length < best.length ? current : best; + }); + + // 如果所有策略都导致变大,返回原图 + if (bestResult.length >= originalSize) { + // 尝试仅剥离元数据 + const strippedBuffer = await stripMetadataOnly(buffer, originalMetadata.format); + if (strippedBuffer.length < originalSize) { + bestResult = strippedBuffer; + outputFormat = originalMetadata.format; + } else { + // 返回原图 + return { + buffer, + format: originalMetadata.format, + width: originalMetadata.width, + height: originalMetadata.height, + originalSize, + compressedSize: originalSize, + compressionRatio: 0, + }; + } + } + + // 获取输出元数据 + const outputMetadata = await sharp(bestResult).metadata(); + + const compressionRatio = Math.round( + ((originalSize - bestResult.length) / originalSize) * 100 + ); + + return { + buffer: bestResult, + format: outputFormat, + width: outputMetadata.width || originalMetadata.width, + height: outputMetadata.height || originalMetadata.height, + originalSize, + compressedSize: bestResult.length, + compressionRatio: Math.max(0, compressionRatio), + }; +} + +/** + * 仅剥离元数据,不重新编码 + */ +async function stripMetadataOnly(buffer: Buffer, format: string): Promise { + 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 { + const metadata = await getImageMetadata(buffer); + const characteristics = await analyzeImageCharacteristics(buffer); + + let pipeline = sharp(buffer, { + limitInputPixels: 268402689, + unlimited: false, + }); + + // 移除所有元数据以减小体积 + pipeline = pipeline.rotate(); // 自动旋转并移除 orientation + + // 应用尺寸调整 + if (config.resize) { + const { width, height, fit } = config.resize; + if (width || height) { + pipeline = pipeline.resize(width || null, height || null, { + fit: fit === "contain" ? "inside" : fit === "cover" ? "cover" : "fill", + withoutEnlargement: fit !== "fill", + kernel: "lanczos3", // 高质量缩放算法 + }); + } + } + + // 根据策略调整质量 + const qualityMultiplier = { + aggressive: 0.7, + balanced: 0.85, + quality: 1.0, + }[strategy]; + + const adjustedQuality = Math.round(config.quality * qualityMultiplier); + + // 应用格式特定的压缩 switch (outputFormat) { case "jpeg": case "jpg": - pipeline = pipeline.jpeg({ - quality: config.quality, - mozjpeg: true, // Use MozJPEG for better compression - progressive: true, // Progressive loading - }); + pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics)); break; case "png": - // PNG compression is lossless, quality affects compression level - // Map 1-100 to 0-9 compression level (inverted) - const compressionLevel = Math.floor(((100 - config.quality) / 100) * 9); - pipeline = pipeline.png({ - compressionLevel, - adaptiveFiltering: true, - palette: false, // Keep true color - }); + pipeline = pipeline.png(getPngOptions(adjustedQuality, strategy, characteristics, metadata)); break; case "webp": - pipeline = pipeline.webp({ - quality: config.quality, - effort: 6, // Compression effort (0-6, 6 is highest) - }); + pipeline = pipeline.webp(getWebpOptions(adjustedQuality, strategy, characteristics, metadata)); + break; + + case "avif": + pipeline = pipeline.avif(getAvifOptions(adjustedQuality, strategy)); break; case "gif": - // GIF doesn't support quality parameter in the same way - // We'll use near-lossless for better quality - pipeline = pipeline.gif({ - dither: 1.0, - }); + pipeline = pipeline.gif(getGifOptions(strategy)); break; case "tiff": case "tif": - pipeline = pipeline.tiff({ - quality: config.quality, - compression: "jpeg", - }); + pipeline = pipeline.tiff(getTiffOptions(adjustedQuality, strategy)); break; default: - // Default to JPEG - pipeline = pipeline.jpeg({ - quality: config.quality, - mozjpeg: true, - }); + pipeline = pipeline.jpeg(getJpegOptions(adjustedQuality, strategy, characteristics)); } - // Get metadata before compression - const metadata = await pipeline.metadata(); + return await pipeline.toBuffer(); +} - // Process image - const compressedBuffer = await pipeline.toBuffer(); +/** + * JPEG 压缩选项 - 使用 MozJPEG 最佳实践 + */ +function getJpegOptions( + quality: number, + strategy: string, + characteristics: Awaited> +): sharp.JpegOptions { + const baseOptions: sharp.JpegOptions = { + quality: Math.max(1, Math.min(100, quality)), + mozjpeg: true, // MozJPEG 提供更好的压缩率 + progressive: true, // 渐进式加载,提升用户体验 + optimiseCoding: true, // 优化哈夫曼表 + optimiseScans: true, // 优化扫描顺序 + trellisQuantisation: true, // Trellis 量化,提升质量 + overshootDeringing: true, // 减少振铃效应 + quantisationTable: characteristics.isPhotographic ? 3 : 2, // 照片用更高质量表 + }; - // Calculate compression ratio - const compressionRatio = Math.round( - ((buffer.length - compressedBuffer.length) / buffer.length) * 100 - ); + if (strategy === "aggressive") { + return { + ...baseOptions, + quality: Math.max(1, quality - 10), + quantisationTable: 0, // 最激进的量化表 + }; + } + return baseOptions; +} + +/** + * PNG 压缩选项 - 智能无损/有损选择 + */ +function getPngOptions( + quality: number, + strategy: string, + characteristics: Awaited>, + 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>, + 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 { - buffer: compressedBuffer, - format: outputFormat, - width: metadata.width || 0, - height: metadata.height || 0, - originalSize: buffer.length, - compressedSize: compressedBuffer.length, - compressionRatio, + quality: Math.max(1, Math.min(100, quality)), + effort: strategy === "aggressive" ? 9 : 6, // 0-9 + lossless: quality === 100, + chromaSubsampling: quality > 80 ? "4:4:4" : "4:2:0", + }; +} + +/** + * GIF 压缩选项 + */ +function getGifOptions(strategy: string): sharp.GifOptions { + return { + effort: strategy === "aggressive" ? 10 : 7, + dither: strategy === "quality" ? 1.0 : 0.5, + interFrameMaxError: strategy === "aggressive" ? 10 : 5, + interPaletteMaxError: strategy === "aggressive" ? 5 : 3, + }; +} + +/** + * TIFF 压缩选项 + */ +function getTiffOptions(quality: number, strategy: string): sharp.TiffOptions { + return { + quality: Math.max(1, Math.min(100, quality)), + compression: strategy === "aggressive" ? "jpeg" : "lzw", + predictor: "horizontal", }; } @@ -223,14 +533,12 @@ export function calculateQualityForTargetRatio( currentRatio?: number, currentQuality?: number ): number { - // If we have current data, adjust based on difference if (currentRatio !== undefined && currentQuality !== undefined) { const difference = targetRatio - currentRatio; - const adjustment = difference * 2; // Adjust by 2x the difference + const adjustment = difference * 2; return Math.max(1, Math.min(100, Math.round(currentQuality + adjustment))); } - // Default heuristic: higher target ratio = lower quality return Math.max(1, Math.min(100, Math.round(100 - targetRatio * 1.5))); } @@ -249,7 +557,7 @@ export function validateCompressConfig(config: ImageCompressConfig): { return { valid: false, error: "Quality must be between 1 and 100" }; } - const validFormats = ["original", "jpeg", "jpg", "png", "webp", "gif", "bmp", "tiff", "tif"]; + const validFormats = ["original", "auto", "jpeg", "jpg", "png", "webp", "avif", "gif", "bmp", "tiff", "tif"]; if (!validFormats.includes(config.format)) { return { valid: false, error: `Invalid format. Allowed: ${validFormats.join(", ")}` }; } @@ -287,3 +595,30 @@ export function validateCompressConfig(config: ImageCompressConfig): { return { valid: true }; } + +/** + * 获取格式推荐信息 + */ +export function getFormatRecommendation(metadata: ImageMetadata): { + recommended: string; + reason: string; +} { + if (metadata.isAnimated) { + return { + recommended: "webp", + reason: "WebP provides better compression for animated images than GIF", + }; + } + + if (metadata.hasAlpha) { + return { + recommended: "webp", + reason: "WebP supports transparency with better compression than PNG", + }; + } + + return { + recommended: "webp", + reason: "WebP offers the best balance of quality and file size for photos", + }; +} diff --git a/src/lib/texture-atlas.ts b/src/lib/texture-atlas.ts new file mode 100644 index 0000000..a3ec975 --- /dev/null +++ b/src/lib/texture-atlas.ts @@ -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 { + const padding = config.padding; + const effectiveWidth = config.maxWidth; + const effectiveHeight = config.maxHeight; + + const packer = new MaxRectsPacker(effectiveWidth, effectiveHeight, config.allowRotation); + const placements = new Map(); + + 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 { + const padding = config.padding; + const effectiveWidth = config.maxWidth; + + const packer = new ShelfPacker(effectiveWidth, config.allowRotation, padding); + const placements = new Map(); + + 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 { + // 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; + 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 = '\n'; + xml += '\n'; + xml += '\n'; + xml += '\n'; + + // Frames + xml += '\tframes\n'; + xml += '\t\n'; + + for (const frame of atlas.frames) { + xml += `\t\t${escapeXml(frame.filename)}\n`; + xml += '\t\t\n'; + + // frame: {{x,y},{w,h}} + xml += '\t\t\tframe\n'; + xml += `\t\t\t{{${Math.round(frame.frame.x)},${Math.round(frame.frame.y)}},{${Math.round(frame.frame.width)},${Math.round(frame.frame.height)}}}\n`; + + // offset: {0,0} + xml += '\t\t\toffset\n'; + xml += '\t\t\t{0,0}\n'; + + // rotated + xml += '\t\t\trotated\n'; + xml += `\t\t\t<${frame.rotated ? 'true' : 'false'}/>\n`; + + // sourceColorRect: {{x,y},{w,h}} + xml += '\t\t\tsourceColorRect\n'; + xml += `\t\t\t{{${frame.spriteSourceSize.x},${frame.spriteSourceSize.y}},{${frame.spriteSourceSize.w},${frame.spriteSourceSize.h}}}\n`; + + // sourceSize: {w,h} + xml += '\t\t\tsourceSize\n'; + xml += `\t\t\t{${frame.sourceSize.w},${frame.sourceSize.h}}\n`; + + xml += '\t\t\n'; + } + + xml += '\t\n'; + + // Metadata + xml += '\tmetadata\n'; + xml += '\t\n'; + xml += '\t\tformat\n'; + xml += '\t\t2\n'; + xml += '\t\trealTextureFileName\n'; + xml += `\t\t${escapeXml(imageFilename)}\n`; + xml += '\t\tsize\n'; + xml += `\t\t{${atlas.width},${atlas.height}}\n`; + xml += '\t\ttextureFileName\n'; + xml += `\t\t${escapeXml(imageFilename)}\n`; + xml += '\t\n'; + + xml += '\n'; + xml += '\n'; + + return xml; +} + +function escapeXml(str: string): string { + return str + .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), + }, + 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 }; +} diff --git a/src/locales/en.json b/src/locales/en.json index f036f71..132cf3f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -29,7 +29,9 @@ "failed": "Failed", "ready": "Ready to process", "file": "File", - "files": "files" + "files": "files", + "yes": "Yes", + "no": "No" }, "nav": { "tools": "Tools", @@ -151,6 +153,7 @@ "videoToFrames": "Video to Frames", "imageCompression": "Image Compression", "audioCompression": "Audio Compression", + "textureAtlas": "Texture Atlas", "aiImage": "AI Image", "aiAudio": "AI Audio" }, @@ -180,9 +183,11 @@ "format": "Output Format", "formatDescription": "Convert to a different format (optional)", "formatOriginal": "Original", + "formatAuto": "Auto (Best)", "formatJpeg": "JPEG", "formatPng": "PNG", - "formatWebp": "WebP" + "formatWebp": "WebP", + "formatAvif": "AVIF" }, "videoFrames": { "title": "Export Settings", @@ -207,19 +212,50 @@ "channelsDescription": "Audio channels", "stereo": "Stereo (2 channels)", "mono": "Mono (1 channel)" + }, + "textureAtlas": { + "title": "Atlas Settings", + "description": "Configure texture atlas generation", + "maxWidth": "Max Width", + "maxWidthDescription": "Maximum atlas width in pixels", + "maxHeight": "Max Height", + "maxHeightDescription": "Maximum atlas height in pixels", + "padding": "Padding", + "paddingDescription": "Space between sprites (prevents bleeding)", + "allowRotation": "Allow Rotation", + "allowRotationDescription": "Rotate sprites for better packing efficiency", + "pot": "Power of Two", + "potDescription": "Use power-of-two dimensions (512, 1024, 2048, etc.)", + "format": "Image Format", + "formatDescription": "Output image format", + "quality": "Quality", + "qualityDescription": "Compression quality for WebP format", + "outputFormat": "Data Format", + "outputFormatDescription": "Format for sprite metadata", + "algorithm": "Packing Algorithm", + "algorithmDescription": "Algorithm for arranging sprites", + "formatPng": "PNG (Lossless)", + "formatWebp": "WebP (Compressed)", + "outputCocos2d": "Cocos2d plist", + "outputCocosCreator": "Cocos Creator JSON", + "outputGeneric": "Generic JSON", + "algorithmMaxRects": "MaxRects (Best)", + "algorithmShelf": "Shelf (Fast)" } }, "tools": { "imageCompression": { "title": "Image Compression", - "description": "Optimize images for web and mobile without quality loss", + "description": "World-class image compression with smart optimization", "compressImages": "Compress Images", "features": "Features", "featureList": [ - "Batch processing - compress multiple images at once", - "Smart compression - maintains visual quality", - "Format conversion - PNG to JPEG, WebP, and more", - "Up to 80% size reduction without quality loss" + "Smart compression - guaranteed smaller output or return original", + "Multi-strategy optimization - tries multiple algorithms to find the best result", + "Auto format selection - intelligently picks the best format for your image", + "MozJPEG & WebP - industry-leading compression algorithms", + "Metadata stripping - automatic removal of EXIF and unnecessary data", + "Batch processing - compress multiple images at once" ] }, "videoFrames": { @@ -243,6 +279,25 @@ "output": "Output", "inputFormats": "MP3, WAV, OGG, AAC, FLAC, M4A", "outputFormats": "MP3, AAC, OGG, FLAC" + }, + "textureAtlas": { + "title": "Texture Atlas", + "description": "Combine multiple images into a single texture atlas for game development", + "createAtlas": "Create Texture Atlas", + "features": "Features", + "featureList": [ + "Smart packing - MaxRects algorithm for optimal space usage", + "Cocos Creator compatible - export in plist/JSON format", + "Rotation support - can rotate sprites for better packing", + "Power of Two - automatic POT sizing for better compatibility" + ], + "downloadAll": "Download All", + "downloadImage": "Download Image", + "downloadData": "Download Data", + "dimensions": "Dimensions", + "sprites": "Sprites", + "imageFormat": "Image Format", + "dataFormat": "Data Format" } }, "processing": { @@ -252,8 +307,11 @@ "extractingFrames": "Extracting frames...", "uploadingAudio": "Uploading audio...", "compressingAudio": "Compressing audio...", + "uploadingSprites": "Uploading sprites...", + "creatingAtlas": "Creating texture atlas...", "compressionComplete": "Compression complete!", "processingComplete": "Processing complete!", + "atlasComplete": "Texture atlas created successfully!", "compressionFailed": "Compression failed", "processingFailed": "Processing failed", "unknownError": "Unknown error", diff --git a/src/locales/zh.json b/src/locales/zh.json index c7efc36..c1020ce 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -29,7 +29,9 @@ "failed": "失败", "ready": "准备处理", "file": "文件", - "files": "文件" + "files": "文件", + "yes": "是", + "no": "否" }, "nav": { "tools": "工具", @@ -151,6 +153,7 @@ "videoToFrames": "视频抽帧", "imageCompression": "图片压缩", "audioCompression": "音频压缩", + "textureAtlas": "合图工具", "aiImage": "AI 图片", "aiAudio": "AI 音频" }, @@ -180,9 +183,11 @@ "format": "输出格式", "formatDescription": "转换为其他格式(可选)", "formatOriginal": "原始", + "formatAuto": "自动(最佳)", "formatJpeg": "JPEG", "formatPng": "PNG", - "formatWebp": "WebP" + "formatWebp": "WebP", + "formatAvif": "AVIF" }, "videoFrames": { "title": "导出设置", @@ -207,19 +212,50 @@ "channelsDescription": "音频声道", "stereo": "立体声(2 声道)", "mono": "单声道(1 声道)" + }, + "textureAtlas": { + "title": "合图设置", + "description": "配置纹理图集生成选项", + "maxWidth": "最大宽度", + "maxWidthDescription": "图集的最大宽度(像素)", + "maxHeight": "最大高度", + "maxHeightDescription": "图集的最大高度(像素)", + "padding": "内边距", + "paddingDescription": "精灵之间的间距(防止溢出)", + "allowRotation": "允许旋转", + "allowRotationDescription": "旋转精灵以提高打包效率", + "pot": "2 的幂次", + "potDescription": "使用 2 的幂次尺寸(512、1024、2048 等)", + "format": "图片格式", + "formatDescription": "输出图片格式", + "quality": "质量", + "qualityDescription": "WebP 格式的压缩质量", + "outputFormat": "数据格式", + "outputFormatDescription": "精灵元数据的格式", + "algorithm": "打包算法", + "algorithmDescription": "排列精灵的算法", + "formatPng": "PNG(无损)", + "formatWebp": "WebP(压缩)", + "outputCocos2d": "Cocos2d plist", + "outputCocosCreator": "Cocos Creator JSON", + "outputGeneric": "通用 JSON", + "algorithmMaxRects": "MaxRects(最优)", + "algorithmShelf": "Shelf(快速)" } }, "tools": { "imageCompression": { "title": "图片压缩", - "description": "为网页和移动端优化图片,不影响质量", + "description": "世界一流的图片压缩,智能优化", "compressImages": "压缩图片", "features": "功能特点", "featureList": [ - "批量处理 - 一次压缩多张图片", - "智能压缩 - 保持视觉质量", - "格式转换 - PNG 转 JPEG、WebP 等", - "高达 80% 的压缩率且不影响质量" + "智能压缩 - 保证输出更小或返回原图", + "多策略优化 - 尝试多种算法找到最佳结果", + "自动格式选择 - 智能选择最适合的格式", + "MozJPEG & WebP - 业界领先的压缩算法", + "元数据剥离 - 自动移除 EXIF 等冗余数据", + "批量处理 - 一次压缩多张图片" ] }, "videoFrames": { @@ -243,6 +279,25 @@ "output": "输出", "inputFormats": "MP3、WAV、OGG、AAC、FLAC、M4A", "outputFormats": "MP3、AAC、OGG、FLAC" + }, + "textureAtlas": { + "title": "合图工具", + "description": "将多张图片合并为一个纹理图集,专为游戏开发优化", + "createAtlas": "创建合图", + "features": "功能特点", + "featureList": [ + "智能打包 - MaxRects 算法实现最优空间利用", + "Cocos Creator 兼容 - 导出 plist/JSON 格式", + "旋转支持 - 可旋转精灵以提高打包效率", + "2 的幂次 - 自动 POT 尺寸提升兼容性" + ], + "downloadAll": "打包下载", + "downloadImage": "下载图片", + "downloadData": "下载数据", + "dimensions": "尺寸", + "sprites": "精灵数", + "imageFormat": "图片格式", + "dataFormat": "数据格式" } }, "processing": { @@ -252,8 +307,11 @@ "extractingFrames": "提取帧中...", "uploadingAudio": "上传音频中...", "compressingAudio": "压缩音频中...", + "uploadingSprites": "上传精灵图中...", + "creatingAtlas": "创建合图中...", "compressionComplete": "压缩完成!", "processingComplete": "处理完成!", + "atlasComplete": "合图创建成功!", "compressionFailed": "压缩失败", "processingFailed": "处理失败", "unknownError": "未知错误", diff --git a/src/types/index.ts b/src/types/index.ts index 1868adf..c697438 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -59,7 +59,7 @@ export interface ProcessingProgress { * Tool types */ -export type ToolType = "video-frames" | "image-compress" | "audio-compress" | "ai-image" | "ai-audio"; +export type ToolType = "video-frames" | "image-compress" | "audio-compress" | "texture-atlas" | "ai-image" | "ai-audio"; export interface ToolConfig { type: ToolType; @@ -129,7 +129,7 @@ export interface VideoFramesConfig { export interface ImageCompressConfig { quality: number; - format: "original" | "jpeg" | "png" | "webp"; + format: "original" | "auto" | "jpeg" | "png" | "webp" | "avif"; resize?: { width?: number; height?: number; @@ -143,3 +143,52 @@ export interface AudioCompressConfig { sampleRate: number; channels: number; } + +/** + * Texture Atlas types + */ + +export interface TextureAtlasConfig { + maxWidth: number; + maxHeight: number; + padding: number; + allowRotation: boolean; + pot: boolean; // Power of Two + format: "png" | "webp"; + quality: number; + outputFormat: "cocos2d" | "cocos-creator" | "generic-json"; + algorithm: "MaxRects" | "Shelf"; +} + +export interface AtlasSprite { + id: string; + name: string; + width: number; + height: number; + buffer: Buffer; +} + +export interface AtlasRect { + x: number; + y: number; + width: number; + height: number; + rotated?: boolean; +} + +export interface AtlasFrame { + filename: string; + frame: AtlasRect; + rotated: boolean; + trimmed: boolean; + spriteSourceSize: { x: number; y: number; w: number; h: number }; + sourceSize: { w: number; h: number }; +} + +export interface TextureAtlasResult { + width: number; + height: number; + image: Buffer; + frames: AtlasFrame[]; + format: string; +}