diff --git a/openpype/hosts/unreal/README.md b/openpype/hosts/unreal/README.md new file mode 100644 index 00000000000..0a69b9e0cff --- /dev/null +++ b/openpype/hosts/unreal/README.md @@ -0,0 +1,9 @@ +## Unreal Integration + +Supported Unreal Engine version is 4.26+ (mainly because of major Python changes done there). + +### Project naming +Unreal doesn't support project names starting with non-alphabetic character. So names like `123_myProject` are +invalid. If OpenPype detects such name it automatically prepends letter **P** to make it valid name, so `123_myProject` +will become `P123_myProject`. There is also soft-limit on project name length to be shorter than 20 characters. +Longer names will issue warning in Unreal Editor that there might be possible side effects. \ No newline at end of file diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index 7e706a2789c..7e34c3ff15d 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -1,38 +1,51 @@ +# -*- coding: utf-8 -*- +"""Unreal launching and project tools.""" import sys import os import platform import json from distutils import dir_util import subprocess +import re +from pathlib import Path +from collections import OrderedDict from openpype.api import get_project_settings -def get_engine_versions(): - """ +def get_engine_versions(env=None): + """Detect Unreal Engine versions. + This will try to detect location and versions of installed Unreal Engine. Location can be overridden by `UNREAL_ENGINE_LOCATION` environment variable. - Returns: + Args: + env (dict, optional): Environment to use. - dict: dictionary with version as a key and dir as value. + Returns: + OrderedDict: dictionary with version as a key and dir as value. + so the highest version is first. Example: - - >>> get_engine_version() + >>> get_engine_versions() { "4.23": "C:/Epic Games/UE_4.23", "4.24": "C:/Epic Games/UE_4.24" } + """ + env = env or os.environ + engine_locations = {} try: - engine_locations = {} - root, dirs, files = next(os.walk(os.environ["UNREAL_ENGINE_LOCATION"])) - - for dir in dirs: - if dir.startswith("UE_"): - ver = dir.split("_")[1] - engine_locations[ver] = os.path.join(root, dir) + root, dirs, _ = next(os.walk(env["UNREAL_ENGINE_LOCATION"])) + + for directory in dirs: + if directory.startswith("UE"): + try: + ver = re.split(r"[-_]", directory)[1] + except IndexError: + continue + engine_locations[ver] = os.path.join(root, directory) except KeyError: # environment variable not set pass @@ -40,32 +53,52 @@ def get_engine_versions(): # specified directory doesn't exists pass - # if we've got something, terminate autodetection process + # if we've got something, terminate auto-detection process if engine_locations: - return engine_locations + return OrderedDict(sorted(engine_locations.items())) # else kick in platform specific detection if platform.system().lower() == "windows": - return _win_get_engine_versions() - elif platform.system().lower() == "linux": + return OrderedDict(sorted(_win_get_engine_versions().items())) + if platform.system().lower() == "linux": # on linux, there is no installation and getting Unreal Engine involves # git clone. So we'll probably depend on `UNREAL_ENGINE_LOCATION`. pass + if platform.system().lower() == "darwin": + return OrderedDict(sorted(_darwin_get_engine_version().items())) + + return OrderedDict() + + +def get_editor_executable_path(engine_path: Path) -> Path: + """Get UE4 Editor executable path.""" + ue4_path = engine_path / "Engine/Binaries" + if platform.system().lower() == "windows": + ue4_path /= "Win64/UE4Editor.exe" + + elif platform.system().lower() == "linux": + ue4_path /= "Linux/UE4Editor" + elif platform.system().lower() == "darwin": - return _darwin_get_engine_version() + ue4_path /= "Mac/UE4Editor" - return {} + return ue4_path def _win_get_engine_versions(): - """ + """Get Unreal Engine versions on Windows. + If engines are installed via Epic Games Launcher then there is: `%PROGRAMDATA%/Epic/UnrealEngineLauncher/LauncherInstalled.dat` This file is JSON file listing installed stuff, Unreal engines are marked with `"AppName" = "UE_X.XX"`` like `UE_4.24` + + Returns: + dict: version as a key and path as a value. + """ install_json_path = os.path.join( - os.environ.get("PROGRAMDATA"), + os.getenv("PROGRAMDATA"), "Epic", "UnrealEngineLauncher", "LauncherInstalled.dat", @@ -75,11 +108,19 @@ def _win_get_engine_versions(): def _darwin_get_engine_version() -> dict: - """ + """Get Unreal Engine versions on MacOS. + It works the same as on Windows, just JSON file location is different. + + Returns: + dict: version as a key and path as a value. + + See Aslo: + :func:`_win_get_engine_versions`. + """ install_json_path = os.path.join( - os.environ.get("HOME"), + os.getenv("HOME"), "Library", "Application Support", "Epic", @@ -91,25 +132,26 @@ def _darwin_get_engine_version() -> dict: def _parse_launcher_locations(install_json_path: str) -> dict: - """ - This will parse locations from json file. + """This will parse locations from json file. + + Args: + install_json_path (str): Path to `LauncherInstalled.dat`. + + Returns: + dict: with unreal engine versions as keys and + paths to those engine installations as value. - :param install_json_path: path to `LauncherInstalled.dat` - :type install_json_path: str - :returns: returns dict with unreal engine versions as keys and - paths to those engine installations as value. - :rtype: dict """ engine_locations = {} if os.path.isfile(install_json_path): with open(install_json_path, "r") as ilf: try: install_data = json.load(ilf) - except json.JSONDecodeError: + except json.JSONDecodeError as e: raise Exception( "Invalid `LauncherInstalled.dat file. `" "Cannot determine Unreal Engine location." - ) + ) from e for installation in install_data.get("InstallationList", []): if installation.get("AppName").startswith("UE_"): @@ -121,55 +163,91 @@ def _parse_launcher_locations(install_json_path: str) -> dict: def create_unreal_project(project_name: str, ue_version: str, - pr_dir: str, - engine_path: str, - dev_mode: bool = False) -> None: - """ - This will create `.uproject` file at specified location. As there is no - way I know to create project via command line, this is easiest option. - Unreal project file is basically JSON file. If we find + pr_dir: Path, + engine_path: Path, + dev_mode: bool = False, + env: dict = None) -> None: + """This will create `.uproject` file at specified location. + + As there is no way I know to create project via command line, this is + easiest option. Unreal project file is basically JSON file. If we find `AVALON_UNREAL_PLUGIN` environment variable we assume this is location of Avalon Integration Plugin and we copy its content to project folder and enable this plugin. - :param project_name: project name - :type project_name: str - :param ue_version: unreal engine version (like 4.23) - :type ue_version: str - :param pr_dir: path to directory where project will be created - :type pr_dir: str - :param engine_path: Path to Unreal Engine installation - :type engine_path: str - :param dev_mode: Flag to trigger C++ style Unreal project needing - Visual Studio and other tools to compile plugins from - sources. This will trigger automatically if `Binaries` - directory is not found in plugin folders as this indicates - this is only source distribution of the plugin. Dev mode - is also set by preset file `unreal/project_setup.json` in - **OPENPYPE_CONFIG**. - :type dev_mode: bool - :returns: None + Args: + project_name (str): Name of the project. + ue_version (str): Unreal engine version (like 4.23). + pr_dir (Path): Path to directory where project will be created. + engine_path (Path): Path to Unreal Engine installation. + dev_mode (bool, optional): Flag to trigger C++ style Unreal project + needing Visual Studio and other tools to compile plugins from + sources. This will trigger automatically if `Binaries` + directory is not found in plugin folders as this indicates + this is only source distribution of the plugin. Dev mode + is also set by preset file `unreal/project_setup.json` in + **OPENPYPE_CONFIG**. + env (dict, optional): Environment to use. If not set, `os.environ`. + + Throws: + NotImplementedError: For unsupported platforms. + + Returns: + None + """ + env = env or os.environ preset = get_project_settings(project_name)["unreal"]["project_setup"] + ue_id = ".".join(ue_version.split(".")[:2]) + # get unreal engine identifier + # ------------------------------------------------------------------------- + # FIXME (antirotor): As of 4.26 this is problem with UE4 built from + # sources. In that case Engine ID is calculated per machine/user and not + # from Engine files as this code then reads. This then prevents UE4 + # to directly open project as it will complain about project being + # created in different UE4 version. When user convert such project + # to his UE4 version, Engine ID is replaced in uproject file. If some + # other user tries to open it, it will present him with similar error. + ue4_modules = Path() + if platform.system().lower() == "windows": + ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + "Win64", "UE4Editor.modules")) + + if platform.system().lower() == "linux": + ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + "Linux", "UE4Editor.modules")) + + if platform.system().lower() == "darwin": + ue4_modules = Path(os.path.join(engine_path, "Engine", "Binaries", + "Mac", "UE4Editor.modules")) + + if ue4_modules.exists(): + print("--- Loading Engine ID from modules file ...") + with open(ue4_modules, "r") as mp: + loaded_modules = json.load(mp) + + if loaded_modules.get("BuildId"): + ue_id = "{" + loaded_modules.get("BuildId") + "}" - if os.path.isdir(os.environ.get("AVALON_UNREAL_PLUGIN", "")): + plugins_path = None + if os.path.isdir(env.get("AVALON_UNREAL_PLUGIN", "")): # copy plugin to correct path under project - plugins_path = os.path.join(pr_dir, "Plugins") - avalon_plugin_path = os.path.join(plugins_path, "Avalon") - if not os.path.isdir(avalon_plugin_path): - os.makedirs(avalon_plugin_path, exist_ok=True) + plugins_path = pr_dir / "Plugins" + avalon_plugin_path = plugins_path / "Avalon" + if not avalon_plugin_path.is_dir(): + avalon_plugin_path.mkdir(parents=True, exist_ok=True) dir_util._path_created = {} dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"), - avalon_plugin_path) + avalon_plugin_path.as_posix()) - if (not os.path.isdir(os.path.join(avalon_plugin_path, "Binaries")) - or not os.path.join(avalon_plugin_path, "Intermediate")): + if not (avalon_plugin_path / "Binaries").is_dir() \ + or not (avalon_plugin_path / "Intermediate").is_dir(): dev_mode = True # data for project file data = { "FileVersion": 3, - "EngineAssociation": ue_version, + "EngineAssociation": ue_id, "Category": "", "Description": "", "Plugins": [ @@ -179,35 +257,6 @@ def create_unreal_project(project_name: str, ] } - if preset["install_unreal_python_engine"]: - # If `OPENPYPE_UNREAL_ENGINE_PYTHON_PLUGIN` is set, copy it from there - # to support offline installation. - # Otherwise clone UnrealEnginePython to Plugins directory - # https://github.com/20tab/UnrealEnginePython.git - uep_path = os.path.join(plugins_path, "UnrealEnginePython") - if os.environ.get("OPENPYPE_UNREAL_ENGINE_PYTHON_PLUGIN"): - - os.makedirs(uep_path, exist_ok=True) - dir_util._path_created = {} - dir_util.copy_tree( - os.environ.get("OPENPYPE_UNREAL_ENGINE_PYTHON_PLUGIN"), - uep_path) - else: - # WARNING: this will trigger dev_mode, because we need to compile - # this plugin. - dev_mode = True - import git - git.Repo.clone_from( - "https://github.com/20tab/UnrealEnginePython.git", - uep_path) - - data["Plugins"].append( - {"Name": "UnrealEnginePython", "Enabled": True}) - - if (not os.path.isdir(os.path.join(uep_path, "Binaries")) - or not os.path.join(uep_path, "Intermediate")): - dev_mode = True - if dev_mode or preset["dev_mode"]: # this will add project module and necessary source file to make it # C++ project and to (hopefully) make Unreal Editor to compile all @@ -220,51 +269,39 @@ def create_unreal_project(project_name: str, "AdditionalDependencies": ["Engine"], }] - if preset["install_unreal_python_engine"]: - # now we need to fix python path in: - # `UnrealEnginePython.Build.cs` - # to point to our python - with open(os.path.join( - uep_path, "Source", - "UnrealEnginePython", - "UnrealEnginePython.Build.cs"), mode="r") as f: - build_file = f.read() - - fix = build_file.replace( - 'private string pythonHome = "";', - 'private string pythonHome = "{}";'.format( - sys.base_prefix.replace("\\", "/"))) - - with open(os.path.join( - uep_path, "Source", - "UnrealEnginePython", - "UnrealEnginePython.Build.cs"), mode="w") as f: - f.write(fix) - # write project file - project_file = os.path.join(pr_dir, "{}.uproject".format(project_name)) + project_file = pr_dir / f"{project_name}.uproject" with open(project_file, mode="w") as pf: json.dump(data, pf, indent=4) - # UE < 4.26 have Python2 by default, so we need PySide - # but we will not need it in 4.26 and up - if int(ue_version.split(".")[1]) < 26: - # ensure we have PySide installed in engine - # TODO: make it work for other platforms 🍎 🐧 - if platform.system().lower() == "windows": - python_path = os.path.join(engine_path, "Engine", "Binaries", - "ThirdParty", "Python", "Win64", - "python.exe") + # ensure we have PySide2 installed in engine + python_path = None + if platform.system().lower() == "windows": + python_path = engine_path / ("Engine/Binaries/ThirdParty/" + "Python3/Win64/pythonw.exe") + + if platform.system().lower() == "linux": + python_path = engine_path / ("Engine/Binaries/ThirdParty/" + "Python3/Linux/bin/python3") + + if platform.system().lower() == "darwin": + python_path = engine_path / ("Engine/Binaries/ThirdParty/" + "Python3/Mac/bin/python3") - subprocess.run([python_path, "-m", - "pip", "install", "pyside"]) + if not python_path: + raise NotImplementedError("Unsupported platform") + if not python_path.exists(): + raise RuntimeError(f"Unreal Python not found at {python_path}") + subprocess.run( + [python_path.as_posix(), "-m", "pip", "install", "pyside2"]) if dev_mode or preset["dev_mode"]: _prepare_cpp_project(project_file, engine_path) -def _prepare_cpp_project(project_file: str, engine_path: str) -> None: - """ +def _prepare_cpp_project(project_file: Path, engine_path: Path) -> None: + """Prepare CPP Unreal Project. + This function will add source files needed for project to be rebuild along with the avalon integration plugin. @@ -273,19 +310,19 @@ def _prepare_cpp_project(project_file: str, engine_path: str) -> None: by some generator. This needs more research as manually writing those files is rather hackish. :skull_and_crossbones: - :param project_file: path to .uproject file - :type project_file: str - :param engine_path: path to unreal engine associated with project - :type engine_path: str - """ - project_name = os.path.splitext(os.path.basename(project_file))[0] - project_dir = os.path.dirname(project_file) - targets_dir = os.path.join(project_dir, "Source") - sources_dir = os.path.join(targets_dir, project_name) + Args: + project_file (str): Path to .uproject file. + engine_path (str): Path to unreal engine associated with project. - os.makedirs(sources_dir, exist_ok=True) - os.makedirs(os.path.join(project_dir, "Content"), exist_ok=True) + """ + project_name = project_file.stem + project_dir = project_file.parent + targets_dir = project_dir / "Source" + sources_dir = targets_dir / project_name + + sources_dir.mkdir(parents=True, exist_ok=True) + (project_dir / "Content").mkdir(parents=True, exist_ok=True) module_target = ''' using UnrealBuildTool; @@ -360,59 +397,59 @@ class {1}_API A{0}GameModeBase : public AGameModeBase }}; '''.format(project_name, project_name.upper()) - with open(os.path.join( - targets_dir, f"{project_name}.Target.cs"), mode="w") as f: + with open(targets_dir / f"{project_name}.Target.cs", mode="w") as f: f.write(module_target) - with open(os.path.join( - targets_dir, f"{project_name}Editor.Target.cs"), mode="w") as f: + with open(targets_dir / f"{project_name}Editor.Target.cs", mode="w") as f: f.write(editor_module_target) - with open(os.path.join( - sources_dir, f"{project_name}.Build.cs"), mode="w") as f: + with open(sources_dir / f"{project_name}.Build.cs", mode="w") as f: f.write(module_build) - with open(os.path.join( - sources_dir, f"{project_name}.cpp"), mode="w") as f: + with open(sources_dir / f"{project_name}.cpp", mode="w") as f: f.write(module_cpp) - with open(os.path.join( - sources_dir, f"{project_name}.h"), mode="w") as f: + with open(sources_dir / f"{project_name}.h", mode="w") as f: f.write(module_header) - with open(os.path.join( - sources_dir, f"{project_name}GameModeBase.cpp"), mode="w") as f: + with open(sources_dir / f"{project_name}GameModeBase.cpp", mode="w") as f: f.write(game_mode_cpp) - with open(os.path.join( - sources_dir, f"{project_name}GameModeBase.h"), mode="w") as f: + with open(sources_dir / f"{project_name}GameModeBase.h", mode="w") as f: f.write(game_mode_h) + u_build_tool = Path( + engine_path / "Engine/Binaries/DotNET/UnrealBuildTool.exe") + u_header_tool = None + + arch = "Win64" if platform.system().lower() == "windows": - u_build_tool = (f"{engine_path}/Engine/Binaries/DotNET/" - "UnrealBuildTool.exe") - u_header_tool = (f"{engine_path}/Engine/Binaries/Win64/" - f"UnrealHeaderTool.exe") + arch = "Win64" + u_header_tool = Path( + engine_path / "Engine/Binaries/Win64/UnrealHeaderTool.exe") elif platform.system().lower() == "linux": - # WARNING: there is no UnrealBuildTool on linux? - u_build_tool = "" - u_header_tool = "" + arch = "Linux" + u_header_tool = Path( + engine_path / "Engine/Binaries/Linux/UnrealHeaderTool") elif platform.system().lower() == "darwin": - # WARNING: there is no UnrealBuildTool on Mac? - u_build_tool = "" - u_header_tool = "" + # we need to test this out + arch = "Mac" + u_header_tool = Path( + engine_path / "Engine/Binaries/Mac/UnrealHeaderTool") - u_build_tool = u_build_tool.replace("\\", "/") - u_header_tool = u_header_tool.replace("\\", "/") + if not u_header_tool: + raise NotImplementedError("Unsupported platform") - command1 = [u_build_tool, "-projectfiles", f"-project={project_file}", - "-progress"] + command1 = [u_build_tool.as_posix(), "-projectfiles", + f"-project={project_file}", "-progress"] subprocess.run(command1) - command2 = [u_build_tool, f"-ModuleWithSuffix={project_name},3555" - "Win64", "Development", "-TargetType=Editor" - f'-Project="{project_file}"', f'"{project_file}"' + command2 = [u_build_tool.as_posix(), + f"-ModuleWithSuffix={project_name},3555", arch, + "Development", "-TargetType=Editor", + f'-Project={project_file}', + f'{project_file}', "-IgnoreJunk"] subprocess.run(command2) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index f084cccfc36..01b8b6bc05d 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -1,31 +1,49 @@ +# -*- coding: utf-8 -*- +"""Hook to launch Unreal and prepare projects.""" import os +from pathlib import Path from openpype.lib import ( PreLaunchHook, - ApplicationLaunchFailed + ApplicationLaunchFailed, + ApplicationNotFound ) from openpype.hosts.unreal.api import lib as unreal_lib class UnrealPrelaunchHook(PreLaunchHook): - """ + """Hook to handle launching Unreal. + This hook will check if current workfile path has Unreal project inside. IF not, it initialize it and finally it pass path to the project by environment variable to Unreal launcher shell script. - """ + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.signature = "( {} )".format(self.__class__.__name__) def execute(self): + """Hook entry method.""" asset_name = self.data["asset_name"] task_name = self.data["task_name"] workdir = self.launch_context.env["AVALON_WORKDIR"] engine_version = self.app_name.split("/")[-1].replace("-", ".") unreal_project_name = f"{asset_name}_{task_name}" + try: + if int(engine_version.split(".")[0]) < 4 and \ + int(engine_version.split(".")[1]) < 26: + raise ApplicationLaunchFailed(( + f"{self.signature} Old unsupported version of UE4 " + f"detected - {engine_version}")) + except ValueError: + # there can be string in minor version and in that case + # int cast is failing. This probably happens only with + # early access versions and is of no concert for this check + # so lets keep it quite. + ... # Unreal is sensitive about project names longer then 20 chars if len(unreal_project_name) > 20: @@ -45,19 +63,21 @@ def execute(self): )) unreal_project_name = f"P{unreal_project_name}" - project_path = os.path.join(workdir, unreal_project_name) + project_path = Path(os.path.join(workdir, unreal_project_name)) self.log.info(( f"{self.signature} requested UE4 version: " f"[ {engine_version} ]" )) - detected = unreal_lib.get_engine_versions() + detected = unreal_lib.get_engine_versions(self.launch_context.env) detected_str = ', '.join(detected.keys()) or 'none' self.log.info(( f"{self.signature} detected UE4 versions: " f"[ {detected_str} ]" )) + if not detected: + raise ApplicationNotFound("No Unreal Engines are found.") engine_version = ".".join(engine_version.split(".")[:2]) if engine_version not in detected.keys(): @@ -66,13 +86,14 @@ def execute(self): f"detected [ {engine_version} ]" )) - os.makedirs(project_path, exist_ok=True) + ue4_path = unreal_lib.get_editor_executable_path( + Path(detected[engine_version])) + + self.launch_context.launch_args.append(ue4_path.as_posix()) + project_path.mkdir(parents=True, exist_ok=True) - project_file = os.path.join( - project_path, - f"{unreal_project_name}.uproject" - ) - if not os.path.isfile(project_file): + project_file = project_path / f"{unreal_project_name}.uproject" + if not project_file.is_file(): engine_path = detected[engine_version] self.log.info(( f"{self.signature} creating unreal " @@ -88,8 +109,9 @@ def execute(self): unreal_project_name, engine_version, project_path, - engine_path=engine_path + engine_path=Path(engine_path) ) # Append project file to launch arguments - self.launch_context.launch_args.append(f"\"{project_file}\"") + self.launch_context.launch_args.append( + f"\"{project_file.as_posix()}\"") diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 224f9dc3183..842c294599a 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -1084,7 +1084,7 @@ "unreal": { "enabled": true, "label": "Unreal Editor", - "icon": "{}/app_icons/ue4.png'", + "icon": "{}/app_icons/ue4.png", "host_name": "unreal", "environment": {}, "variants": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json b/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json index b6e94d9d036..4e197e9fc81 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json @@ -15,11 +15,6 @@ "type": "boolean", "key": "dev_mode", "label": "Dev mode" - }, - { - "type": "boolean", - "key": "install_unreal_python_engine", - "label": "Install unreal python engine" } ] }