644 lines
20 KiB
Python
644 lines
20 KiB
Python
"""UE5 静态模型几何提取脚本。
|
||
|
||
对 index.json 中发现的每个 StaticMesh 提取:
|
||
- 整体包围盒(min, max, size)
|
||
- 锚点类型和 XY 偏移
|
||
- 网格复杂度(顶点数、三角形数)
|
||
- Socket 列表
|
||
- 材质槽位
|
||
- 包围盒 6 面候选
|
||
- 射线检测细节包围盒(spawn + Line Trace)
|
||
- AI 占位字段(key_surfaces, style_tags, scene_tags 等)
|
||
|
||
输出: output/meshes/<SubfolderName>.json,每个子目录一个文件
|
||
|
||
用法:
|
||
交互模式: exec(open(r"...\analyze_meshes.py").read())
|
||
命令行: 通过 run_all.py 调用
|
||
"""
|
||
|
||
import sys
|
||
import math
|
||
from pathlib import Path
|
||
|
||
_SCRIPT_DIR = Path(__file__).resolve().parent
|
||
if str(_SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||
|
||
from _common import (
|
||
HAS_UNREAL, get_scan_root, get_output_dir, get_steps,
|
||
write_json, read_json, wait_for_editor, progress_log,
|
||
iso_now, safe_subfolder_name, classify_pivot, compute_bbox_faces,
|
||
)
|
||
|
||
TAG = "[MeshAnalysis]"
|
||
|
||
|
||
# ============================================================
|
||
# 自动 Tag 生成
|
||
# ============================================================
|
||
|
||
# (pattern, [style_tags])
|
||
_MESH_NAME_RULES = [
|
||
# 家具
|
||
(r"Table", ["furniture", "table"]),
|
||
(r"Chair|Armchair", ["furniture", "seating"]),
|
||
(r"Bed", ["furniture", "bed"]),
|
||
(r"Monitor|Laptop|Keyboard", ["furniture", "electronics"]),
|
||
(r"Broom|Radiator", ["furniture", "appliance"]),
|
||
# 建筑
|
||
(r"Wall", ["architecture", "wall"]),
|
||
(r"Floor", ["architecture", "floor"]),
|
||
(r"Door", ["architecture", "door"]),
|
||
(r"Window", ["architecture", "window"]),
|
||
(r"Stair", ["architecture", "stair"]),
|
||
(r"Beam|Ladder", ["architecture", "structure"]),
|
||
(r"Barrier|Fence", ["architecture", "barrier"]),
|
||
# 灯光
|
||
(r"Lamp|Light|Spot|Flash|Torch", ["lighting"]),
|
||
# 车辆
|
||
(r"\bCar\b|Truck|Bus|Boat|Plane|Metro|Trailer", ["vehicle"]),
|
||
# 工业
|
||
(r"Barrel|WoodenBox|WoodenCrate|Pallet|Container", ["industrial"]),
|
||
# 道路
|
||
(r"Road|Barricade|SpeedBump|Cone", ["outdoor", "road"]),
|
||
# 基础体
|
||
(r"\bCube\b|\bCylinder\b|\bSphere\b|\bCone\b|\bRamp\b|\bPipe\b|Quarter", ["primitive"]),
|
||
# 浴室
|
||
(r"Toilet|Urinal", ["bathroom"]),
|
||
# 装饰
|
||
(r"Book|Bottle|Mug|Plate|Can|Flask|Padlock|Bucket|Trash", ["decoration", "prop"]),
|
||
# 自然
|
||
(r"Tree|Foliage|Mount", ["vegetation"]),
|
||
# 武器
|
||
(r"Pistol|Sniper|Sword|Dagger|Target", ["weapon"]),
|
||
# 人物
|
||
(r"Human|Camera|Cage", ["character", "reference"]),
|
||
# 展示
|
||
(r"DisplayPanel", ["display"]),
|
||
]
|
||
|
||
# (subdirectory keyword, [scene_tags])
|
||
_MESH_SCENE_RULES = [
|
||
(r"Furnitures|Bedroom|Bathroom|Decorations", ["indoor", "home"]),
|
||
(r"Lights", ["indoor", "outdoor"]),
|
||
(r"Vehicles|Roads", ["outdoor", "street"]),
|
||
(r"Industrials|Pipes|Gears", ["outdoor", "industrial"]),
|
||
(r"SmallBuildings|BigBuildings", ["outdoor", "building"]),
|
||
(r"Architecture", ["architecture", "construction"]),
|
||
(r"Vegetations", ["outdoor", "nature"]),
|
||
(r"Primitives|Extra", ["utility"]),
|
||
(r"Weapons|Interactions", ["gameplay"]),
|
||
]
|
||
|
||
|
||
def _generate_mesh_tags(name: str, subfolder: str) -> tuple:
|
||
"""根据模型名和目录生成 style_tags 和 scene_tags。
|
||
|
||
Returns:
|
||
(style_tags, scene_tags)
|
||
"""
|
||
import re
|
||
style_tags = []
|
||
scene_tags = []
|
||
|
||
# 基于名称的 style tags
|
||
for pattern, tags in _MESH_NAME_RULES:
|
||
if re.search(pattern, name):
|
||
for t in tags:
|
||
if t not in style_tags:
|
||
style_tags.append(t)
|
||
|
||
# 基于目录的 scene tags
|
||
for pattern, tags in _MESH_SCENE_RULES:
|
||
if re.search(pattern, subfolder):
|
||
for t in tags:
|
||
if t not in scene_tags:
|
||
scene_tags.append(t)
|
||
|
||
# 如果没有任何 style tag,标记为 unknown
|
||
if not style_tags:
|
||
style_tags = ["unknown"]
|
||
if not scene_tags:
|
||
scene_tags = ["unclassified"]
|
||
|
||
return style_tags, scene_tags
|
||
|
||
|
||
# ============================================================
|
||
# 单个 Mesh 分析
|
||
# ============================================================
|
||
|
||
def _round3(v):
|
||
"""将浮点数或列表保留 3 位小数。"""
|
||
if isinstance(v, (list, tuple)):
|
||
return [round(x, 3) if isinstance(x, float) else x for x in v]
|
||
return round(v, 3) if isinstance(v, float) else v
|
||
|
||
|
||
def _try_get_complexity(mesh) -> dict:
|
||
"""尝试获取网格复杂度信息。"""
|
||
info = {"num_verts": None, "num_triangles": None}
|
||
try:
|
||
import unreal
|
||
# 尝试获取 LOD 数量
|
||
num_lod = mesh.get_num_lods()
|
||
info["num_lods"] = num_lod
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# 尝试获取 section 信息
|
||
num_sections = mesh.get_num_sections(0)
|
||
info["num_sections"] = num_sections
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# 尝试通过 render data 获取顶点数
|
||
render_data = mesh.get_render_data()
|
||
if render_data:
|
||
info["num_verts"] = render_data.num_vertices
|
||
info["num_triangles"] = render_data.num_triangles
|
||
except Exception:
|
||
pass
|
||
|
||
return info
|
||
|
||
|
||
def _try_get_sockets(mesh) -> list:
|
||
"""尝试获取 Socket 名称列表。"""
|
||
sockets = []
|
||
try:
|
||
import unreal
|
||
socket_names = mesh.find_socket_names() if hasattr(mesh, 'find_socket_names') else []
|
||
for name in socket_names:
|
||
try:
|
||
sock = mesh.find_socket(name)
|
||
if sock:
|
||
loc = sock.relative_location
|
||
rot = sock.relative_rotation
|
||
sockets.append({
|
||
"name": str(name),
|
||
"relative_location": _round3([loc.x, loc.y, loc.z]),
|
||
"relative_rotation": _round3([rot.pitch, rot.yaw, rot.roll]),
|
||
})
|
||
except Exception:
|
||
sockets.append({"name": str(name), "relative_location": None, "relative_rotation": None})
|
||
except Exception:
|
||
pass
|
||
return sockets
|
||
|
||
|
||
def _try_get_material_slots(mesh) -> list:
|
||
"""尝试获取材质槽位。"""
|
||
import unreal
|
||
slots = []
|
||
|
||
# 方法 1: get_material(i)
|
||
try:
|
||
num_mat = mesh.get_num_materials()
|
||
for i in range(num_mat):
|
||
slot_info = {"index": i, "slot_name": None, "material_name": None}
|
||
try:
|
||
mat = mesh.get_material(i)
|
||
if mat:
|
||
slot_info["material_name"] = mat.get_name()
|
||
except Exception:
|
||
pass
|
||
slots.append(slot_info)
|
||
except Exception:
|
||
pass
|
||
|
||
# 方法 2: get_editor_property("static_materials")
|
||
if not slots:
|
||
try:
|
||
static_mats = mesh.get_editor_property("static_materials")
|
||
if static_mats:
|
||
for i, sm in enumerate(static_mats):
|
||
slot_info = {"index": i, "slot_name": None, "material_name": None}
|
||
try:
|
||
slot_info["slot_name"] = sm.get_editor_property("material_slot_name")
|
||
mi = sm.get_editor_property("material_interface")
|
||
if mi:
|
||
slot_info["material_name"] = mi.get_name()
|
||
except Exception:
|
||
pass
|
||
slots.append(slot_info)
|
||
except Exception:
|
||
pass
|
||
|
||
# 方法 3: get_material_slot_names() — 至少获取槽位名
|
||
if not slots:
|
||
try:
|
||
slot_names = mesh.get_material_slot_names()
|
||
for i, name in enumerate(slot_names):
|
||
slots.append({"index": i, "slot_name": str(name), "material_name": None})
|
||
except Exception:
|
||
pass
|
||
|
||
# 方法 4: 用 section 数作为 fallback
|
||
if not slots:
|
||
try:
|
||
num_sections = mesh.get_num_sections(0)
|
||
for i in range(num_sections):
|
||
slots.append({"index": i, "slot_name": None, "material_name": None})
|
||
except Exception:
|
||
pass
|
||
|
||
return slots
|
||
|
||
|
||
def _analyze_single_mesh(asset_info: dict, subfolder: str = "") -> dict:
|
||
"""分析单个 StaticMesh,返回完整信息。"""
|
||
import unreal
|
||
|
||
ue_path = asset_info["ue_path"]
|
||
name = asset_info["name"]
|
||
|
||
result = {
|
||
"ue_path": ue_path,
|
||
"bounding_box": None,
|
||
"pivot": None,
|
||
"mesh_complexity": {},
|
||
"sockets": [],
|
||
"material_slots": [],
|
||
"detail_boxes": [],
|
||
"bbox_faces": [],
|
||
"analysis_notes": [],
|
||
# AI Pass 2 占位
|
||
"key_surfaces": [],
|
||
"detail_boxes_annotated": [],
|
||
"ground_sink_estimate": None,
|
||
"style_tags": [],
|
||
"scene_tags": [],
|
||
}
|
||
|
||
# 加载 mesh
|
||
try:
|
||
mesh = unreal.load_asset(ue_path)
|
||
except Exception as e:
|
||
result["analysis_notes"].append(f"加载失败: {e}")
|
||
return result
|
||
|
||
if mesh is None:
|
||
result["analysis_notes"].append("加载返回 None")
|
||
return result
|
||
|
||
# ---- 1. 包围盒 ----
|
||
try:
|
||
bb = mesh.get_bounding_box()
|
||
bmin = (bb.min.x, bb.min.y, bb.min.z)
|
||
bmax = (bb.max.x, bb.max.y, bb.max.z)
|
||
size = (bmax[0] - bmin[0], bmax[1] - bmin[1], bmax[2] - bmin[2])
|
||
|
||
result["bounding_box"] = {
|
||
"min": _round3(list(bmin)),
|
||
"max": _round3(list(bmax)),
|
||
"size": _round3(list(size)),
|
||
}
|
||
|
||
# ---- 2. 锚点 ----
|
||
result["pivot"] = classify_pivot(bmin, bmax)
|
||
|
||
# ---- 3. 包围盒面 ----
|
||
result["bbox_faces"] = compute_bbox_faces(bmin, bmax)
|
||
|
||
except Exception as e:
|
||
result["analysis_notes"].append(f"包围盒提取失败: {e}")
|
||
return result
|
||
|
||
# ---- 4. 网格复杂度 ----
|
||
result["mesh_complexity"] = _try_get_complexity(mesh)
|
||
|
||
# ---- 5. Sockets ----
|
||
result["sockets"] = _try_get_sockets(mesh)
|
||
|
||
# ---- 6. 材质槽位 ----
|
||
result["material_slots"] = _try_get_material_slots(mesh)
|
||
|
||
# ---- 7. 细节包围盒 ----
|
||
detail_boxes, trace_debug = _try_trace_detail_boxes(mesh, bmin, bmax)
|
||
if trace_debug:
|
||
result["analysis_notes"].extend(trace_debug)
|
||
if detail_boxes:
|
||
result["detail_boxes"] = detail_boxes
|
||
else:
|
||
# 降级: 仅整体包围盒
|
||
result["detail_boxes"] = [{
|
||
"label": "overall",
|
||
"min": _round3(list(bmin)),
|
||
"max": _round3(list(bmax)),
|
||
"size": _round3(list(size)),
|
||
"source": "overall",
|
||
}]
|
||
|
||
# ---- 8. 自动 Tags ----
|
||
style_tags, scene_tags = _generate_mesh_tags(name, subfolder)
|
||
result["style_tags"] = style_tags
|
||
result["scene_tags"] = scene_tags
|
||
|
||
return result
|
||
|
||
|
||
# ============================================================
|
||
# Line Trace 细节包围盒
|
||
# ============================================================
|
||
|
||
# UE5.7 Python correct enum names (discovered via probe)
|
||
_TRACE_CHANNEL = None
|
||
_DRAW_DEBUG = None
|
||
|
||
def _get_trace_enums():
|
||
"""Lazy-init: get the correct UE5.7 trace enums."""
|
||
global _TRACE_CHANNEL, _DRAW_DEBUG
|
||
if _TRACE_CHANNEL is not None:
|
||
return _TRACE_CHANNEL is not None
|
||
import unreal
|
||
try:
|
||
_TRACE_CHANNEL = unreal.TraceTypeQuery.TRACE_TYPE_QUERY1
|
||
_DRAW_DEBUG = unreal.DrawDebugTrace.NONE
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _try_trace_detail_boxes(mesh, bmin: tuple, bmax: tuple) -> tuple:
|
||
"""通过 Line Trace 获取细节包围盒。
|
||
|
||
Returns:
|
||
(detail_boxes, debug_notes)
|
||
"""
|
||
import unreal
|
||
|
||
detail_boxes = []
|
||
debug = []
|
||
|
||
size = (bmax[0] - bmin[0], bmax[1] - bmin[1], bmax[2] - bmin[2])
|
||
if max(size) < 10:
|
||
return [], ["skip_trace: model too small"]
|
||
|
||
# Check trace API availability
|
||
if not _get_trace_enums():
|
||
return [], ["trace_unavailable: enums not found"]
|
||
|
||
# ---- Spawn temporary Actor ----
|
||
try:
|
||
editor_subsystem = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
|
||
if not editor_subsystem:
|
||
return [], ["trace_fail: no subsystem"]
|
||
except Exception as e:
|
||
return [], [f"trace_fail: {e}"]
|
||
|
||
try:
|
||
actor = editor_subsystem.spawn_actor_from_object(mesh, unreal.Vector(0, 0, 0))
|
||
except Exception as e:
|
||
return [], [f"trace_fail: spawn {e}"]
|
||
|
||
if not actor:
|
||
return [], ["trace_fail: spawn None"]
|
||
|
||
# Set collision to BlockAll so traces can hit this mesh
|
||
smc = actor.get_component_by_class(unreal.StaticMeshComponent)
|
||
if smc:
|
||
try:
|
||
smc.set_collision_enabled(unreal.CollisionEnabled.QUERY_AND_PHYSICS)
|
||
smc.set_collision_profile_name("BlockAll")
|
||
except Exception as e:
|
||
debug.append(f"collision_set_warn: {e}")
|
||
|
||
try:
|
||
hit_points = _perform_line_traces(actor, bmin, bmax)
|
||
debug.append(f"trace_hits: {len(hit_points)}")
|
||
|
||
if len(hit_points) >= 4:
|
||
detail_boxes = _cluster_hit_points(hit_points, bmin, bmax)
|
||
if detail_boxes:
|
||
debug.append(f"trace_boxes: {len(detail_boxes)}")
|
||
finally:
|
||
try:
|
||
unreal.EditorLevelLibrary.destroy_actor(actor)
|
||
except Exception:
|
||
pass
|
||
|
||
return detail_boxes, debug
|
||
|
||
|
||
def _perform_line_traces(actor, bmin: tuple, bmax: tuple) -> list:
|
||
"""多方向 Line Trace 收集命中点。"""
|
||
import unreal
|
||
|
||
hit_points = []
|
||
mn_x, mn_y, mn_z = bmin
|
||
mx_x, mx_y, mx_z = bmax
|
||
sx, sy, sz = mx_x - mn_x, mx_y - mn_y, mx_z - mn_z
|
||
|
||
step = min(50, max(10, min(sx, sy) / 5))
|
||
margin = max(20, max(sx, sy, sz) * 0.1)
|
||
z_step = min(50, max(15, sz / 4))
|
||
|
||
trace_ch = _TRACE_CHANNEL
|
||
draw_dbg = _DRAW_DEBUG
|
||
|
||
def _trace(start_v, end_v):
|
||
try:
|
||
hit = unreal.SystemLibrary.line_trace_single(
|
||
actor, start_v, end_v,
|
||
trace_ch, False, [], draw_dbg,
|
||
)
|
||
if hit and hasattr(hit, 'impact_point'):
|
||
pt = hit.impact_point
|
||
hit_points.append((pt.x, pt.y, pt.z))
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
# Top-down (Z axis)
|
||
z = mn_z
|
||
while z <= mx_z + 0.1:
|
||
x = mn_x + step / 2
|
||
while x <= mx_x + 0.1:
|
||
y = mn_y + step / 2
|
||
while y <= mx_y + 0.1:
|
||
_trace(unreal.Vector(x, y, mx_z + margin),
|
||
unreal.Vector(x, y, mn_z - margin))
|
||
y += step
|
||
x += step
|
||
z += z_step
|
||
|
||
# Front (Y+)
|
||
x = mn_x + step / 2
|
||
while x <= mx_x + 0.1:
|
||
z = mn_z + step / 2
|
||
while z <= mx_z + 0.1:
|
||
_trace(unreal.Vector(x, mx_y + margin, z),
|
||
unreal.Vector(x, mn_y - margin, z))
|
||
z += step
|
||
x += step
|
||
|
||
# Right (X+)
|
||
y = mn_y + step / 2
|
||
while y <= mx_y + 0.1:
|
||
z = mn_z + step / 2
|
||
while z <= mx_z + 0.1:
|
||
_trace(unreal.Vector(mx_x + margin, y, z),
|
||
unreal.Vector(mn_x - margin, y, z))
|
||
z += step
|
||
y += step
|
||
except Exception:
|
||
pass
|
||
|
||
return hit_points
|
||
|
||
|
||
def _cluster_hit_points(hit_points: list, bmin: tuple, bmax: tuple) -> list:
|
||
"""将命中点按 Z 轴聚类为细节包围盒。"""
|
||
if not hit_points:
|
||
return []
|
||
|
||
sorted_pts = sorted(hit_points, key=lambda p: p[2])
|
||
z_slices = []
|
||
current_slice = [sorted_pts[0]]
|
||
z_gap = 10.0
|
||
|
||
for i in range(1, len(sorted_pts)):
|
||
if sorted_pts[i][2] - current_slice[-1][2] > z_gap:
|
||
z_slices.append(current_slice)
|
||
current_slice = [sorted_pts[i]]
|
||
else:
|
||
current_slice.append(sorted_pts[i])
|
||
z_slices.append(current_slice)
|
||
|
||
boxes = []
|
||
for idx, pts in enumerate(z_slices):
|
||
if len(pts) < 3:
|
||
continue
|
||
xs = [p[0] for p in pts]
|
||
ys = [p[1] for p in pts]
|
||
zs = [p[2] for p in pts]
|
||
bmn = [min(xs), min(ys), min(zs)]
|
||
bmx = [max(xs), max(ys), max(zs)]
|
||
bsz = [bmx[0]-bmn[0], bmx[1]-bmn[1], bmx[2]-bmn[2]]
|
||
if bsz[0] < 2 and bsz[1] < 2:
|
||
continue
|
||
boxes.append({
|
||
"label": f"part_{idx}",
|
||
"min": _round3(bmn), "max": _round3(bmx), "size": _round3(bsz),
|
||
"source": "trace", "hit_count": len(pts),
|
||
})
|
||
|
||
if len(boxes) > 8:
|
||
boxes.sort(key=lambda b: b["size"][0]*b["size"][1]*b["size"][2], reverse=True)
|
||
boxes = boxes[:8]
|
||
return boxes
|
||
|
||
|
||
# ============================================================
|
||
# 主流程
|
||
# ============================================================
|
||
|
||
def run(index_data: dict = None) -> dict:
|
||
"""执行模型分析。
|
||
|
||
Args:
|
||
index_data: 已加载的 index.json 数据,若为 None 则从文件读取
|
||
|
||
Returns:
|
||
统计信息 dict
|
||
"""
|
||
if not HAS_UNREAL:
|
||
print(f"{TAG} 错误: unreal 模块不可用")
|
||
return {"status": "error", "reason": "no unreal"}
|
||
|
||
output_dir = get_output_dir()
|
||
|
||
# 加载 index.json
|
||
if index_data is None:
|
||
index_path = output_dir / "index.json"
|
||
if not index_path.exists():
|
||
print(f"{TAG} 错误: index.json 不存在,请先运行 discover")
|
||
return {"status": "error", "reason": "no index.json"}
|
||
index_data = read_json(index_path)
|
||
|
||
meshes_by_subfolder = index_data.get("meshes", {})
|
||
if not meshes_by_subfolder:
|
||
print(f"{TAG} 无 StaticMesh 资产需要分析")
|
||
return {"status": "ok", "analyzed": 0}
|
||
|
||
# 创建输出目录
|
||
meshes_dir = output_dir / "meshes"
|
||
meshes_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
total_all = sum(len(assets) for assets in meshes_by_subfolder.values())
|
||
print(f"{TAG} 共 {total_all} 个 StaticMesh,分布在 {len(meshes_by_subfolder)} 个子目录")
|
||
|
||
analyzed = 0
|
||
failed = 0
|
||
global_idx = 0
|
||
|
||
for subfolder, assets in meshes_by_subfolder.items():
|
||
safe_name = safe_subfolder_name(subfolder)
|
||
output_file = meshes_dir / f"{safe_name}.json"
|
||
|
||
# 断点续跑: 跳过已完成的文件
|
||
if output_file.exists():
|
||
try:
|
||
existing = read_json(output_file)
|
||
if existing.get("completed"):
|
||
print(f"{TAG} 跳过已完成的: {safe_name}")
|
||
analyzed += existing.get("count", len(assets))
|
||
global_idx += len(assets)
|
||
continue
|
||
except Exception:
|
||
pass
|
||
|
||
print(f"{TAG} 分析子目录: {subfolder} ({len(assets)} 个模型)")
|
||
mesh_results = {}
|
||
subfolder_analyzed = 0
|
||
subfolder_failed = 0
|
||
|
||
for i, asset_info in enumerate(assets):
|
||
progress_log(i, len(assets), f"{asset_info['name']}")
|
||
|
||
result = _analyze_single_mesh(asset_info, subfolder)
|
||
mesh_results[asset_info["name"]] = result
|
||
|
||
if result.get("bounding_box") is not None:
|
||
subfolder_analyzed += 1
|
||
else:
|
||
subfolder_failed += 1
|
||
|
||
global_idx += 1
|
||
|
||
# 写入分组文件
|
||
group_data = {
|
||
"subfolder": subfolder,
|
||
"source_path": f"{get_scan_root()}/Meshes/{subfolder}" if subfolder else f"{get_scan_root()}/Meshes",
|
||
"count": len(assets),
|
||
"completed": True,
|
||
"meshes": mesh_results,
|
||
}
|
||
write_json(group_data, output_file)
|
||
|
||
analyzed += subfolder_analyzed
|
||
failed += subfolder_failed
|
||
print(f"{TAG} ✅ {safe_name}: {subfolder_analyzed} 成功, {subfolder_failed} 失败 → {output_file}")
|
||
|
||
print(f"{TAG} 全部完成: {analyzed} 成功, {failed} 失败 / {total_all} 总计")
|
||
|
||
return {
|
||
"status": "ok",
|
||
"total": total_all,
|
||
"analyzed": analyzed,
|
||
"failed": failed,
|
||
}
|
||
|
||
|
||
# ---- 入口 ----
|
||
if __name__ == "__main__":
|
||
if not wait_for_editor():
|
||
sys.exit(1)
|
||
result = run()
|
||
if result.get("status") != "ok":
|
||
sys.exit(1)
|