优化第一阶段

This commit is contained in:
yinsx
2025-12-22 12:07:12 +08:00
parent dd99e932b4
commit 1df41ac4ab
38 changed files with 340 additions and 300 deletions

View File

@ -0,0 +1,10 @@
async function run() {
throw new Error("尚未实现");
}
export default {
id: "audio",
name: "音频压缩",
desc: "压缩音频文件",
run,
};

View File

@ -0,0 +1,37 @@
import fs from "fs";
import { getImportExtensions, getExportFormats } from "./converters.js";
import { sortByUsage } from "../../stats.js";
const EXCLUDED_FORMATS = ["x", "glb", "gltf"];
export function listConvertibleFiles() {
const cwd = process.cwd();
const exts = getImportExtensions();
return fs.readdirSync(cwd).filter(file => {
const ext = file.slice(file.lastIndexOf(".")).toLowerCase();
return exts.includes(ext);
});
}
export function getSteps() {
const files = listConvertibleFiles();
const formats = sortByUsage("convert_format", getExportFormats().filter(f => !EXCLUDED_FORMATS.includes(f)));
return [
{
name: "源文件",
type: "multiselect",
message: files.length ? "选择要转换的模型文件" : "当前目录未找到可转换的模型文件",
options: files.map(file => ({ value: file, label: file })),
default: [],
emptyMessage: "请按 Esc 返回并放入模型文件后重试"
},
{
name: "输出格式",
type: "select",
message: "选择目标格式",
options: formats.map(f => ({ value: f, label: f })),
default: formats[0]
}
];
}

View File

@ -0,0 +1,33 @@
import { spawnSync } from "child_process";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ASSIMP_PATH = path.join(__dirname, "../../../bin/model_converter.exe");
let importExts = null;
let exportFormats = null;
export function getImportExtensions() {
if (!importExts) {
const r = spawnSync(ASSIMP_PATH, ["listext"], { encoding: "utf8" });
if (!r.stdout) throw new Error("无法获取支持的导入格式,请检查 model_converter.exe 是否存在");
importExts = r.stdout.trim().split(";").map(e => e.replace("*", "").toLowerCase());
}
return importExts;
}
export function getExportFormats() {
if (!exportFormats) {
const r = spawnSync(ASSIMP_PATH, ["listexport"], { encoding: "utf8" });
if (!r.stdout) throw new Error("无法获取支持的导出格式,请检查 model_converter.exe 是否存在");
exportFormats = r.stdout.trim().split(/\r?\n/).filter(Boolean);
}
return exportFormats;
}
export async function convert(inputFile, outputFile, format, cwd = process.cwd()) {
const r = spawnSync(ASSIMP_PATH, ["export", inputFile, outputFile, `-f${format}`], { encoding: "utf8", cwd });
if (r.status !== 0) throw new Error(r.stderr || r.stdout || "转换失败");
return outputFile;
}

View File

