Files
unreal-engine-mcp-system-pl…/ue_side/animation.py
Bonchellon eba71c4ca8 Unified, portable Unreal Engine MCP system plugin
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>
2026-06-24 01:07:24 +03:00

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