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, };