@ -0,0 +1,107 @@
import fs from "fs";
import path from "path";
import color from "picocolors";
import { convert } from "./converters.js";
import { runInteractive, showSummary } from "./ui.js";
import { stopKeypress, waitForKey } from "../../keyboard.js";
import { record } from "../../stats.js";
const FORMAT_EXT = {
collada: ".dae",stp: ".stp", obj: ".obj", objnomtl: ".obj",
stl: ".stl", stlb: ".stl", ply: ".ply", plyb: ".ply", "3ds": ".3ds",
gltf2: ".gltf", glb2: ".glb",
assbin: ".assbin", assxml: ".assxml", x3d: ".x3d",
fbx: ".fbx", fbxa: ".fbx", "3mf": ".3mf", pbrt: ".pbrt", assjson: ".json"
};
function getOutputExt(format) {
return FORMAT_EXT[format] || "." + format;
}
async function processFile(file, config) {
const cwd = process.cwd();
const { dir, name } = path.parse(file);
const outputExt = getOutputExt(config.outputFormat);
const outputFile = path.join(dir || "", `${name}${outputExt}`);
const absolutePath = path.join(cwd, file);
if (!fs.existsSync(absolutePath)) {
return { ok: false, file, reason: "文件不存在" };
}
await convert(file, outputFile, config.outputFormat, cwd);
return { ok: true, file, output: outputFile };
}
async function waitForEsc() {
return waitForKey(color.dim("按 Esc 返回"), key => key?.name === "escape" || (key?.ctrl && key?.name === "c"));
}
async function run() {
const result = await runInteractive();
if (!result) return "back";
const { results } = result;
const config = {
files: results[0] || [],
outputFormat: results[1] || "glb2"
};
stopKeypress();
showSummary([
"源文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"目标格式: " + config.outputFormat.toUpperCase()
]);
if (!config.files.length) {
console.log(color.yellow("未选择任何模型文件"));
await waitForEsc();
return "back";
}
const total = config.files.length;
let success = 0;
const failed = [];
console.log(color.cyan(`开始转换 ${total} 个文件...\n`));
for (let i = 0; i < config.files.length; i++) {
const file = config.files[i];
const progress = `[${i + 1}/${total}]`;
console.log(color.dim(`${progress} 处理中: ${file}`));
try {
const result = await processFile(file, config);
if (result.ok) {
success++;
record("convert_format", config.outputFormat);
console.log(color.green(`${progress}${file}${path.basename(result.output)}`));
} else {
failed.push({ file, reason: result.reason });
console.log(color.yellow(`${progress} ⊘ 跳过: ${file} (${result.reason})`));
}
} catch (err) {
failed.push({ file, reason: err?.message || String(err) });
console.log(color.red(`${progress} ✖ 失败: ${file}`));
console.log(color.dim(" " + String(err?.message || err)));
}
}
console.log("\n" + color.bgGreen(color.black(" 转换完成 ")));
if (success) {
console.log(color.green(`成功: ${success}`));
}
if (failed.length) {
console.log(color.yellow(`失败: ${failed.length}`));
}
await waitForEsc();
return "back";
}
export default {
id: "convert",
name: "格式转换",
desc: "支持多种模型格式转换",
run,
};

View File

@ -0,0 +1,9 @@
import { createStepUI } from "../../utils/stepui.js";
import { getSteps } from "./config.js";
const ui = createStepUI({
title: "格式转换工具",
getSteps
});
export const { runInteractive, showSummary } = ui;

View File

@ -0,0 +1,27 @@
import { listGltfFiles } from "../../utils/gltf.js";
const extensionStep = {
name: "扩展选项",
type: "multiselect",
message: "请选择要添加的glTF扩展",
options: [
{ value: "textureBasisu", label: "KHR_texture_basisu纹理自动扩展", hint: "自动添加KTX2纹理扩展支持" },
{ value: "placeholder1", label: "预留选项1", hint: "功能开发中..." },
{ value: "placeholder2", label: "预留选项2", hint: "功能开发中..." },
{ value: "placeholder3", label: "预留选项3", hint: "功能开发中..." }
],
default: ["textureBasisu"]
};
export function getSteps() {
const files = listGltfFiles();
const fileStep = {
name: "文件选择",
type: "multiselect",
message: files.length ? "请选择要处理的glTF文件" : "当前目录未找到glTF文件",
options: files.map(file => ({ value: file, label: file })),
default: [...files]
};
return [fileStep, extensionStep];
}

80
lib/plugins/gltf/index.js Normal file
View File

