#!/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())