Merge of the two project copies into one self-contained plugin (the superset: variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring). Made fully project- and machine-agnostic — no hardcoded paths: - New src/projectPaths.js auto-detects the host .uproject (walk-up), project name, Editor build target, log file, and engine install (EngineAssociation via launcher manifest/registry, else installed-engine scan). All overridable via UE_* env vars. - Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver. - Voxel made optional: Build.cs auto-detects the Voxel plugin (env UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL, so the module builds in projects without Voxel; .uplugin marks Voxel optional. - De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded node_modules/Binaries/Intermediate/__pycache__/secrets from the repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
387 lines
14 KiB
Python
387 lines
14 KiB
Python
"""Animation ops — AnimBlueprint, AnimSequence, AnimMontage, Notifies, Skeleton sockets.
|
|
|
|
Ops:
|
|
create_anim_blueprint {path, skeleton}
|
|
create_anim_montage {path, sequence?}
|
|
create_blend_space {path, skeleton, dim?:1|2}
|
|
get_sequence_info {path} — length, fps, frames, additive type, skeleton
|
|
list_anim_assets {folder?, skeleton?}
|
|
list_sockets {skeleton}
|
|
add_socket {skeleton, name, bone, location?, rotation?}
|
|
remove_socket {skeleton, name}
|
|
list_montage_sections {path}
|
|
add_montage_section {path, name, start_time?}
|
|
list_notifies {sequence}
|
|
add_notify {sequence, name, time, notify_class?}
|
|
remove_notify {sequence, index}
|
|
list_state_machines {anim_bp} — best-effort (graph names)
|
|
retarget_animation {src, dst_skeleton, dst_path}
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import traceback
|
|
|
|
try:
|
|
import unreal # type: ignore
|
|
except ImportError:
|
|
unreal = None
|
|
|
|
|
|
def _at():
|
|
return unreal.AssetToolsHelpers.get_asset_tools()
|
|
|
|
|
|
def _load(path: str):
|
|
return unreal.EditorAssetLibrary.load_asset(path)
|
|
|
|
|
|
def _split_pkg(path: str):
|
|
folder, name = path.rsplit("/", 1)
|
|
return folder, name
|
|
|
|
|
|
def _vec(v, default=(0, 0, 0)):
|
|
if v is None:
|
|
v = default
|
|
return unreal.Vector(float(v[0]), float(v[1]), float(v[2]))
|
|
|
|
|
|
def _rot(v, default=(0, 0, 0)):
|
|
if v is None:
|
|
v = default
|
|
return unreal.Rotator(float(v[0]), float(v[1]), float(v[2]))
|
|
|
|
|
|
def create_anim_blueprint(args: dict) -> dict:
|
|
skel = _load(args["skeleton"])
|
|
if skel is None:
|
|
return {"ok": False, "error": f"skeleton not found: {args['skeleton']}"}
|
|
folder, name = _split_pkg(args["path"])
|
|
factory = unreal.AnimBlueprintFactory()
|
|
factory.set_editor_property("target_skeleton", skel)
|
|
asset = _at().create_asset(name, folder, unreal.AnimBlueprint, factory)
|
|
if asset is None:
|
|
return {"ok": False, "error": "create_asset returned None"}
|
|
unreal.EditorAssetLibrary.save_asset(asset.get_path_name())
|
|
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
|
|
|
|
|
def create_anim_montage(args: dict) -> dict:
|
|
folder, name = _split_pkg(args["path"])
|
|
factory = unreal.AnimMontageFactory()
|
|
seq_path = args.get("sequence")
|
|
if seq_path:
|
|
seq = _load(seq_path)
|
|
if seq is None:
|
|
return {"ok": False, "error": f"sequence not found: {seq_path}"}
|
|
if hasattr(factory, "set_editor_property"):
|
|
try:
|
|
factory.set_editor_property("preview_skeletal_mesh", seq.get_editor_property("skeleton"))
|
|
except Exception:
|
|
pass
|
|
asset = _at().create_asset(name, folder, unreal.AnimMontage, factory)
|
|
if asset is None:
|
|
return {"ok": False, "error": "create_asset returned None"}
|
|
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
|
|
|
|
|
def create_blend_space(args: dict) -> dict:
|
|
skel = _load(args["skeleton"])
|
|
if skel is None:
|
|
return {"ok": False, "error": "skeleton not found"}
|
|
folder, name = _split_pkg(args["path"])
|
|
dim = int(args.get("dim") or 2)
|
|
factory_cls = getattr(unreal, "BlendSpaceFactory1D" if dim == 1 else "BlendSpaceFactoryNew", None)
|
|
if factory_cls is None:
|
|
return {"ok": False, "error": "BlendSpace factory unavailable in this UE build"}
|
|
factory = factory_cls()
|
|
if hasattr(factory, "target_skeleton"):
|
|
factory.target_skeleton = skel
|
|
asset_cls = unreal.BlendSpace1D if dim == 1 else unreal.BlendSpace
|
|
asset = _at().create_asset(name, folder, asset_cls, factory)
|
|
if asset is None:
|
|
return {"ok": False, "error": "create_asset returned None"}
|
|
return {"ok": True, "path": asset.get_path_name().split(".")[0]}
|
|
|
|
|
|
def get_sequence_info(args: dict) -> dict:
|
|
seq = _load(args["path"])
|
|
if seq is None:
|
|
return {"ok": False, "error": "not found"}
|
|
out = {"ok": True, "path": args["path"], "class": seq.get_class().get_name()}
|
|
for attr in ("get_play_length", "sequence_length"):
|
|
try:
|
|
v = getattr(seq, attr)
|
|
out["length"] = v() if callable(v) else float(v)
|
|
break
|
|
except Exception:
|
|
pass
|
|
try:
|
|
out["num_frames"] = int(seq.get_editor_property("number_of_sampled_keys") or 0)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
out["frame_rate"] = float(seq.get_editor_property("target_frame_rate") or 0)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
skel = seq.get_editor_property("skeleton")
|
|
if skel:
|
|
out["skeleton"] = skel.get_path_name().split(".")[0]
|
|
except Exception:
|
|
pass
|
|
try:
|
|
out["additive_type"] = str(seq.get_editor_property("additive_anim_type"))
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
def list_anim_assets(args: dict) -> dict:
|
|
folder = args.get("folder") or "/Game"
|
|
skel_filter = args.get("skeleton")
|
|
ar = unreal.AssetRegistryHelpers.get_asset_registry()
|
|
filt = unreal.ARFilter(package_paths=[folder], recursive_paths=True,
|
|
class_names=["AnimSequence", "AnimMontage", "BlendSpace", "BlendSpace1D", "AnimBlueprint"])
|
|
items = []
|
|
for d in ar.get_assets(filt):
|
|
path = str(d.package_name)
|
|
if skel_filter:
|
|
try:
|
|
asset = _load(path)
|
|
sk = asset.get_editor_property("skeleton") if asset else None
|
|
if not sk or sk.get_path_name().split(".")[0] != skel_filter:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
items.append({"path": path, "class": str(d.asset_class)})
|
|
return {"ok": True, "count": len(items), "items": items}
|
|
|
|
|
|
def list_sockets(args: dict) -> dict:
|
|
skel = _load(args["skeleton"])
|
|
if skel is None:
|
|
return {"ok": False, "error": "skeleton not found"}
|
|
try:
|
|
sockets = skel.get_editor_property("sockets") or []
|
|
except Exception:
|
|
sockets = []
|
|
out = []
|
|
for s in sockets:
|
|
try:
|
|
out.append({
|
|
"name": str(s.get_editor_property("socket_name")),
|
|
"bone": str(s.get_editor_property("bone_name")),
|
|
"location": list(s.get_editor_property("relative_location")),
|
|
"rotation": list(s.get_editor_property("relative_rotation")),
|
|
})
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "count": len(out), "sockets": out}
|
|
|
|
|
|
def add_socket(args: dict) -> dict:
|
|
skel = _load(args["skeleton"])
|
|
if skel is None:
|
|
return {"ok": False, "error": "skeleton not found"}
|
|
sock = unreal.SkeletalMeshSocket()
|
|
sock.set_editor_property("socket_name", args["name"])
|
|
sock.set_editor_property("bone_name", args["bone"])
|
|
sock.set_editor_property("relative_location", _vec(args.get("location")))
|
|
sock.set_editor_property("relative_rotation", _rot(args.get("rotation")))
|
|
sockets = list(skel.get_editor_property("sockets") or [])
|
|
sockets.append(sock)
|
|
skel.set_editor_property("sockets", sockets)
|
|
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
|
|
return {"ok": True, "name": args["name"]}
|
|
|
|
|
|
def remove_socket(args: dict) -> dict:
|
|
skel = _load(args["skeleton"])
|
|
if skel is None:
|
|
return {"ok": False, "error": "skeleton not found"}
|
|
target = args["name"]
|
|
sockets = [s for s in (skel.get_editor_property("sockets") or [])
|
|
if str(s.get_editor_property("socket_name")) != target]
|
|
skel.set_editor_property("sockets", sockets)
|
|
unreal.EditorAssetLibrary.save_asset(skel.get_path_name())
|
|
return {"ok": True, "removed": target}
|
|
|
|
|
|
def list_montage_sections(args: dict) -> dict:
|
|
m = _load(args["path"])
|
|
if m is None:
|
|
return {"ok": False, "error": "not found"}
|
|
try:
|
|
secs = m.get_editor_property("composite_sections") or []
|
|
except Exception:
|
|
secs = []
|
|
out = []
|
|
for s in secs:
|
|
try:
|
|
out.append({"name": str(s.get_editor_property("section_name")),
|
|
"start_time": float(s.get_editor_property("segment_begin_time") or 0)})
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "sections": out}
|
|
|
|
|
|
def add_montage_section(args: dict) -> dict:
|
|
m = _load(args["path"])
|
|
if m is None:
|
|
return {"ok": False, "error": "not found"}
|
|
sec = unreal.CompositeSection() if hasattr(unreal, "CompositeSection") else None
|
|
if sec is None:
|
|
return {"ok": False, "error": "CompositeSection not exposed in this UE build"}
|
|
sec.set_editor_property("section_name", args["name"])
|
|
sec.set_editor_property("segment_begin_time", float(args.get("start_time") or 0))
|
|
secs = list(m.get_editor_property("composite_sections") or [])
|
|
secs.append(sec)
|
|
m.set_editor_property("composite_sections", secs)
|
|
unreal.EditorAssetLibrary.save_asset(m.get_path_name())
|
|
return {"ok": True}
|
|
|
|
|
|
def list_notifies(args: dict) -> dict:
|
|
seq = _load(args["sequence"])
|
|
if seq is None:
|
|
return {"ok": False, "error": "not found"}
|
|
try:
|
|
notifies = seq.get_editor_property("notifies") or []
|
|
except Exception:
|
|
notifies = []
|
|
out = []
|
|
for n in notifies:
|
|
try:
|
|
cls = n.get_editor_property("notify")
|
|
out.append({
|
|
"name": str(n.get_editor_property("notify_name")),
|
|
"time": float(n.get_editor_property("trigger_time_offset") or 0) + float(n.get_editor_property("link_value") or 0),
|
|
"class": cls.get_class().get_name() if cls else None,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "notifies": out}
|
|
|
|
|
|
def add_notify(args: dict) -> dict:
|
|
seq = _load(args["sequence"])
|
|
if seq is None:
|
|
return {"ok": False, "error": "not found"}
|
|
ev = unreal.AnimNotifyEvent() if hasattr(unreal, "AnimNotifyEvent") else None
|
|
if ev is None:
|
|
return {"ok": False, "error": "AnimNotifyEvent not exposed; use editor UI"}
|
|
ev.set_editor_property("notify_name", args["name"])
|
|
ev.set_editor_property("link_value", float(args["time"]))
|
|
notifies = list(seq.get_editor_property("notifies") or [])
|
|
notifies.append(ev)
|
|
seq.set_editor_property("notifies", notifies)
|
|
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
|
|
return {"ok": True}
|
|
|
|
|
|
def remove_notify(args: dict) -> dict:
|
|
seq = _load(args["sequence"])
|
|
if seq is None:
|
|
return {"ok": False, "error": "not found"}
|
|
notifies = list(seq.get_editor_property("notifies") or [])
|
|
idx = int(args["index"])
|
|
if idx < 0 or idx >= len(notifies):
|
|
return {"ok": False, "error": "index out of range"}
|
|
notifies.pop(idx)
|
|
seq.set_editor_property("notifies", notifies)
|
|
unreal.EditorAssetLibrary.save_asset(seq.get_path_name())
|
|
return {"ok": True}
|
|
|
|
|
|
def list_state_machines(args: dict) -> dict:
|
|
bp = _load(args["anim_bp"])
|
|
if bp is None:
|
|
return {"ok": False, "error": "not found"}
|
|
out = []
|
|
try:
|
|
for g in bp.get_editor_property("ubergraph_pages") or []:
|
|
out.append(g.get_name())
|
|
except Exception:
|
|
pass
|
|
try:
|
|
for g in bp.get_editor_property("function_graphs") or []:
|
|
out.append(g.get_name())
|
|
except Exception:
|
|
pass
|
|
return {"ok": True, "graphs": out}
|
|
|
|
|
|
def retarget_animation(args: dict) -> dict:
|
|
src = _load(args["src"])
|
|
dst_skel = _load(args["dst_skeleton"])
|
|
if src is None or dst_skel is None:
|
|
return {"ok": False, "error": "src or dst_skeleton not found"}
|
|
folder, name = _split_pkg(args["dst_path"])
|
|
try:
|
|
new_asset = unreal.AnimationLibrary.duplicate_animation_asset(src, name, folder) \
|
|
if hasattr(unreal, "AnimationLibrary") and hasattr(unreal.AnimationLibrary, "duplicate_animation_asset") \
|
|
else unreal.EditorAssetLibrary.duplicate_asset(src.get_path_name(), folder + "/" + name)
|
|
except Exception as e:
|
|
return {"ok": False, "error": f"duplicate failed: {e}"}
|
|
try:
|
|
new_asset.set_editor_property("skeleton", dst_skel)
|
|
except Exception:
|
|
pass
|
|
unreal.EditorAssetLibrary.save_asset(new_asset.get_path_name())
|
|
return {"ok": True, "path": new_asset.get_path_name().split(".")[0]}
|
|
|
|
|
|
OPS = {
|
|
"create_anim_blueprint": create_anim_blueprint,
|
|
"create_anim_montage": create_anim_montage,
|
|
"create_blend_space": create_blend_space,
|
|
"get_sequence_info": get_sequence_info,
|
|
"list_anim_assets": list_anim_assets,
|
|
"list_sockets": list_sockets,
|
|
"add_socket": add_socket,
|
|
"remove_socket": remove_socket,
|
|
"list_montage_sections": list_montage_sections,
|
|
"add_montage_section": add_montage_section,
|
|
"list_notifies": list_notifies,
|
|
"add_notify": add_notify,
|
|
"remove_notify": remove_notify,
|
|
"list_state_machines": list_state_machines,
|
|
"retarget_animation": retarget_animation,
|
|
}
|
|
|
|
|
|
def entrypoint(payload_json: str) -> str:
|
|
try:
|
|
p = json.loads(payload_json)
|
|
except Exception as e:
|
|
return _tee({"ok": False, "error": f"bad json: {e}"}, {})
|
|
op = p.get("op")
|
|
fn = OPS.get(op)
|
|
if not fn:
|
|
return _tee({"ok": False, "error": f"unknown op {op!r}", "available": list(OPS)}, p)
|
|
try:
|
|
return _tee(fn(p), p)
|
|
except Exception as e:
|
|
return _tee({"ok": False, "error": str(e), "traceback": traceback.format_exc()}, p)
|
|
|
|
|
|
def _tee(result: dict, p: dict) -> str:
|
|
txt = json.dumps(result)
|
|
try:
|
|
if unreal is not None:
|
|
sd = unreal.Paths.project_saved_dir() + "MCP/"
|
|
os.makedirs(sd, exist_ok=True)
|
|
rid = p.get("__rid") or "last"
|
|
with open(sd + "last.json", "w", encoding="utf-8") as f:
|
|
f.write(txt)
|
|
if rid != "last":
|
|
with open(sd + f"{rid}.json", "w", encoding="utf-8") as f:
|
|
f.write(txt)
|
|
unreal.log("[MCP-ANIM] " + txt[:1500])
|
|
except Exception:
|
|
pass
|
|
return txt
|