@ -0,0 +1,80 @@
import fs from "fs";
import path from "path";
import color from "picocolors";
import { checkRequiredFiles, modifyGltfContent, listGltfFiles, BACKUP_SUFFIX } from "../../utils/gltf.js";
import { runInteractive, showSummary } from "./ui.js";
import { stopKeypress, waitForKey } from "../../keyboard.js";
export function runGltfExtension(config = { files: [], extensions: ["textureBasisu"] }) {
const cwd = process.cwd();
const { files = [], extensions = ["textureBasisu"] } = config;
const selectedExtensions = extensions.length ? extensions : ["textureBasisu"];
const { ok, missing } = checkRequiredFiles();
if (!ok) {
console.log(color.red("\n✖ 缺少必要文件: " + missing.join(", ")));
console.log(color.dim("请确保当前目录包含 .ktx2、.gltf 和 .bin 文件\n"));
return { success: false, count: 0 };
}
const fallbackFiles = listGltfFiles();
const gltfFiles = (files.length ? files : fallbackFiles).filter(f => f.toLowerCase().endsWith(".gltf"));
if (!gltfFiles.length) {
console.log(color.yellow("未选择可处理的 glTF 文件"));
return { success: false, count: 0 };
}
let count = 0;
for (const file of gltfFiles) {
const fullPath = path.join(cwd, file);
if (!fs.existsSync(fullPath)) {
console.log(color.yellow("跳过缺失的文件: " + file));
continue;
}
const baseName = file.replace(/\.gltf$/i, "");
const backupName = baseName + BACKUP_SUFFIX + ".gltf";
const backupPath = path.join(cwd, backupName);
fs.copyFileSync(fullPath, backupPath);
const modified = modifyGltfContent(fullPath, selectedExtensions);
fs.writeFileSync(fullPath, JSON.stringify(modified, null, 2), "utf-8");
console.log(color.green("✓ " + file + " (备份: " + backupName + ")"));
count++;
}
return { success: count > 0, count };
}
async function run() {
const result = await runInteractive();
if (!result) return "back";
stopKeypress();
const { results } = result;
const config = {
files: results[0] || [],
extensions: results[1] || []
};
showSummary([
"处理文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"扩展选项: " + (config.extensions.length ? config.extensions.join(", ") : "未选择")
]);
const { success, count } = runGltfExtension(config);
if (success) {
console.log(color.green("\n✓ 已修改 " + count + " 个 glTF 文件"));
}
await waitForKey();
return "back";
}
export default {
id: "gltf",
name: "glTF扩展",
desc: "添加KTX2纹理扩展",
run,
};

9
lib/plugins/gltf/ui.js Normal file
View File

@ -0,0 +1,9 @@
import { createStepUI } from "../../utils/stepui.js";
import { getSteps } from "./config.js";
const ui = createStepUI({
title: "glTF 扩展工具",
getSteps
});
export const { runInteractive, showSummary } = ui;

View File

@ -0,0 +1,10 @@
async function run() {
throw new Error("尚未实现");
}
export default {
id: "image",
name: "图片批量处理",
desc: "裁剪/缩放/转换",
run,
};

14
lib/plugins/index.js Normal file
View File

@ -0,0 +1,14 @@
import { registerTool, getTools } from "./registry.js";
import convert from "./convert/index.js";
import ktx2 from "./ktx2/index.js";
import gltf from "./gltf/index.js";
import model from "./model/index.js";
import image from "./image/index.js";
import sprite from "./sprite/index.js";
import lod from "./lod/index.js";
import audio from "./audio/index.js";
import scaffold from "./scaffold/index.js";
[convert, ktx2, gltf, model, image, sprite, lod, audio, scaffold].forEach(registerTool);
export { getTools, registerTool };

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", "texture_tool.exe");
// 检查 toktx 是否存在
export function checkToktx() {
if (!fs.existsSync(toktx)) {
console.error("❌ 找不到 texture_tool.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 };
}

View File

@ -0,0 +1,63 @@
// 步骤配置
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: "静默模式(减少控制台输出信息)" },
{ value: "gltfExtension", label: "修改glTF扩展添加KHR_texture_basisu", dynamic: true }
],
default: ["overwrite", "keepOriginal"]
}
];

3
lib/plugins/ktx2/gltf.js Normal file
View File

@ -0,0 +1,3 @@
// 从 utils 导出,保持兼容
export { hasGltfFile, checkRequiredFiles, modifyGltfContent } from "../../utils/gltf.js";
export { runGltfExtension } from "../gltf/index.js";

56
lib/plugins/ktx2/index.js Normal file
View File

@ -0,0 +1,56 @@
import color from "picocolors";
import { checkToktx, scanImages, compressAll } from "./compressor.js";
import { runInteractive, showSummary } from "./ui.js";
import { runGltfExtension } from "./gltf.js";
import { stopKeypress, waitForKey } from "../../keyboard.js";
async function run() {
checkToktx();
const result = await runInteractive();
if (!result) return "back";
stopKeypress();
const { results } = result;
const [exts, quality, encoding, mipmap, outputOpts] = results;
const config = { exts, quality, encoding, mipmap, outputOpts };
showSummary([
"文件格式: " + config.exts.join(", "),
"压缩程度: " + config.quality,
"编码格式: " + config.encoding,
"Mipmap: " + config.mipmap,
"输出选项: " + config.outputOpts.join(", ")
]);
const { images, cwd } = scanImages(exts);
if (images.length === 0) {
console.log(color.yellow("当前目录没有匹配的图片"));
await waitForKey();
return "back";
}
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🎉 全部文件压缩完成!"));
}
if (outputOpts.includes("gltfExtension")) {
console.log(color.cyan("\n正在修改 glTF 文件..."));
const { success, count } = runGltfExtension();
if (success) console.log(color.green("✓ 已修改 " + count + " 个 glTF 文件"));
}
await waitForKey();
return "back";
}
export default {
id: "ktx2",
name: "KTX2压缩",
desc: "纹理压缩为KTX2",
run,
};

