Files
Nexus/plug-ins/MetaHumanForMaya/plugin/MetaHumanForMaya.py
2025-12-05 08:08:44 +08:00

828 lines
24 KiB
Python

# Copyright Epic Games, Inc. All Rights Reserved.
import os
import re
import json
import time
import logging
import platform
import importlib
import subprocess
import webbrowser
import http.client
from pathlib import Path
from datetime import datetime, timedelta
from urllib.parse import urlparse
import maya.OpenMayaUI as omui
import maya.api.OpenMaya as om
from maya import cmds
try:
import shiboken6 as shiboken
from PySide6 import QtGui, QtCore, QtWidgets
except ImportError:
try:
import shiboken2 as shiboken
from PySide2 import QtGui, QtCore, QtWidgets
except ImportError:
raise ImportError("No compatible Qt bindings found (PySide6 or PySide2)")
DOCUMENTATION_URL = "https://dev.epicgames.com/documentation/en-us/metahuman/metahuman-for-maya"
EULA_URL = "https://www.fab.com/eula"
DOWNLOAD_URL = "https://www.fab.com/listings/9e3bf55e-d4c3-44fc-a3d4-ec4cb772ec29"
LATEST_VERSION_URL = "https://metahuman-versioning.ucs.on.epicgames.com/api/v1/mayaversions"
LIBS = {
"PyDNA": "dna",
"PyDNACalib2": "dnacalib2",
"PyRDF": "rdf",
}
PLUGINS = {
"cbsnode_maya": "cbsNode",
"DNACalibMayaPlugin": "DNACalibMayaPlugin",
"riglogic4_maya": "embeddedRL4",
"MayaUERBFPlugin": "MayaUERBFPlugin",
"PreviewRigLogic": "PreviewRigLogic",
"SwingTwistEvaluatorPlugin": "SwingTwistEvaluatorPlugin",
}
CHECK_INTERVAL_DAYS = 1
WINDOW_TITLE = "MetaHuman for Maya"
def maya_useNewAPI():
"""
The presence of this function tells Maya that the plugin produces, and
expects to be passed, objects created using the Maya Python API 2.0.
"""
pass
def is_batch_mode():
if not hasattr(cmds, "about"):
return False
else:
return cmds.about(batch=True)
def current_dir():
return Path(os.path.dirname(current_dir.__code__.co_filename))
def get_version():
version_file = os.path.abspath(os.path.join(current_dir(), "version.txt"))
with open(version_file, encoding="utf-8") as f:
version = f.read().replace("\n", "").replace("\r", "").replace("\t", "").strip()
return version
MENU_NAME = "MetaHuman"
VERSION = get_version()
QSETTINGS = QtCore.QSettings(
QtCore.QSettings.Format.IniFormat,
QtCore.QSettings.Scope.UserScope,
"Epic Games",
"MetaHumanForMaya/MetaHumanForMaya",
)
SETTINGS_PATH = Path(QSETTINGS.fileName()).parent.as_posix()
# --------------------------------------------------------------------------------------------------
logger = logging.getLogger("MetaHumanForMaya")
log_level_name = os.getenv("MH4M_LOGLEVEL", "INFO").upper()
log_level = getattr(logging, log_level_name, logging.INFO)
logger.setLevel(log_level)
# --------------------------------------------------------------------------------------------------
def get_maya_main_window():
"""Get the main Maya window as a QtWidgets.QMainWindow instance."""
ptr = omui.MQtUtil.mainWindow()
if ptr is not None:
return shiboken.wrapInstance(int(ptr), QtWidgets.QMainWindow)
# --------------------------------------------------------------------------------------------------
def load_plugin_info():
json_path = current_dir() / "plugin_info.json"
with json_path.open("r", encoding="utf-8") as f:
return json.load(f)
def get_package_version(package_name):
data = load_plugin_info()
for entry in data.get("Packages", []):
if entry.startswith(f"{package_name}-"):
return entry.split("-", maxsplit=1)[1]
return None
def _apply_used_resolve(packages):
rez_env = os.getenv("REZ_USED_RESOLVE")
if not rez_env:
return packages
used_resolve = {}
for entry in rez_env.split(" "):
if "-" not in entry or not entry:
continue
name = entry.split("-", 1)[0]
used_resolve[name] = entry
merged = []
for pkg in packages:
if "-" not in pkg:
merged.append(pkg)
continue
name = pkg.split("-", 1)[0]
merged.append(used_resolve.get(name, pkg))
return merged
def _deduplicate_packages(packages):
seen = set()
unique = []
for pkg in packages:
if pkg not in seen:
unique.append(pkg)
seen.add(pkg)
return unique
def _filter_packages(packages):
filtered_prefixes = ("maya", "python", "platform", "os", "arch")
result = []
for pkg in packages:
name = pkg.split("-", 1)[0]
if name in filtered_prefixes:
continue
result.append(pkg)
return result
def format_info_text(info):
lines = [
f"Name: {info.get('Name')}",
f"Version: {get_version()}",
f"System: {platform.system()}",
f"Host: {cmds.about(installedVersion=True)}",
f"Settings: {SETTINGS_PATH}",
]
packages = info.get("Packages", [])
packages = _apply_used_resolve(packages)
packages = _deduplicate_packages(packages)
packages = _filter_packages(packages)
lines.append("Packages:")
for package in packages:
lines.append(f"{package}")
return "\n".join(lines)
class AboutDialog(QtWidgets.QDialog):
def __init__(self, plugin_info, parent=None):
super().__init__(parent)
self.setWindowTitle(WINDOW_TITLE)
self.setMinimumSize(400, 400)
self.info_text = format_info_text(plugin_info)
main_layout = QtWidgets.QVBoxLayout(self)
self.text_edit = QtWidgets.QPlainTextEdit()
self.text_edit.setReadOnly(True)
self.text_edit.setPlainText(self.info_text)
self.text_edit.setStyleSheet(
"""
QPlainTextEdit {
font-family: Consolas, monospace;
font-size: 10pt;
}
"""
)
button_row = QtWidgets.QHBoxLayout()
button_row.setContentsMargins(0, 0, 0, 0)
copy_button = QtWidgets.QPushButton("Copy to Clipboard")
copy_button.clicked.connect(self.copy_to_clipboard)
ok_button = QtWidgets.QPushButton("OK")
ok_button.clicked.connect(self.accept)
button_row.addWidget(copy_button, 0, QtCore.Qt.AlignmentFlag.AlignLeft)
button_row.addStretch(1)
button_row.addWidget(ok_button, 0, QtCore.Qt.AlignmentFlag.AlignRight)
main_layout.addWidget(self.text_edit)
main_layout.addLayout(button_row)
self.resize(400, 600)
def copy_to_clipboard(self):
QtGui.QGuiApplication.clipboard().setText(self.info_text)
# --------------------------------------------------------------------------------------------------
def _safe_ssl_context():
try:
import ssl # imported here on purpose
except Exception as e:
logger.error("ssl module import failed: %s", e)
return None
def _pick_ca_locations():
# env overrides
env_file = os.environ.get("SSL_CERT_FILE")
if env_file and os.path.isfile(env_file):
return env_file, None
env_dir = os.environ.get("SSL_CERT_DIR")
if env_dir and os.path.isdir(env_dir):
return None, env_dir
# defaults from this openssl build
try:
dvp = ssl.get_default_verify_paths()
if getattr(dvp, "cafile", None) and os.path.isfile(dvp.cafile):
return dvp.cafile, None
if getattr(dvp, "capath", None) and os.path.isdir(dvp.capath):
return None, dvp.capath
except Exception:
pass
# common unix locations
if os.name == "posix":
common = [
"/etc/ssl/certs/ca-certificates.crt", # debian, ubuntu
"/etc/pki/tls/certs/ca-bundle.crt", # rhel, centos, fedora
"/etc/ssl/ca-bundle.pem", # suse
"/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem",
"/etc/ssl/cert.pem", # macos
"/usr/local/etc/openssl@1.1/cert.pem", # homebrew old
"/usr/local/etc/openssl/cert.pem", # homebrew current
]
for p in common:
if os.path.isfile(p):
return p, None
return None, None
try:
ctx = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH)
ctx.check_hostname = True
ctx.verify_mode = ssl.CERT_REQUIRED
if hasattr(ssl, "TLSVersion"):
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
cafile, capath = _pick_ca_locations()
if cafile or capath:
ctx.load_verify_locations(cafile=cafile, capath=capath)
else:
logger.debug("No system CA bundle: relying on OpenSSL defaults")
return ctx
except Exception as e:
logger.error("failed building SSL context: %s", e)
return None
def https_get(url, timeout=10):
try:
parsed = urlparse(url)
if parsed.scheme != "https":
logger.error(f"Only HTTPS URLs are supported, got: {parsed.scheme}")
return None, None
path = parsed.path or "/"
if parsed.query:
path = f"{path}?{parsed.query}"
ctx = _safe_ssl_context()
if ctx is None:
logger.error("cannot create SSL context, HTTPS is unavailable in this runtime")
return None, None
conn = http.client.HTTPSConnection(parsed.hostname, parsed.port or 443, timeout=timeout, context=ctx)
try:
conn.request("GET", path, headers={"User-Agent": "MetaHumanForMaya"})
resp = conn.getresponse()
body = resp.read()
body_str = body.decode("utf-8", errors="replace")
try:
return resp.status, json.loads(body_str)
except json.JSONDecodeError:
return resp.status, body_str
finally:
conn.close()
except (http.client.HTTPException, OSError, TimeoutError) as e:
logger.error(f"Network error fetching {url}: {e}")
return None, None
except Exception as e:
logger.error(f"Unexpected error fetching {url}: {e}")
return None, None
# --------------------------------------------------------------------------------------------------
def get_latest_version():
try:
status, content = https_get(LATEST_VERSION_URL, timeout=15)
# Check if request failed
if status is None or content is None:
logger.error("Failed to fetch latest version (network error)")
return None
if status != 200:
logger.error(f"Error checking for updates, request returned: {status}")
return None
# Ensure content is a dict
if not isinstance(content, dict):
logger.error(f"Unexpected response type: {type(content)}")
return None
# Get platform-specific data
system = platform.system()
data = content.get(system, {})
if not data:
logger.warning(f"No version data available for platform: {system}")
return None
version = data.get("version")
if not version:
logger.warning(f"Version field missing in response for platform: {system}")
return None
return version
except Exception as e:
logger.error(f"Unexpected error getting latest version: {e}")
return None
def is_update_available():
try:
if is_batch_mode():
return (False, VERSION)
latest = get_latest_version()
if latest is None:
return (None, None)
logger.debug(f"Latest version: {latest}, current version: {VERSION}")
try:
current_is_smaller = compare_semver_versions(VERSION, latest) == -1
except (ValueError, AttributeError) as e:
logger.error(f"Error comparing versions '{VERSION}' vs '{latest}': {e}")
return (None, None)
if current_is_smaller:
return (True, latest)
return (False, VERSION)
except Exception as e:
logger.error(f"Unexpected error checking for updates: {e}")
return (None, None)
class UpdatePrompt(QtWidgets.QDialog):
def __init__(self, message, update_available=False, parent=None):
super().__init__(parent)
self.setWindowTitle(WINDOW_TITLE)
self.setMinimumWidth(400)
layout = QtWidgets.QVBoxLayout(self)
label = QtWidgets.QLabel(message, self)
label.setWordWrap(True)
button_box = QtWidgets.QDialogButtonBox(self)
if update_available:
label.setText(label.text() + "\n" + "Would you like to go to download page?")
button_box.setStandardButtons(QtWidgets.QDialogButtonBox.Yes | QtWidgets.QDialogButtonBox.No)
else:
button_box.setStandardButtons(QtWidgets.QDialogButtonBox.Ok)
layout.addWidget(label)
layout.addWidget(button_box)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
# --------------------------------------------------------------------------------------------------
class WarningDialog(QtWidgets.QDialog):
def __init__(self, message, parent=None):
super().__init__(parent)
self.setWindowTitle(WINDOW_TITLE + " | Warning")
self.setMinimumWidth(400)
self.message = message
main_layout = QtWidgets.QVBoxLayout(self)
content_layout = QtWidgets.QVBoxLayout()
content_layout.setSpacing(10)
icon = self.style().standardIcon(QtWidgets.QStyle.SP_MessageBoxWarning)
icon_label = QtWidgets.QLabel(self)
icon_label.setPixmap(icon.pixmap(32, 32))
icon_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
self.text_edit = QtWidgets.QPlainTextEdit(self)
self.text_edit.setReadOnly(True)
self.text_edit.setPlainText(message)
self.text_edit.setStyleSheet(
"""
QPlainTextEdit {
font-family: Consolas, monospace;
font-size: 10pt;
}
"""
)
content_layout.addWidget(icon_label)
content_layout.addWidget(self.text_edit)
button_row = QtWidgets.QHBoxLayout()
copy_btn = QtWidgets.QPushButton("Copy to Clipboard")
copy_btn.clicked.connect(self.copy_to_clipboard)
ignore_btn = QtWidgets.QPushButton("Ignore and continue")
ignore_btn.clicked.connect(self.accept)
cancel_btn = QtWidgets.QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
button_row.addWidget(copy_btn, 0, QtCore.Qt.AlignmentFlag.AlignLeft)
button_row.addStretch(1)
button_row.addWidget(ignore_btn, 0, QtCore.Qt.AlignmentFlag.AlignRight)
button_row.addWidget(cancel_btn, 0, QtCore.Qt.AlignmentFlag.AlignRight)
main_layout.addLayout(content_layout)
main_layout.addLayout(button_row)
def copy_to_clipboard(self):
QtGui.QGuiApplication.clipboard().setText(self.message)
# --------------------------------------------------------------------------------------------------
def should_check_for_updates():
if is_batch_mode():
return False
disable_updates = QSETTINGS.value("disable_updates", type=int)
if disable_updates == 1:
return False
timestamp = QSETTINGS.value("last_update_check", type=int)
if timestamp:
last_check = datetime.fromtimestamp(timestamp)
if datetime.now() - last_check < timedelta(days=CHECK_INTERVAL_DAYS):
return False
return True
def parse_semver_version(version_string):
match = re.search(r"(?:v)?(\d+)(?:\.(\d+))?(?:\.(\d+))?$", version_string)
if not match:
raise ValueError(f"Could not extract version from string: '{version_string}'")
major = match.group(1)
minor = match.group(2)
patch = match.group(3)
return f"{major}.{minor}.{patch}"
def compare_semver_versions(v1, v2):
def normalize(v):
parts = parse_semver_version(v).split(".")
return [int(p) if p is not None and p != "None" else 0 for p in parts]
v1_parts = normalize(v1)
v2_parts = normalize(v2)
for a, b in zip(v1_parts, v2_parts):
if a < b:
return -1 # v1 < v2
elif a > b:
return 1 # v1 > v2
return 0 # equal
def check_lib_version(name, expected_version):
try:
lib = importlib.import_module(name)
except ImportError:
logger.error(f"Library '{name}' could not be imported.")
return False, "N/A"
actual_version = "0.0.0"
if hasattr(lib, "VersionInfo_getVersionString"):
actual_version = lib.VersionInfo_getVersionString()
elif hasattr(lib, "__version__"):
actual_version = lib.__version__
else:
raise AttributeError(
f"Cannot determine the major version of library '{name}'. "
"Expected a 'VersionInfo_getVersionString' method or '__version__' attribute."
)
actual_version = parse_semver_version(actual_version)
return (actual_version == expected_version), actual_version
def user_warning(message):
logger.warning(message)
if is_batch_mode():
return
QtWidgets.QApplication.restoreOverrideCursor()
dialog = WarningDialog(message, get_maya_main_window())
dialog.resize(920, 360)
result = dialog.exec()
return result == QtWidgets.QDialog.Rejected
def get_user_plugin_path():
version = cmds.about(version=True)
doc_root = os.path.join(os.environ["USERPROFILE"], "Documents", "maya")
return os.path.join(doc_root, version, "plug-ins").replace("\\", "/")
def get_maya_plugin_search_paths():
paths = set()
for path in os.environ.get("MAYA_PLUG_IN_PATH", "").split(os.pathsep):
if path:
paths.add(os.path.normpath(path).replace("\\", "/"))
paths.add(get_user_plugin_path())
return list(paths)
def dependency_checks():
issues = []
# libs
for package, lib in LIBS.items():
expected = get_package_version(package)
check, actual = check_lib_version(lib, expected)
if not check:
issues.append(
{
"kind": "lib",
"name": lib,
"expected": expected,
"actual": actual,
"detail": (
"A plugin that uses an incompatible version of the specified "
"library is already loaded. If you've used a related external tool, "
"the issue may be caused by a plugin associated with it."
),
}
)
# plugins
for package, plugin in PLUGINS.items():
expected = get_package_version(package)
if not cmds.pluginInfo(plugin, query=True, loaded=True):
cmds.loadPlugin(plugin, quiet=True)
actual = cmds.pluginInfo(plugin, query=True, version=True)
if actual != expected:
filepath = cmds.pluginInfo(plugin, query=True, path=True)
issues.append(
{
"kind": "plugin",
"name": plugin,
"expected": expected,
"actual": actual,
"detail": (
"Specified plugin is already loaded and incompatible with the "
"current application. Remove the plugin and try again."
),
"path": filepath,
}
)
# nothing to report
if not issues:
return
# format a single combined warning dialog
lines = []
lines.append("Dependency mismatch summary\n")
for i, item in enumerate(issues, 1):
lines.append(f"{i}. {item['kind']}: {item['name']}")
lines.append(f" expected: {item['expected']}")
lines.append(f" found: {item['actual']}")
if "path" in item and item["path"]:
lines.append(f" path: {item['path']}")
lines.append(f" note: {item['detail']}\n")
message = "\n".join(lines)
rejected = user_warning(message)
if rejected:
logger.error("dependency checks failed")
raise Exception("Incompatible or incomplete environment, please refer to full error log for more info.")
logger.warning("dependency mismatches present; continuing on user request")
def perform_check_for_updates(shy_dialog=False):
update_available, ver = is_update_available()
QSETTINGS.setValue("last_update_check", int(time.time()))
message = ""
if update_available is None:
message = (
"Error: Could not fetch version data from server. Please check if theres an update using Epic Launcher."
)
elif isinstance(update_available, bool):
if update_available:
message = f"A new version of {WINDOW_TITLE} is available: v{ver}"
else:
message = f"You have the latest version of {WINDOW_TITLE}: v{ver}"
logger.info(message)
if is_batch_mode():
return message
if shy_dialog and not update_available:
return message
dialog = UpdatePrompt(message, update_available, get_maya_main_window())
result = dialog.exec()
if update_available and result == QtWidgets.QDialog.Accepted:
webbrowser.open(DOWNLOAD_URL)
return message
def updates_check():
try:
logger.info("Checking for updates...")
if should_check_for_updates():
perform_check_for_updates(shy_dialog=True)
else:
logger.info("Check skipped.")
except Exception as e:
logger.error(f"Error during updates check: {e}")
# --------------------------------------------------------------------------------------------------
def command_open_EULA(*args):
webbrowser.open(EULA_URL)
def command_open_licenses_folder(*args):
licenses_dir = current_dir().parent / "licenses"
system = platform.system()
if system == "Windows":
os.startfile(licenses_dir)
elif system == "Darwin": # macOS
subprocess.run(["open", str(licenses_dir)])
else: # Linux and other UNIX-like
subprocess.run(["xdg-open", str(licenses_dir)])
def command_check_for_updates(*args):
perform_check_for_updates(shy_dialog=False)
def command_open_documentation(*args):
webbrowser.open(DOCUMENTATION_URL)
def command_show_about(*args):
plugin_info = load_plugin_info()
logger.info(plugin_info)
if is_batch_mode():
return
dialog = AboutDialog(plugin_info, get_maya_main_window())
dialog.exec()
# --------------------------------------------------------------------------------------------------
def install_menu():
# check if the menu already exists; if so, delete it
uninstall_menu()
# create a new menu in the Maya main menu bar
menu = cmds.menu(MENU_NAME, label=MENU_NAME, to=True, parent="MayaWindow")
cmds.menuItem(
parent=menu,
label="Character Assembler",
command="import mh_character_assembler;mh_character_assembler.show()",
)
cmds.menuItem(
parent=menu,
label="Expression Editor",
command="import mh_expression_editor;mh_expression_editor.show()",
)
cmds.menuItem(
parent=menu,
label="Pose Editor",
command="import mh_pose_editor;mh_pose_editor.show()",
)
cmds.menuItem(
parent=menu,
label="Groom Exporter",
command="import mh_groom_exporter;mh_groom_exporter.show()",
)
cmds.menuItem(
parent=menu,
divider=True,
)
cmds.menuItem(
parent=menu,
label="View EULA",
command=command_open_EULA,
)
cmds.menuItem(
parent=menu,
label="Third party notices",
command=command_open_licenses_folder,
)
cmds.menuItem(
parent=menu,
label="Check for updates",
command=command_check_for_updates,
)
cmds.menuItem(
parent=menu,
label="Documentation",
command=command_open_documentation,
)
cmds.menuItem(
parent=menu,
label="About",
command=command_show_about,
)
cmds.menuItem(
parent=menu,
divider=True,
)
cmds.menuItem(
parent=menu,
label=VERSION,
enable=False,
)
def uninstall_menu():
if cmds.menu(MENU_NAME, exists=True):
cmds.deleteUI(MENU_NAME, menu=True)
# --------------------------------------------------------------------------------------------------
# initialize the plug-in
def initializePlugin(plugin):
om.MFnPlugin(plugin, "MetaHumanForMaya", VERSION, "Any")
# check dependencies
dependency_checks()
# check for updates
updates_check()
# add menu items to the custom menu
install_menu()
# uninitialize the plug-in
def uninitializePlugin(plugin):
om.MFnPlugin(plugin)
uninstall_menu()