第一版

This commit is contained in:
yinsx
2025-12-16 16:21:26 +08:00
parent 0c73ef2547
commit 8ba7e635f5
15 changed files with 1006 additions and 30 deletions

115
lib/ktx2/compressor.js Normal file
View 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/ktx2/config.js Normal file
View 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"]
}
];

40
lib/ktx2/index.js Normal file
View File

@ -0,0 +1,40 @@
import color from "picocolors";
import { checkToktx, scanImages, compressAll } from "./compressor.js";
import { runInteractive, showSummary } from "./ui.js";
export async function run() {
// 检查 toktx
checkToktx();
// 运行交互界面
const result = await runInteractive();
// ESC 返回主菜单
if (!result) return "back";
const [exts, quality, encoding, mipmap, outputOpts] = result;
const config = { exts, quality, encoding, mipmap, outputOpts };
// 显示配置摘要
showSummary(config);
// 扫描文件
const { images, cwd } = scanImages(exts);
if (images.length === 0) {
console.log(color.yellow("当前目录没有匹配的图片"));
return;
}
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🎉 全部文件压缩完成!"));
}
}

151
lib/ktx2/ui.js Normal file
View File

@ -0,0 +1,151 @@
import color from "picocolors";
import { steps } from "./config.js";
import { initKeypress, onKey } from "../keyboard.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 选中 | Tab 提交 | Esc 返回\n"));
console.log(renderOptions());
}
// 主交互循环
export async function runInteractive() {
initResults();
initKeypress();
return new Promise((resolve) => {
render();
onKey((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") {
resolve(results);
} else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
resolve(null);
}
});
});
}
// 显示配置摘要
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`);
}