20
lib/plugins/ktx2/ui.js Normal file
View File

@ -0,0 +1,20 @@
import { createStepUI } from "../../utils/stepui.js";
import { steps } from "./config.js";
import { hasGltfFile } from "./gltf.js";
function getFilteredSteps() {
const hasGltf = hasGltfFile();
return steps.map(step => {
if (step.name === "输出选项") {
return { ...step, options: step.options.filter(opt => !opt.dynamic || hasGltf) };
}
return step;
});
}
const ui = createStepUI({
title: "KTX2 纹理压缩工具",
getSteps: getFilteredSteps
});
export const { runInteractive, showSummary } = ui;

10
lib/plugins/lod/index.js Normal file
View File

@ -0,0 +1,10 @@
async function run() {
throw new Error("尚未实现");
}
export default {
id: "lod",
name: "LOD生成器",
desc: "生成多级细节",
run,
};

View File

@ -0,0 +1,71 @@
import { listAllModelFiles } from "../../utils/gltf.js";
const transformOptions = [
{ value: "dedup", label: "dedup去重", hint: "删除重复的访问器、材质、网格" },
{ value: "prune", label: "prune清理无用节点", hint: "移除未被引用的节点、材质、动画" },
{ value: "resample", label: "resample动画重采样", hint: "统一动画关键帧间隔,减少多余数据" },
{ value: "weld", label: "weld合并顶点", hint: "合并共享位置的顶点以减少面数" },
{ value: "quantize", label: "quantize量化顶点数据", hint: "降低顶点属性精度减小模型体积" }
];
const quantizePresets = [
{ value: "high", label: "高质量位置16位法线12位UV14位", hint: "尽量保证细节,适合高保真场景" },
{ value: "balanced", label: "均衡位置14位法线10位UV12位", hint: "默认推荐,兼顾体积与质量" },
{ value: "aggressive", label: "极限压缩位置12位法线8位UV10位", hint: "最小体积,但可能损失细节" },
{ value: "light", label: "轻量位置10位法线8位UV10位", hint: "适合移动端、卡通等对精度不敏感场景" }
];
const outputFormats = [
{ value: "auto", label: "保持原格式", hint: "glTF/GLB保持原格式OBJ/FBX转为GLB" },
{ value: "glb", label: "统一导出为 GLB二进制", hint: "单文件发布更方便" },
{ value: "gltf", label: "统一导出为 glTFJSON", hint: "可读性好,调试方便" }
];
const outputOptions = [
{ value: "overwrite", label: "覆盖原文件", hint: "直接把结果写回源文件" },
{ value: "backup", label: "保留备份 (_备份)", hint: "覆盖前在同目录生成 _备份 副本" },
{ value: "copy", label: "输出副本 (_compressed)", hint: "保留原文件不动,结果写入新文件" }
];
export function getSteps() {
const files = listAllModelFiles();
const fileStep = {
name: "模型选择",
type: "multiselect",
message: files.length ? "选择要处理的模型(支持 glTF/GLB/OBJ/FBX" : "当前目录未找到模型文件",
options: files.map(file => ({ value: file, label: file })),
default: [...files]
};
return [
fileStep,
{
name: "压缩命令",
type: "multiselect",
message: "请选择要执行的 gltf-transform 操作",
options: transformOptions,
default: ["dedup", "prune", "weld", "quantize"]
},
{
name: "量化级别",
type: "select",
message: "量化可显著减小体积(如未启用 quantize 可直接 Tab",
options: quantizePresets,
default: "balanced"
},
{
name: "输出格式",
type: "select",
message: "选择最终模型格式",
options: outputFormats,
default: "auto"
},
{
name: "输出选项",
type: "multiselect",
message: "请选择输出行为",
options: outputOptions,
default: ["overwrite", "backup"]
}
];
}

