commit 33910f031f7974edc63500b4a435eebb12c65b92 Author: yinsx Date: Tue Dec 16 15:14:07 2025 +0800 init diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..56ff59b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(npm install:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d032d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/node_modules/ +/public/ +/dist/ +nul \ No newline at end of file diff --git a/bin/ktx.dll b/bin/ktx.dll new file mode 100644 index 0000000..ed7a56b Binary files /dev/null and b/bin/ktx.dll differ diff --git a/bin/toktx.exe b/bin/toktx.exe new file mode 100644 index 0000000..7d29d04 Binary files /dev/null and b/bin/toktx.exe differ diff --git a/cli.js b/cli.js new file mode 100644 index 0000000..481b494 --- /dev/null +++ b/cli.js @@ -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🎉 全部文件压缩完成!")); +} diff --git a/lib/compressor.js b/lib/compressor.js new file mode 100644 index 0000000..9d8ba9f --- /dev/null +++ b/lib/compressor.js @@ -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 }; +} diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..1d9f0da --- /dev/null +++ b/lib/config.js @@ -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"] + } +]; diff --git a/lib/ui.js b/lib/ui.js new file mode 100644 index 0000000..3e0a6bb --- /dev/null +++ b/lib/ui.js @@ -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`); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a785e43 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd90938 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/xw_20251216142200.png b/xw_20251216142200.png new file mode 100644 index 0000000..55d1ab9 Binary files /dev/null and b/xw_20251216142200.png differ