init
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/node_modules/
|
||||
/public/
|
||||
/dist/
|
||||
nul
|
||||
BIN
bin/ktx.dll
Normal file
BIN
bin/ktx.dll
Normal file
Binary file not shown.
BIN
bin/toktx.exe
Normal file
BIN
bin/toktx.exe
Normal file
Binary file not shown.
35
cli.js
Normal file
35
cli.js
Normal file
@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env node
|
||||
import color from "picocolors";
|
||||
import { checkToktx, scanImages, compressAll } from "./lib/compressor.js";
|
||||
import { runInteractive, showSummary } from "./lib/ui.js";
|
||||
|
||||
// 检查 toktx
|
||||
checkToktx();
|
||||
|
||||
// 运行交互界面
|
||||
const [exts, quality, encoding, mipmap, outputOpts] = await runInteractive();
|
||||
|
||||
const config = { exts, quality, encoding, mipmap, outputOpts };
|
||||
|
||||
// 显示配置摘要
|
||||
showSummary(config);
|
||||
|
||||
// 扫描文件
|
||||
const { images, cwd } = scanImages(exts);
|
||||
|
||||
if (images.length === 0) {
|
||||
console.log(color.yellow("当前目录没有匹配的图片"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`📁 找到 ${color.cyan(images.length)} 个待转换文件\n`);
|
||||
|
||||
// 执行压缩
|
||||
const { total, failed } = await compressAll(images, config, cwd);
|
||||
|
||||
// 显示结果
|
||||
if (failed > 0) {
|
||||
console.log(color.yellow(`\n⚠️ 完成,但有 ${failed} 个文件失败`));
|
||||
} else {
|
||||
console.log(color.green("\n🎉 全部文件压缩完成!"));
|
||||
}
|
||||
115
lib/compressor.js
Normal file
115
lib/compressor.js
Normal file
@ -0,0 +1,115 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
import color from "picocolors";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const toktx = path.join(__dirname, "..", "bin", "toktx.exe");
|
||||
|
||||
// 检查 toktx 是否存在
|
||||
export function checkToktx() {
|
||||
if (!fs.existsSync(toktx)) {
|
||||
console.error("❌ 找不到 toktx.exe");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 扫描图片文件
|
||||
export function scanImages(exts) {
|
||||
console.log("🔍 扫描目标文件中...");
|
||||
const cwd = process.cwd();
|
||||
const images = fs.readdirSync(cwd).filter(f =>
|
||||
exts.some(ext => f.toLowerCase().endsWith("." + ext))
|
||||
);
|
||||
return { images, cwd };
|
||||
}
|
||||
|
||||
// 构建压缩参数
|
||||
export function buildArgs(input, output, config) {
|
||||
const args = ["--t2"];
|
||||
|
||||
if (config.encoding === "uastc") {
|
||||
args.push("--encode", "uastc");
|
||||
const zcmpLevel = { none: "0", standard: "10", high: "18", extreme: "22" };
|
||||
args.push("--zcmp", zcmpLevel[config.quality] || "10");
|
||||
} else if (config.encoding === "etc1s") {
|
||||
args.push("--encode", "etc1s");
|
||||
} else if (config.encoding === "astc") {
|
||||
args.push("--encode", "astc");
|
||||
const blkSize = { none: "8x8", standard: "6x6", high: "5x5", extreme: "4x4" };
|
||||
args.push("--astc_blk_d", blkSize[config.quality] || "6x6");
|
||||
}
|
||||
|
||||
if (config.mipmap === "auto") {
|
||||
args.push("--genmipmap");
|
||||
}
|
||||
|
||||
args.push(output, input);
|
||||
return args;
|
||||
}
|
||||
|
||||
// 压缩单个文件
|
||||
export function compressFile(img, config, cwd, progress) {
|
||||
const baseName = img.replace(/\.[^.]+$/, "");
|
||||
const out = baseName + ".ktx2";
|
||||
|
||||
// 点动画
|
||||
let dots = 0;
|
||||
const dotAnim = setInterval(() => {
|
||||
const dotStr = ".".repeat(dots);
|
||||
process.stdout.write(`\r${progress} ${img} 正在转换中${dotStr} `);
|
||||
dots = dots >= 3 ? 0 : dots + 1;
|
||||
}, 300);
|
||||
|
||||
process.stdout.write(`${progress} ${img} 正在转换中.`);
|
||||
|
||||
const args = buildArgs(img, out, config);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(toktx, args, { cwd });
|
||||
|
||||
let stderr = "";
|
||||
proc.stderr?.on("data", data => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", code => {
|
||||
clearInterval(dotAnim);
|
||||
if (code === 0) {
|
||||
console.log(`\r${progress} ${color.green("✓")} ${out} `);
|
||||
resolve({ success: true });
|
||||
} else {
|
||||
console.log(`\r${progress} ${color.red("✗")} ${img} 失败 `);
|
||||
if (stderr) {
|
||||
console.log(color.dim(` 错误: ${stderr.trim()}`));
|
||||
}
|
||||
resolve({ success: false, error: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("error", err => {
|
||||
clearInterval(dotAnim);
|
||||
console.log(`\r${progress} ${color.red("✗")} ${img} 失败 `);
|
||||
console.log(color.dim(` 错误: ${err.message}`));
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 批量压缩
|
||||
export async function compressAll(images, config, cwd) {
|
||||
const total = images.length;
|
||||
let finished = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const img of images) {
|
||||
finished++;
|
||||
const progress = `(${finished}/${total})`;
|
||||
const result = await compressFile(img, config, cwd, progress);
|
||||
if (!result.success) failed++;
|
||||
}
|
||||
|
||||
return { total, failed };
|
||||
}
|
||||
62
lib/config.js
Normal file
62
lib/config.js
Normal file
@ -0,0 +1,62 @@
|
||||
// 步骤配置
|
||||
export const steps = [
|
||||
{
|
||||
name: "文件格式",
|
||||
type: "multiselect",
|
||||
message: "请选择要压缩的图片类型",
|
||||
options: [
|
||||
{ value: "png", label: "PNG (.png)(无损格式,适合图标和透明图)" },
|
||||
{ value: "jpg", label: "JPG (.jpg)(有损格式,适合照片和复杂图像)" },
|
||||
{ value: "jpeg", label: "JPEG (.jpeg)(同JPG,仅扩展名不同)" },
|
||||
{ value: "webp", label: "WebP (.webp)(新一代格式,体积更小)" },
|
||||
{ value: "tga", label: "TGA (.tga)(游戏纹理常用格式)" }
|
||||
],
|
||||
default: ["png", "jpg"]
|
||||
},
|
||||
{
|
||||
name: "压缩程度",
|
||||
type: "select",
|
||||
message: "请选择压缩级别",
|
||||
options: [
|
||||
{ value: "none", label: "无压缩(原始质量)", hint: "保持原始文件大小,图片和内容无损" },
|
||||
{ value: "standard", label: "标准压缩(推荐)", hint: "平衡文件大小与质量,压缩率约40%" },
|
||||
{ value: "high", label: "高度压缩(最小体积)", hint: "最大程度减小文件体积,可能轻微影响清晰度" },
|
||||
{ value: "extreme", label: "极限压缩(极致压缩)", hint: "牺牲部分质量换取最小体积,适合网络传输" }
|
||||
],
|
||||
default: "standard"
|
||||
},
|
||||
{
|
||||
name: "编码格式",
|
||||
type: "select",
|
||||
message: "请选择编码格式",
|
||||
options: [
|
||||
{ value: "uastc", label: "UASTC(通用超压缩纹理)", hint: "高质量GPU纹理,解码快,适合实时渲染" },
|
||||
{ value: "etc1s", label: "ETC1S(基础压缩纹理)", hint: "文件体积最小,兼容性好,适合移动端" },
|
||||
{ value: "astc", label: "ASTC(自适应纹理压缩)", hint: "灵活块大小,质量与体积可调,适合高端设备" }
|
||||
],
|
||||
default: "uastc"
|
||||
},
|
||||
{
|
||||
name: "Mipmap",
|
||||
type: "select",
|
||||
message: "请选择Mipmap生成方式",
|
||||
options: [
|
||||
{ value: "auto", label: "自动生成(推荐)", hint: "根据图片尺寸自动生成多级纹理,优化远距离渲染" },
|
||||
{ value: "none", label: "不生成Mipmap", hint: "仅保留原始尺寸,文件更小但可能出现锯齿" },
|
||||
{ value: "custom", label: "自定义层级", hint: "手动指定Mipmap层数,精细控制纹理细节" }
|
||||
],
|
||||
default: "auto"
|
||||
},
|
||||
{
|
||||
name: "输出选项",
|
||||
type: "multiselect",
|
||||
message: "请选择输出选项",
|
||||
options: [
|
||||
{ value: "overwrite", label: "覆盖已存在文件(自动替换同名文件)" },
|
||||
{ value: "keepOriginal", label: "保留原文件(压缩后不删除源文件)" },
|
||||
{ value: "report", label: "生成压缩报告(输出详细的压缩统计信息)" },
|
||||
{ value: "silent", label: "静默模式(减少控制台输出信息)" }
|
||||
],
|
||||
default: ["overwrite", "keepOriginal"]
|
||||
}
|
||||
];
|
||||
162
lib/ui.js
Normal file
162
lib/ui.js
Normal file
@ -0,0 +1,162 @@
|
||||
import color from "picocolors";
|
||||
import readline from "readline";
|
||||
import { steps } from "./config.js";
|
||||
|
||||
// 存储结果和状态
|
||||
let results = [];
|
||||
let completed = new Set();
|
||||
let currentStep = 0;
|
||||
let currentOption = 0;
|
||||
|
||||
// 初始化结果
|
||||
export function initResults() {
|
||||
results = steps.map(s => s.type === "multiselect" ? [...s.default] : s.default);
|
||||
completed = new Set();
|
||||
currentStep = 0;
|
||||
currentOption = 0;
|
||||
}
|
||||
|
||||
// 渲染导航栏
|
||||
function renderNav() {
|
||||
const nav = steps.map((step, i) => {
|
||||
if (completed.has(i)) {
|
||||
return color.green(`☑ ${step.name}`);
|
||||
} else if (i === currentStep) {
|
||||
return color.bgCyan(color.black(` ${step.name} `));
|
||||
} else {
|
||||
return color.dim(`□ ${step.name}`);
|
||||
}
|
||||
});
|
||||
return `← ${nav.join(" ")} ${color.green("✓Submit")} →`;
|
||||
}
|
||||
|
||||
// 渲染选项列表
|
||||
function renderOptions() {
|
||||
const step = steps[currentStep];
|
||||
const lines = [];
|
||||
|
||||
lines.push(color.cyan(step.message));
|
||||
lines.push("");
|
||||
|
||||
step.options.forEach((opt, i) => {
|
||||
const isCurrent = i === currentOption;
|
||||
const isSelected = step.type === "multiselect"
|
||||
? results[currentStep].includes(opt.value)
|
||||
: results[currentStep] === opt.value;
|
||||
|
||||
let prefix;
|
||||
if (step.type === "multiselect") {
|
||||
prefix = isSelected ? color.green("◉ ") : "○ ";
|
||||
} else {
|
||||
prefix = isSelected ? color.green("● ") : "○ ";
|
||||
}
|
||||
|
||||
const cursor = isCurrent ? color.cyan("❯ ") : " ";
|
||||
const label = isCurrent ? color.cyan(opt.label) : opt.label;
|
||||
const check = isSelected ? color.green(" ✓") : "";
|
||||
|
||||
lines.push(`${cursor}${prefix}${label}${check}`);
|
||||
if (opt.hint) {
|
||||
lines.push(` ${color.dim(opt.hint)}`);
|
||||
}
|
||||
});
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// 渲染整个界面
|
||||
function render() {
|
||||
console.clear();
|
||||
console.log(color.bgCyan(color.black(" KTX2 纹理压缩工具 ")));
|
||||
console.log("\n" + renderNav());
|
||||
console.log(color.dim("\n← → 切换步骤 | ↑ ↓ 选择选项 | Space 选中 | Enter 确认 | Tab 提交\n"));
|
||||
console.log(renderOptions());
|
||||
}
|
||||
|
||||
// 主交互循环
|
||||
export async function runInteractive() {
|
||||
initResults();
|
||||
|
||||
readline.emitKeypressEvents(process.stdin);
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
render();
|
||||
|
||||
const handler = (str, key) => {
|
||||
if (!key) return;
|
||||
|
||||
const step = steps[currentStep];
|
||||
const optCount = step.options.length;
|
||||
|
||||
if (key.name === "left") {
|
||||
if (currentStep > 0) {
|
||||
currentStep--;
|
||||
currentOption = 0;
|
||||
}
|
||||
render();
|
||||
} else if (key.name === "right") {
|
||||
if (currentStep < steps.length - 1) {
|
||||
currentStep++;
|
||||
currentOption = 0;
|
||||
}
|
||||
render();
|
||||
} else if (key.name === "up") {
|
||||
currentOption = (currentOption - 1 + optCount) % optCount;
|
||||
render();
|
||||
} else if (key.name === "down") {
|
||||
currentOption = (currentOption + 1) % optCount;
|
||||
render();
|
||||
} else if (key.name === "space") {
|
||||
const opt = step.options[currentOption];
|
||||
if (step.type === "multiselect") {
|
||||
const idx = results[currentStep].indexOf(opt.value);
|
||||
if (idx >= 0) {
|
||||
results[currentStep].splice(idx, 1);
|
||||
} else {
|
||||
results[currentStep].push(opt.value);
|
||||
}
|
||||
} else {
|
||||
results[currentStep] = opt.value;
|
||||
}
|
||||
completed.add(currentStep);
|
||||
render();
|
||||
} else if (key.name === "return") {
|
||||
if (step.type === "select") {
|
||||
results[currentStep] = step.options[currentOption].value;
|
||||
}
|
||||
completed.add(currentStep);
|
||||
if (currentStep < steps.length - 1) {
|
||||
currentStep++;
|
||||
currentOption = 0;
|
||||
}
|
||||
render();
|
||||
} else if (key.name === "tab") {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.removeListener("keypress", handler);
|
||||
resolve(results);
|
||||
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
|
||||
process.stdin.setRawMode(false);
|
||||
console.clear();
|
||||
console.log(color.yellow("操作已取消"));
|
||||
process.exit(0);
|
||||
}
|
||||
};
|
||||
|
||||
process.stdin.on("keypress", handler);
|
||||
});
|
||||
}
|
||||
|
||||
// 显示配置摘要
|
||||
export function showSummary(config) {
|
||||
console.clear();
|
||||
console.log(color.bgCyan(color.black(" KTX2 纹理压缩工具 ")));
|
||||
console.log("\n" + color.green("配置完成!当前设置:"));
|
||||
console.log(` 文件格式: ${config.exts.join(", ")}`);
|
||||
console.log(` 压缩程度: ${config.quality}`);
|
||||
console.log(` 编码格式: ${config.encoding}`);
|
||||
console.log(` Mipmap: ${config.mipmap}`);
|
||||
console.log(` 输出选项: ${config.outputOpts.join(", ")}\n`);
|
||||
}
|
||||
75
package-lock.json
generated
Normal file
75
package-lock.json
generated
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "demo",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "demo",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"prompts": "^2.4.2"
|
||||
},
|
||||
"bin": {
|
||||
"yinx": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz",
|
||||
"integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz",
|
||||
"integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "0.5.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
|
||||
"integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prompts": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
|
||||
"integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"kleur": "^3.0.3",
|
||||
"sisteransi": "^1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
package.json
Normal file
15
package.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "demo",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"yinx": "./cli.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"picocolors": "^1.1.1",
|
||||
"prompts": "^2.4.2"
|
||||
}
|
||||
}
|
||||
BIN
xw_20251216142200.png
Normal file
BIN
xw_20251216142200.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Reference in New Issue
Block a user