202
lib/plugins/model/index.js Normal file
View File

@ -0,0 +1,202 @@
import fs from "fs";
import path from "path";
import color from "picocolors";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dedup, prune, resample, weld, quantize } from "@gltf-transform/functions";
import { BACKUP_SUFFIX, needsConversion } from "../../utils/gltf.js";
import { convert } from "../convert/converters.js";
import { runInteractive, showSummary } from "./ui.js";
import { stopKeypress, waitForKey } from "../../keyboard.js";
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const QUANTIZE_PRESETS = {
high: { position: 16, normal: 12, texcoord: 14, color: 10, generic: 12 },
balanced: { position: 14, normal: 10, texcoord: 12, color: 8, generic: 12 },
aggressive: { position: 12, normal: 8, texcoord: 10, color: 8, generic: 10 },
light: { position: 10, normal: 8, texcoord: 10, color: 8, generic: 10 }
};
function buildTransforms(config) {
const transforms = [];
const selected = new Set(config.commands);
if (selected.has("dedup")) transforms.push(dedup());
if (selected.has("prune")) transforms.push(prune());
if (selected.has("resample")) transforms.push(resample());
if (selected.has("weld")) transforms.push(weld());
if (selected.has("quantize")) {
const preset = QUANTIZE_PRESETS[config.quantizePreset] || QUANTIZE_PRESETS.balanced;
transforms.push(quantize({
quantizePosition: preset.position,
quantizeNormal: preset.normal,
quantizeTexcoord: preset.texcoord,
quantizeColor: preset.color,
quantizeGeneric: preset.generic
}));
}
return transforms;
}
function resolveOutput(file, config) {
const cwd = process.cwd();
const originalExt = path.extname(file).toLowerCase() || ".gltf";
const baseName = originalExt ? file.slice(0, -originalExt.length) : file;
const isConvertible = needsConversion(file);
// OBJ/FBX 默认输出 GLB除非指定了 gltf
const targetExt = config.outputFormat === "gltf" ? ".gltf"
: (config.outputFormat === "glb" || isConvertible) ? ".glb"
: originalExt;
const wantsCopy = config.outputOptions.includes("copy");
const wantsOverwrite = config.outputOptions.includes("overwrite");
const mode = wantsCopy ? "copy" : (wantsOverwrite ? "overwrite" : "copy");
const backup = config.outputOptions.includes("backup") && mode === "overwrite";
const targetName = mode === "copy"
? `${baseName}_compressed${targetExt}`
: `${baseName}${targetExt}`;
return {
cwd,
mode,
backup,
sourcePath: path.join(cwd, file),
sourceExt: originalExt,
targetExt,
targetPath: path.join(cwd, targetName),
baseName,
needsConversion: isConvertible
};
}
async function processFile(file, config, transforms) {
const output = resolveOutput(file, config);
if (!fs.existsSync(output.sourcePath)) {
console.log(color.yellow("跳过,文件不存在: " + file));
return { ok: false, file, reason: "missing" };
}
if (!transforms.length) {
console.log(color.yellow("未选择任何 gltf-transform 操作,已中止"));
return { ok: false, file, reason: "no-transform" };
}
if (output.backup) {
const backupName = `${output.baseName}${BACKUP_SUFFIX}${output.sourceExt}`;
const backupPath = path.join(output.cwd, backupName);
fs.copyFileSync(output.sourcePath, backupPath);
}
let inputPath = output.sourcePath;
// 如果是 OBJ/FBX先转换为临时 GLB
if (output.needsConversion) {
const tempPath = path.join(output.cwd, `${output.baseName}_temp.glb`);
console.log(color.dim("转换中: " + file + " → GLB"));
await convert(output.sourcePath, tempPath, { binary: true });
inputPath = tempPath;
}
const document = io.read(inputPath);
await document.transform(...transforms);
io.write(output.targetPath, document);
// 清理临时文件
if (output.needsConversion) {
fs.rmSync(inputPath, { force: true });
}
if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) {
fs.rmSync(output.sourcePath, { force: true });
}
if (output.mode === "overwrite" && output.targetPath !== output.sourcePath) {
console.log(color.dim("已生成新文件: " + path.basename(output.targetPath)));
}
return { ok: true, file, output: output.targetPath };
}
async function run() {
const result = await runInteractive();
if (!result) return "back";
stopKeypress();
const { results } = result;
const config = {
files: results[0] || [],
commands: results[1] || [],
quantizePreset: results[2] || "balanced",
outputFormat: results[3] || "auto",
outputOptions: results[4] || []
};
showSummary([
"模型文件: " + (config.files.length ? config.files.join(", ") : "未选择"),
"gltf-transform 命令: " + (config.commands.length ? config.commands.join(", ") : "无"),
"量化级别: " + config.quantizePreset,
"输出格式: " + config.outputFormat,
"输出选项: " + (config.outputOptions.length ? config.outputOptions.join(", ") : "默认")
]);
if (!config.files.length) {
console.log(color.yellow("未选择任何模型文件"));
await waitForKey();
return "back";
}
const transforms = buildTransforms(config);
if (!transforms.length) {
console.log(color.yellow("未配置任何压缩命令,请至少选择一项 gltf-transform 操作"));
await waitForKey();
return "back";
}
const total = config.files.length;
let success = 0;
const failed = [];
console.log(color.cyan(`开始压缩 ${total} 个文件...\n`));
for (let i = 0; i < config.files.length; i++) {
const file = config.files[i];
const progress = `[${i + 1}/${total}]`;
console.log(color.dim(`${progress} 处理中: ${file}`));
try {
const result = await processFile(file, config, transforms);
if (result.ok) {
success++;
console.log(color.green(`${progress}${file}${path.basename(result.output)}`));
} else {
failed.push(file);
console.log(color.yellow(`${progress} ⊘ 跳过: ${file}`));
}
} catch (err) {
failed.push(file);
console.log(color.red(`${progress} ✖ 失败: ${file}`));
console.log(color.dim(" " + String(err?.message || err)));
}
}
console.log("\n" + color.bgGreen(color.black(" 压缩完成 ")));
if (success) {
console.log(color.green(`成功: ${success}`));
}
if (failed.length) {
console.log(color.yellow(`失败: ${failed.length} 个 (${failed.join(", ")})`));
}
await waitForKey();
return "back";
}
export default {
id: "model",
name: "模型压缩",
desc: "glTF/GLB/OBJ/FBX压缩",
run,
};

