Files
agame/assets/脚本/convert_models.py
2026-10-12 17:48:27 +08:00

798 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
convert_models.py —— 可复用·批量模型转换器(Blender 后端)
==========================================================
支持把一整个目录的模型批量转成 GLB(也可输出 .gltf / 分离式 glTF)。
输入格式:fbx / obj / gltf / glb / stl / ply / usd / usda / usdc / dae / blend
输出格式:glb(默认,单文件内嵌贴图)/ gltf(.gltf + .bin + 贴图分离)
--------------------------------------------------------------------------
★ GLB 重新导出(re-export)
--------------------------------------------------------------------------
把已经存在的 .glb 重新走一遍 Blender 的导入 → 导出流程,用于:
* 换导出参数重出(--draco 压缩、--tex-format jpeg 换贴图编码、
去掉相机/灯光/自定义属性、改 Y-up/Z-up …)
* 修好模型后用同一套参数刷新一批旧 GLB
* 统一把某个目录的 GLB 规格拉齐
用 --re-export 开启;它会自动把源格式设成 glb、输出格式设成 glb 并允许覆盖。
默认**原地替换**,替换前会把原文件备份到 `_glb_backup/`(可用 --backup-dir 改位置)。
# 原地重导出(自动备份原始 GLB)
python convert_models.py --re-export --input "E:\\Godot\\agame\\assets\\GLB"
# 把一批老 GLB 的贴图统一压成 JPEG(体积减小的稳妥做法,Godot 完全支持)
python convert_models.py --re-export --input "E:\\Godot\\agame\\assets\\GLB" \
--tex-format jpeg --tex-quality 85
注意:这是**往返(round-trip)**过程,模型会经 Blender 重新解析一次。
网格数、三角面数、材质数、贴图、动画、自定义属性都会保留;差异仅在:
* 顶点数可能因 glTF 规范在 UV/法线接缝处重新拆分而小幅增加(正常)
* 因此不压缩时体积通常持平,个别模型会 ±几个百分点
* 若开启 --draco,坐标会被量化,尺寸会有 1e-4 级别的微小变化
--------------------------------------------------------------------------
两种运行方式(脚本会自动判断)
--------------------------------------------------------------------------
1) 普通终端里直接跑(脚本会自己找到 Blender 并接管):
python convert_models.py --input "E:\\某个模型目录" --output "E:\\输出目录"
* 可用 --blender "D:\\...\\blender.exe" 手动指定 Blender
* Windows 下也可以直接双击同目录的 convert.bat(支持拖文件夹进去)
2) 在 Blender 里跑(把本文件丢进 Text Editor,改完 CONFIG 后 Run Script)
--------------------------------------------------------------------------
常用参数(全部可选,命令行会覆盖 CONFIG)
--------------------------------------------------------------------------
-i, --input DIR 输入目录
-o, --output DIR 输出目录;默认 __same__(输出到源文件旁边)
-e, --ext LIST 要处理的扩展名,逗号分隔,默认 fbx
-r, --recursive 递归扫描子目录(输出保持相对目录结构)
--overwrite 覆盖已存在的输出(默认跳过 → 可断点续跑)
--dry-run 只列出将要转换的文件,不实际执行
--suffix S 输出文件名加后缀,如 --suffix _conv
--filter GLOB 只处理文件名匹配该通配符的文件,如 --filter "Door_*"
--exclude GLOB 排除文件名匹配该通配符的文件,如 --exclude "*_LOD*"
--limit N 只处理前 N 个(调试用)
--re-export GLB 重新导出模式(见上方说明)
--in-place 输出直接替换源文件;替换前自动备份
--backup-dir DIR 指定备份目录(默认 <源目录>/_glb_backup)
--no-backup 原地替换时不备份(危险,不建议)
--scale FLOAT 导入时的全局缩放,默认 1.0(源文件按厘米建模时用 0.01)
--apply 导出前应用修改器
--no-anim 不导出动画
--no-texture 不导出贴图(仅保留材质基础色)
--tex-format auto|jpeg|webp 贴图编码;默认 auto 保持原编码,jpeg/webp 可显著减小体积
--tex-quality N 贴图质量 0-100(配合 --tex-format 使用,默认 75)
--leaf-bones 保留自动生成的 leaf bone(默认剔除,游戏资产更干净)
--tangents 导出切线
--no-extras 不导出自定义属性
--draco 启用 Draco 网格压缩(体积可降 80%+,但需引擎支持解码)
⚠ 本项目的 Godot(4.7)官方 glTF 导入器不支持 Draco,
开启后 GLB 会导入失败。除非另装 GDDraco 之类的
GDExtension 解码扩展,否则请保持默认关闭。
--cameras / --lights 顺带导出相机 / 灯光(默认都不导出)
--z-up 输出 Z-up(默认按 glTF 标准输出 Y-up)
--out-format glb|gltf 输出格式,默认 glb
-q, --quiet 只输出汇总
-v, --verbose 失败时打印完整堆栈
--csv 额外生成 _convert_report.csv
--------------------------------------------------------------------------
产物
--------------------------------------------------------------------------
<输出目录>/<同名模型>.glb 每个源文件一个
<输出目录>/_convert_report.txt 转换报告(成功/跳过/失败、耗时、体积)
<输出目录>/_convert_report.csv 加 --csv 时才有
原地重导出时:原文件被替换,原始文件备份在 <源目录>/_glb_backup/
退出码:0 全部成功;1 有失败;2 参数或环境错误。
"""
from __future__ import annotations
import fnmatch
import glob
import os
import shutil
import subprocess
import sys
import time
import traceback
# ===========================================================================
# CONFIG —— 在 Blender 里 Run Script 时生效;命令行参数会覆盖这里的值
# ===========================================================================
CONFIG = {
"input": r"E:\Godot\agame\assets\FBX",
"output": "__same__", # 或写成 r"E:\Godot\agame\assets\GLB"
"ext": "fbx",
"recursive": False,
"overwrite": False, # True = 覆盖已有文件;False = 跳过(续跑)
"apply_modifiers": False,
"animations": True,
"textures": True,
"keep_leaf_bones": False,
"scale": 1.0,
"limit": 0,
"yup": True,
"cameras": False,
"lights": False,
"extras": True,
"tangents": False,
"draco": False,
"out_format": "glb",
"suffix": "",
"filter": "",
"exclude": "",
"dry_run": False,
"quiet": False,
"verbose": False,
"csv": False,
# ---- GLB 重新导出 ----
"re_export": False, # 重新导出模式:源=glb,输出=glb,允许覆盖
"in_place": False, # 原地替换源文件(自动备份)
"backup_dir": "", # 空 = <源目录>/_glb_backup
"no_backup": False, # 原地替换时不备份(危险)
"tex_format": "auto", # auto / jpeg / webp
"tex_quality": 0, # 0 = 用 Blender 默认
}
BOOL_FLAGS = {
"--recursive": ("recursive", True),
"--overwrite": ("overwrite", True),
"--apply": ("apply_modifiers", True),
"--no-anim": ("animations", False),
"--no-texture": ("textures", False),
"--leaf-bones": ("keep_leaf_bones", True),
"--tangents": ("tangents", True),
"--no-extras": ("extras", False),
"--draco": ("draco", True),
"--cameras": ("cameras", True),
"--lights": ("lights", True),
"--z-up": ("yup", False),
"--dry-run": ("dry_run", True),
"-q": ("quiet", True),
"--quiet": ("quiet", True),
"-v": ("verbose", True),
"--verbose": ("verbose", True),
"--csv": ("csv", True),
# ---- GLB 重新导出 ----
"--re-export": ("re_export", True),
"--reexport": ("re_export", True),
"--in-place": ("in_place", True),
"--no-backup": ("no_backup", True),
}
VALUE_FLAGS = {
"-i": "input", "--input": "input",
"-o": "output", "--output": "output", "--out": "output",
"-e": "ext", "--ext": "ext",
"--scale": "scale",
"--limit": "limit",
"--suffix": "suffix",
"--filter": "filter",
"--exclude": "exclude",
"--out-format": "out_format",
"--blender": "blender", # 只在外部启动模式下使用
"--backup-dir": "backup_dir",
"--tex-format": "tex_format",
"--tex-quality": "tex_quality",
}
HELP = __doc__
# ===========================================================================
# 命令行解析
# ===========================================================================
def parse_opts(argv):
"""解析纯参数列表(不含 Blender 自身参数)。"""
opts = dict(CONFIG)
opts.setdefault("blender", "")
seen = set()
i = 0
while i < len(argv):
a = argv[i]
key, eq, inline = a.partition("=")
if key in ("-h", "--help"):
print(HELP)
raise SystemExit(0)
if key in VALUE_FLAGS:
val = inline if eq else (argv[i + 1] if i + 1 < len(argv) else None)
if not eq:
i += 1
if val is None:
raise SystemExit("[ERROR] 参数 %s 缺少取值" % a)
name = VALUE_FLAGS[key]
if name == "scale":
opts[name] = float(val)
elif name in ("limit", "tex_quality"):
opts[name] = int(val)
elif name in ("out_format", "tex_format"):
opts[name] = val.lower()
else:
opts[name] = val
seen.add(name)
i += 1
continue
if key in BOOL_FLAGS:
name, value = BOOL_FLAGS[key]
opts[name] = value
seen.add(name)
i += 1
continue
i += 1
opts["_seen"] = seen
return apply_mode(opts)
def apply_mode(opts):
"""把 --re-export / --in-place 归一化成具体行为,并做约束校验。"""
seen = opts.pop("_seen", set())
if opts["out_format"] not in ("glb", "gltf"):
raise SystemExit("[ERROR] --out-format 只能是 glb 或 gltf")
if opts["tex_format"] not in ("auto", "jpeg", "webp"):
raise SystemExit("[ERROR] --tex-format 只能是 auto / jpeg / webp")
if not 0 <= int(opts["tex_quality"]) <= 100:
raise SystemExit("[ERROR] --tex-quality 需要在 0-100 之间")
if opts["re_export"]:
# 重新导出:源 glb -> 输出 glb,允许覆盖
if "ext" not in seen:
opts["ext"] = "glb"
opts["out_format"] = "glb"
opts["overwrite"] = True
# 没指定输出目录时,默认原地替换(并自动备份)
if opts["output"] == "__same__" and "in_place" not in seen:
opts["in_place"] = True
if opts["in_place"]:
if opts["out_format"] != "glb":
raise SystemExit("[ERROR] --in-place 只支持 glb 输出(多文件格式无法原地替换)")
exts = {e.strip().lower().lstrip(".") for e in str(opts["ext"]).split(",") if e.strip()}
if exts != {"glb"}:
raise SystemExit("[ERROR] --in-place 要求源文件为 .glb,当前 --ext=%s" % opts["ext"])
opts["overwrite"] = True
return opts
def blender_user_args(raw_argv):
"""Blender 运行模式下,用户参数一律放在 '--' 之后。
在 Blender 的 Text Editor 里直接 Run Script 时通常没有 '--',
此时返回空列表 —— 即完全使用脚本顶部的 CONFIG。
"""
if "--" in raw_argv:
return raw_argv[raw_argv.index("--") + 1:]
return []
# ===========================================================================
# 日志
# ===========================================================================
def log(msg, quiet=False):
if quiet:
return
try:
print(msg, flush=True)
except UnicodeEncodeError:
print(msg.encode("ascii", "replace").decode("ascii"), flush=True)
except Exception:
pass
# ===========================================================================
# Blender 定位 + 外部启动(在普通 Python 里运行时走这里)
# ===========================================================================
def find_blender():
"""返回 blender 可执行文件路径,找不到返回 None。"""
env = os.environ.get("BLENDER_EXE") or os.environ.get("BLENDER")
if env and os.path.isfile(env):
return env
exe = "blender.exe" if os.name == "nt" else "blender"
from shutil import which
w = which(exe) or which("blender")
if w:
return w
patterns = [
r"C:\Program Files\Blender Foundation\Blender*\blender.exe",
r"C:\Program Files (x86)\Blender Foundation\Blender*\blender.exe",
r"D:\Program Files\Blender Foundation\Blender*\blender.exe",
r"E:\Program Files\Blender Foundation\Blender*\blender.exe",
r"C:\Program Files\Steam\steamapps\common\Blender\blender.exe",
r"D:\Steam\steamapps\common\Blender\blender.exe",
"/Applications/Blender.app/Contents/MacOS/Blender",
"/usr/local/bin/blender",
"/usr/bin/blender",
"/snap/bin/blender",
"/opt/blender*/blender",
]
found = []
for p in patterns:
found.extend(glob.glob(p))
def ver_key(path):
digits = []
cur = ""
for ch in os.path.basename(os.path.dirname(path)) + os.path.basename(path):
if ch.isdigit():
cur += ch
elif cur:
digits.append(int(cur)); cur = ""
if cur:
digits.append(int(cur))
return digits
found = [f for f in found if os.path.isfile(f)]
if not found:
return None
try:
found.sort(key=ver_key)
except Exception:
pass
return found[-1]
def relaunch(opts, raw_argv):
blender = opts.get("blender") or find_blender()
if not blender:
print("[ERROR] 找不到 Blender。请用 --blender 指定,例如:")
print(' python convert_models.py --blender "D:\\Program Files\\Blender '
'Foundation\\Blender 5.1\\blender.exe" --input ... --output ...')
return 2
child_args = raw_argv[1:]
cmd = [blender, "-b", "--factory-startup", "--python", os.path.abspath(__file__), "--"] + child_args
print("[INFO] Blender: %s" % blender, flush=True)
print("[INFO] 输出格式: %s -> %s" % (opts.get("ext", "?"), opts.get("out_format", "glb")), flush=True)
print(flush=True)
try:
return subprocess.call(cmd)
except OSError as exc:
print("[ERROR] 启动 Blender 失败: %s" % exc)
return 2
# ===========================================================================
# 以下函数都在 Blender 内运行
# ===========================================================================
def scene_reset(bpy):
"""彻底清空场景,避免上一个模型的数据污染下一个。"""
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
for coll in list(bpy.data.collections):
bpy.data.collections.remove(coll)
for _ in range(3):
bpy.data.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True)
def _try_op(op, **kwargs):
"""尝试调用 Blender 算子;成功返回 True。"""
try:
op(**kwargs)
return True
except Exception:
return False
def import_model(bpy, path, opts):
"""按扩展名导入模型,返回实际使用的导入器名称。"""
ext = os.path.splitext(path)[1].lower().lstrip(".")
scale = opts["scale"]
leaf = not opts["keep_leaf_bones"]
if ext == "fbx":
# Blender 5.x 新版原生导入器,失败再退回 4.x 的旧算子
if hasattr(bpy.ops.wm, "fbx_import") and _try_op(
bpy.ops.wm.fbx_import, filepath=path, global_scale=scale,
ignore_leaf_bones=leaf, use_anim=True, use_custom_normals=True):
return "wm.fbx_import"
scene_reset(bpy)
bpy.ops.import_scene.fbx(
filepath=path, global_scale=scale, ignore_leaf_bones=leaf,
automatic_bone_orientation=False, use_anim=True, use_custom_normals=True)
return "import_scene.fbx"
if ext == "obj":
if _try_op(bpy.ops.wm.obj_import, filepath=path, global_scale=scale):
return "wm.obj_import"
bpy.ops.import_scene.obj(filepath=path, global_scale=scale)
return "import_scene.obj"
if ext in ("gltf", "glb"):
bpy.ops.import_scene.gltf(filepath=path)
return "import_scene.gltf"
if ext == "stl":
if _try_op(bpy.ops.wm.stl_import, filepath=path, global_scale=scale):
return "wm.stl_import"
bpy.ops.import_mesh.stl(filepath=path, global_scale=scale)
return "import_mesh.stl"
if ext == "ply":
if _try_op(bpy.ops.wm.ply_import, filepath=path):
return "wm.ply_import"
bpy.ops.import_mesh.ply(filepath=path)
return "import_mesh.ply"
if ext in ("usd", "usda", "usdc", "usdz"):
bpy.ops.wm.usd_import(filepath=path, global_scale=scale)
return "wm.usd_import"
if ext in ("dae", "dae.gz"):
bpy.ops.wm.collada_import(filepath=path)
return "wm.collada_import"
if ext == "blend":
bpy.ops.wm.open_mainfile(filepath=path)
return "wm.open_mainfile"
raise RuntimeError("不支持导入的格式: .%s" % ext)
def export_model(bpy, dst, opts):
# 贴图编码:auto 保持原编码;jpeg/webp 会重编码,体积通常显著下降
if not opts["textures"]:
img_fmt = "NONE"
else:
img_fmt = {"auto": "AUTO", "jpeg": "JPEG", "webp": "WEBP"}[opts["tex_format"]]
kwargs = dict(
filepath=dst,
export_format="GLB" if opts["out_format"] == "glb" else "GLTF_SEPARATE",
use_selection=False,
export_apply=opts["apply_modifiers"],
export_yup=opts["yup"],
export_materials="EXPORT",
export_image_format=img_fmt,
export_animations=opts["animations"],
export_cameras=opts["cameras"],
export_lights=opts["lights"],
export_extras=opts["extras"],
export_texcoords=True,
export_normals=True,
export_tangents=opts["tangents"],
)
if opts["textures"] and int(opts["tex_quality"]) > 0:
kwargs["export_image_quality"] = int(opts["tex_quality"])
if opts["draco"]:
kwargs.update(
export_draco_mesh_compression_enable=True,
export_draco_mesh_compression_level=6,
)
try:
bpy.ops.export_scene.gltf(**kwargs)
except TypeError:
# 老版本 Blender 参数名不兼容时,退化到最小参数集
bpy.ops.export_scene.gltf(
filepath=dst,
export_format=kwargs["export_format"],
use_selection=False,
export_apply=opts["apply_modifiers"],
export_yup=opts["yup"],
export_animations=opts["animations"],
)
# ---------------------------------------------------------------------------
# 文件收集
# ---------------------------------------------------------------------------
def collect_files(root, exts, recursive, include, exclude):
"""按扩展名/通配符筛选源文件,返回排序后的绝对路径列表。"""
if not os.path.isdir(root):
return []
if recursive:
walker = ((d, fs) for d, _sub, fs in os.walk(root))
else:
walker = [(root, os.listdir(root))]
hits = []
for dirpath, filenames in walker:
for fn in filenames:
if fn.startswith("."):
continue
if not os.path.isfile(os.path.join(dirpath, fn)):
continue
if os.path.splitext(fn)[1].lower().lstrip(".") not in exts:
continue
if include and not fnmatch.fnmatch(fn.lower(), include.lower()):
continue
if exclude and fnmatch.fnmatch(fn.lower(), exclude.lower()):
continue
hits.append(os.path.join(dirpath, fn))
return sorted(hits)
def _fmt_size(num_bytes):
for unit in ("B", "KB", "MB", "GB"):
if num_bytes < 1024 or unit == "GB":
return "%.2f%s" % (num_bytes, unit)
num_bytes /= 1024.0
def _pct(old, new):
"""体积变化百分比,例如 -74%。"""
if not old:
return ""
return "%+.0f%%" % ((new - old) * 100.0 / old)
def backup_file(src, backup_root, src_root):
"""把源文件复制进备份目录(保持相对目录结构)。
已存在同名备份时不覆盖 —— 保证备份里始终是**最早那一版**原始文件,
重复跑 --re-export 不会把中间结果覆盖掉原始文件。
"""
dst = os.path.join(backup_root, os.path.relpath(src, src_root))
if os.path.exists(dst):
return dst
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
# 让 Godot 跳过备份目录
gdignore = os.path.join(backup_root, ".gdignore")
if not os.path.exists(gdignore):
try:
open(gdignore, "w").close()
except OSError:
pass
return dst
# ---------------------------------------------------------------------------
# 主流程(Blender 内)
# ---------------------------------------------------------------------------
def run_inside_blender(raw_argv):
import bpy
try:
opts = parse_opts(blender_user_args(raw_argv))
except SystemExit as exc:
if exc.code:
print(exc.code)
return 0 if exc.code == 0 else 2
src_root = os.path.abspath(opts["input"]) if opts["input"] else ""
if not src_root or not os.path.isdir(src_root):
print("[ERROR] 输入目录不存在: %s" % (src_root or "(空)"))
print(" 用 --input \"目录\" 指定,或修改脚本顶部的 CONFIG。")
return 2
out_root = src_root if opts["output"] == "__same__" else os.path.abspath(opts["output"])
exts = {e.strip().lower().lstrip(".") for e in str(opts["ext"]).split(",") if e.strip()}
files = collect_files(src_root, exts, opts["recursive"], opts["filter"], opts["exclude"])
if opts["limit"] > 0:
files = files[:int(opts["limit"])]
# ---- 原地替换模式准备 ----
in_place = bool(opts["in_place"])
hint_same_dir = (not in_place and os.path.normcase(out_root) == os.path.normcase(src_root)
and opts["out_format"] == "glb" and exts == {"glb"})
if hint_same_dir:
in_place = True
backup_root = ""
if in_place:
if opts["no_backup"]:
backup_root = ""
else:
backup_root = os.path.abspath(opts["backup_dir"]) if opts["backup_dir"] \
else os.path.join(src_root, "_glb_backup")
out_root = backup_root or src_root
quiet = opts["quiet"]
log("=" * 72)
log("批量模型转换 -> %s%s"
% (opts["out_format"].upper(), " [原地重新导出]" if in_place else ""))
log("输入目录 : %s" % src_root)
if in_place:
log("输出 : 原地替换源文件")
log("备份 : %s" % (backup_root if backup_root else "(未启用备份)"))
else:
log("输出目录 : %s" % out_root)
log("格式 : %s 找到文件: %d" % (",".join(sorted(exts)), len(files)))
log("递归 : %-5s 覆盖: %-5s 缩放: %s%s"
% (opts["recursive"], opts["overwrite"], opts["scale"],
(" Draco: %s" % opts["draco"]) if opts["draco"] else ""))
if in_place and hint_same_dir and not opts["in_place"]:
log("提示 : 输出目录与源目录相同,已自动切换为原地替换(原文件会先备份)")
log("=" * 72)
if not files:
log("[WARN] 没有匹配到任何源文件。")
return 0
if opts["dry_run"]:
for idx, src in enumerate(files, 1):
log("[%3d/%3d] 待转换 %s" % (idx, len(files), os.path.relpath(src, src_root)))
log("-" * 72)
log("[DRY-RUN] 共 %d 个文件,未实际执行。" % len(files))
return 0
ok, skipped, failed = 0, 0, []
rows = []
t_all = time.time()
out_bytes = 0
for idx, src in enumerate(files, 1):
stem = os.path.splitext(os.path.basename(src))[0]
if in_place:
dst_dir = os.path.dirname(src)
dst = src
else:
if opts["recursive"]:
rel = os.path.relpath(os.path.dirname(src), src_root)
dst_dir = out_root if rel == "." else os.path.join(out_root, rel)
else:
dst_dir = out_root
dst = os.path.join(dst_dir, stem + opts["suffix"] + "." + opts["out_format"])
tag = "[%3d/%3d]" % (idx, len(files))
if not in_place:
if os.path.abspath(dst) == os.path.abspath(src):
log("%s 跳过(输入输出同名) %s" % (tag, stem), quiet)
skipped += 1
continue
if os.path.exists(dst) and not opts["overwrite"]:
log("%s 跳过(已存在) %s" % (tag, stem), quiet)
skipped += 1
rows.append((src, dst, "skipped", "", "0", ""))
continue
os.makedirs(dst_dir, exist_ok=True)
before = os.path.getsize(src) if os.path.isfile(src) else 0
# 原地模式先写临时文件,成功后再原子替换,失败不会破坏原文件
tmp = os.path.join(dst_dir, stem + "__reexport_tmp__." + opts["out_format"]) if in_place else dst
t0 = time.time()
try:
scene_reset(bpy)
importer = import_model(bpy, src, opts)
if not bpy.data.objects:
raise RuntimeError("导入后场景为空")
export_model(bpy, tmp, opts)
if in_place:
if backup_root:
backup_file(src, backup_root, src_root)
os.replace(tmp, dst)
size = os.path.getsize(dst)
out_bytes += size
dt = time.time() - t0
note = ("%s -> %s (%s)" % (_fmt_size(before), _fmt_size(size), _pct(before, size))
if in_place else _fmt_size(size))
log("%s OK %-38s %-24s %5.1fs (%s)"
% (tag, stem, note, dt, importer), quiet)
rows.append((src, dst, "ok", importer, "%.2f" % dt, note))
ok += 1
except Exception as exc:
dt = time.time() - t0
log("%s FAIL %-38s %s" % (tag, stem, exc), quiet)
if opts["verbose"]:
log(traceback.format_exc())
rows.append((src, dst, "failed", "", "%.2f" % dt, str(exc)))
failed.append((src, str(exc), traceback.format_exc()))
try:
if os.path.exists(tmp):
os.remove(tmp)
if not in_place and os.path.exists(dst):
os.remove(dst)
except OSError:
pass
total = time.time() - t_all
src_bytes = sum(os.path.getsize(f[0]) for f in rows if os.path.isfile(f[0])) if rows else 0
# ---- 报告 ----
report = os.path.join(out_root, "_convert_report.txt")
csv_path = os.path.join(out_root, "_convert_report.csv")
header = "批量模型重新导出报告" if in_place else "批量模型转换报告"
try:
os.makedirs(out_root, exist_ok=True)
with open(report, "w", encoding="utf-8") as f:
f.write("%s\n" % header)
f.write("时间 : %s\n" % time.strftime("%Y-%m-%d %H:%M:%S"))
f.write("输入目录 : %s\n" % src_root)
if in_place:
f.write("输出 : 原地替换源文件\n")
f.write("备份目录 : %s\n" % (backup_root if backup_root else "(未启用备份)"))
else:
f.write("输出目录 : %s\n" % out_root)
f.write("输入格式 : %s\n输出格式 : %s\n" % (",".join(sorted(exts)), opts["out_format"]))
f.write("导出选项 : draco=%s apply=%s anim=%s texture=%s/%s extras=%s yup=%s\n"
% (opts["draco"], opts["apply_modifiers"], opts["animations"],
opts["textures"], opts["tex_format"], opts["extras"], opts["yup"]))
f.write("总计 %d 成功 %d 跳过 %d 失败 %d\n" % (len(files), ok, skipped, len(failed)))
f.write("耗时 : %.1fs\n" % total)
f.write("产物体积 : %s\n" % _fmt_size(out_bytes))
f.write("\n%-10s %-46s %-8s %s\n" % ("状态", "源文件", "耗时s", "说明"))
f.write("-" * 100 + "\n")
for src, dst, status, imp, dt, note in rows:
f.write("%-10s %-46s %-8s %s\n" % (status, os.path.relpath(src, src_root), dt, note))
if failed:
f.write("\n--- 失败清单 ---\n")
for src, err, _tb in failed:
f.write("\n%s\n %s\n" % (src, err))
except OSError as exc:
report = "(写入失败: %s)" % exc
if opts["csv"]:
try:
import csv as _csv
with open(csv_path, "w", encoding="utf-8-sig", newline="") as f:
w = _csv.writer(f)
w.writerow(["status", "source", "output", "importer", "seconds", "note"])
for row in rows:
w.writerow([row[2], row[0], row[1], row[3], row[4], row[5]])
except Exception as exc:
csv_path = "(写入失败: %s)" % exc
log("-" * 72)
log("完成: 成功 %d / 跳过 %d / 失败 %d 耗时 %.1fs 产物 %s"
% (ok, skipped, len(failed), total, _fmt_size(out_bytes)))
if in_place:
log("原文件已原地替换;备份位置: %s" % (backup_root if backup_root else "(未备份)"))
if failed:
log("失败清单:")
for src, err, _tb in failed:
log(" - %s (%s)" % (os.path.basename(src), err))
if in_place:
log(" (失败的文件没有被改动,原文件保持原样)")
log("报告: %s" % report)
if opts["csv"]:
log("CSV : %s" % csv_path)
return 1 if failed else 0
# ===========================================================================
# 入口:自动判断是在 Blender 内还是在普通 Python 里
# ===========================================================================
def main():
raw_argv = list(sys.argv)
inside = "bpy" in sys.modules
if not inside:
try:
import bpy # noqa: F401
inside = True
except ImportError:
inside = False
if inside:
return run_inside_blender(raw_argv)
# 普通 Python:解析参数后转交给 Blender
try:
opts = parse_opts(raw_argv[1:])
except SystemExit as exc:
if exc.code:
print(exc.code)
return 0 if exc.code == 0 else 2
return relaunch(opts, raw_argv)
if __name__ == "__main__":
sys.exit(main())