9
lib/plugins/model/ui.js Normal file
View File

@ -0,0 +1,9 @@
import { createStepUI } from "../../utils/stepui.js";
import { getSteps } from "./config.js";
const ui = createStepUI({
title: "模型压缩工具",
getSteps
});
export const { runInteractive, showSummary } = ui;

23
lib/plugins/registry.js Normal file
View File

@ -0,0 +1,23 @@
const tools = [];
export function registerTool(definition) {
const { id, name, desc, run } = definition || {};
if (!id || !name || !run) {
throw new Error("Tool definition must include id, name and run");
}
const existing = tools.findIndex(t => t.id === id);
const tool = { ...definition };
if (existing >= 0) {
tools[existing] = tool;
} else {
tools.push(tool);
}
return tool;
}
export function getTools() {
return [...tools];
}

View File

@ -0,0 +1,216 @@
// 二级菜单 - 项目类型
export const projectTypes = [
{ name: "前端", desc: "React/Vue项目" },
{ name: "后端", desc: "Node.js服务端" },
{ name: "全栈", desc: "前后端一体化" },
];
// 框架选项
export const frameworkOptions = {
前端: [
{ value: "vue", label: "VueVite + Vue" },
{ value: "react", label: "ReactVite + React" },
],
后端: [
{ value: "bun", label: "BunBun运行时" },
{ value: "node", label: "Node.jsNode运行时" },
],
全栈: [
{ value: "nextjs", label: "Next.jsReact全栈框架" },
{ value: "nuxt", label: "NuxtVue全栈框架" },
],
};
const bunComponents = {
框架: [
{ value: "normal", label: "原生" },
{ value: "elysia", label: "Elysia(官方亲儿子)" },
{ value: "hono", label: "Hono(轻量级)" },
]
};
const nodeComponents = {
框架: [
{ value: "nestjs", label: "NestJS企业级框架" },
{ value: "express", label: "ExpressExpress Generator" },
{ value: "koa", label: "KoaKoa Generator" },
{ value: "egg", label: "Egg阿里Egg.js" },
{ value: "midway", label: "Midway阿里Midway" },
{ value: "fastify", label: "Fastify高性能框架" },
{ value: "adonisjs", label: "AdonisJS全功能MVC" },
]
};
//后端公共框架
const backendCommonComponents = {
数据库: [
{ value: "none", label: "不需要数据库" },
{ value: "mongoose", label: "MongoDB + Mongoose" },
{ value: "prisma", label: "Prisma支持 MySQL / PostgreSQL / SQLite" },
{ value: "typeorm", label: "TypeORM支持 MySQL / PostgreSQL 等)" },
]
};
// 组件选项 - 按框架分类
const reactComponents = {
路由: [
{ value: "none", label: "不需要路由" },
{ value: "react-router", label: "React Router" },
{ value: "tanstack-router", label: "TanStack Router" },
],
状态管理: [
{ value: "none", label: "不需要状态管理" },
{ value: "zustand", label: "Zustand轻量级推荐" },
{ value: "redux", label: "Redux Toolkit" },
{ value: "jotai", label: "Jotai原子化" },
{ value: "mobx", label: "MobX" },
],
UI组件库: [
{ value: "none", label: "不需要UI库" },
{ value: "antd", label: "Ant Design" },
{ value: "arco", label: "Arco Design" },
{ value: "shadcn", label: "shadcn/ui" },
{ value: "mui", label: "Material UI" },
],
表单验证: [
{ value: "none", label: "不需要表单验证" },
{ value: "react-hook-form", label: "React Hook Form" },
{ value: "formik", label: "Formik" },
{ value: "zod", label: "Zod" },
],
工具库: [
{ value: "ahooks", label: "ahooksReact Hooks库" },
{ value: "lodash", label: "Lodash" },
{ value: "dayjs", label: "Day.js" },
{ value: "iconify", label: "Iconify图标" },
],
国际化: [
{ value: "none", label: "不需要国际化" },
{ value: "i18next", label: "i18next" },
],
};
const vueComponents = {
路由: [
{ value: "none", label: "不需要路由" },
{ value: "vue-router", label: "Vue Router" },
],
状态管理: [
{ value: "none", label: "不需要状态管理" },
{ value: "pinia", label: "Pinia官方推荐" },
],
UI组件库: [
{ value: "none", label: "不需要UI库" },
{ value: "element-plus", label: "Element Plus" },
{ value: "naive-ui", label: "Naive UI" },
{ value: "arco-vue", label: "Arco Design Vue" },
{ value: "ant-design-vue", label: "Ant Design Vue" },
],
表单验证: [
{ value: "none", label: "不需要表单验证" },
{ value: "vee-validate", label: "VeeValidate" },
{ value: "vuelidate", label: "Vuelidate" },
{ value: "zod", label: "Zod" },
],
工具库: [
{ value: "vueuse", label: "VueUse组合式工具" },
{ value: "lodash", label: "Lodash" },
{ value: "dayjs", label: "Day.js" },
{ value: "iconify", label: "Iconify图标" },
], 国际化: [
{ value: "none", label: "不需要国际化" },
{ value: "vue-i18n", label: "Vue-i18n" },
],
};
// 通用组件React和Vue共用
const commonComponents = {
HTTP请求: [
{ value: "none", label: "不需要HTTP库" },
{ value: "axios", label: "Axios" },
{ value: "ky", label: "Ky轻量级" },
{ value: "ofetch", label: "ofetch" },
],
CSS方案: [
{ value: "none", label: "原生CSS" },
{ value: "tailwind", label: "Tailwind CSS" },
{ value: "unocss", label: "UnoCSS" },
{ value: "sass", label: "Sass/SCSS" },
{ value: "less", label: "Less" },
],
代码规范: [
{ value: "eslint", label: "ESLint" },
{ value: "prettier", label: "Prettier" },
{ value: "husky", label: "Husky" },
{ value: "lint-staged", label: "lint-staged" },
],
其他: [
{ value: "mock", label: "Mock.js" },
{ value: "pwa", label: "PWA支持" },
{ value: "storage", label: "持久化存储" },
{ value: "threejs", label: "3D集成(Three.js)" },
{ value: "babylonjs", label: "3D集成(Babylon.js)" },
],
};
// 根据框架获取组件配置
export function getComponentsByFramework(framework) {
if (!framework || framework === "none") return {};
const isReact = ["react", "nextjs"].includes(framework);
const isVue = ["vue", "nuxt"].includes(framework);
const isBun = framework === "bun";
const isNode = framework === "node";
if (isReact) {
return { ...reactComponents, ...commonComponents };
} else if (isVue) {
return { ...vueComponents, ...commonComponents };
} else if (isBun) {
return { ...bunComponents, ...backendCommonComponents };
} else if (isNode) {
return { ...nodeComponents, ...backendCommonComponents };
}
return {};
}
// 获取默认框架
function getDefaultFramework(projectType) {
const opts = frameworkOptions[projectType];
return opts?.[0]?.value || "none";
}
// 生成前端/全栈项目的步骤配置
export function generateSteps(projectType, selectedFramework) {
const defaultFramework = getDefaultFramework(projectType);
const framework = selectedFramework || defaultFramework;
const steps = [
{
name: "运行时",
type: "select",
message: "选择项目框架",
options: frameworkOptions[projectType] || [],
default: defaultFramework
}
];
const components = getComponentsByFramework(framework);
const componentNames = Object.keys(components);
componentNames.forEach(name => {
const isMulti = ["工具库", "代码规范", "其他"].includes(name);
const opts = components[name] || [];
steps.push({
name,
type: isMulti ? "multiselect" : "select",
message: `选择${name}`,
options: opts,
default: isMulti ? [] : opts[0]?.value
});
});
return steps;
}

View File

@ -0,0 +1,42 @@
import color from "picocolors";
import { projectTypes } from "./config.js";
import { gridSelect } from "../../grid.js";
import { createScaffoldUI, formatResults, waitKey } from "./ui.js";
async function run() {
while (true) {
// 二级菜单 - 项目类型
const typeResult = await gridSelect({
items: projectTypes,
title: "项目搭建器 - 选择类型",
cols: Math.min(3, projectTypes.length),
colWidth: 20,
instructions: "←→ 选择 | Enter 确认 | Esc 返回",
onCancel: "back",
mapper: item => ({ action: "select", item }),
showUpdateButton: false,
});
if (typeResult === "back") return "back";
const projectType = typeResult.item.name;
// 三级页面 - 框架+组件配置(合并)
const ui = createScaffoldUI(projectType);
const result = await ui.runInteractive();
if (result) {
const summary = formatResults(result.steps, result.results);
ui.showSummary(summary);
console.log(color.yellow("功能开发中,敬请期待..."));
await waitKey();
}
}
}
export default {
id: "scaffold",
name: "项目搭建",
desc: "快速创建项目模板",
run,
};

View File

@ -0,0 +1,41 @@
import color from "picocolors";
import { initKeypress, onKey, stopKeypress } from "../../keyboard.js";
import { createStepUI } from "../../utils/stepui.js";
import { generateSteps } from "./config.js";
// 框架 + 组件配置 UI
export function createScaffoldUI(projectType) {
return createStepUI({
title: `${projectType} - 项目配置`,
getSteps: () => generateSteps(projectType, null),
onStepChange: framework => generateSteps(projectType, framework),
});
}
// 解析配置结果
export function formatResults(steps, results) {
const summary = [];
results.forEach((val, i) => {
if (Array.isArray(val) && val.length > 0) {
summary.push(`${steps[i].name}: ${val.join(", ")}`);
} else if (val && val !== "none") {
summary.push(`${steps[i].name}: ${val}`);
}
});
return summary.length ? summary : ["未选择任何组件"];
}
// 等待按键
export async function waitKey(message = "按任意键返回") {
console.log(color.dim(`\n${message}`));
return new Promise(resolve => {
initKeypress();
onKey(() => {
stopKeypress();
resolve();
});
});
}

View File

@ -0,0 +1,10 @@
async function run() {
throw new Error("尚未实现");
}
export default {
id: "sprite",
name: "Sprite图集",
desc: "合并精灵图集",
run,
};