From 7dd86824cb8f8ce0646bc622e6a33bfdab07799e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 10 Jun 2024 13:59:35 +0200 Subject: [PATCH 01/11] Added DL official Unreal5 plugin enhanced by Perforce utilities Whole Unreal5 plugin copied here as it is not possible to add to custom folder only `JobPreLoad.py` and `UnrealSyncUtil.py` which is handling Perforce. Might need to be revisited as this would create dependency on official Unreal5 plugin. `JobPreLoad.py` and `UnrealSyncUtil.py` handles Perforce syncing, must be triggered before Unreal rendering job. It would better to have here only these two files here, but deployment wouldn't be straightforward copy as for other plugins. --- .../plugins/UnrealEngine5/DeadlineRPC.py | 319 +++++++ .../plugins/UnrealEngine5/JobPreLoad.py | 212 +++++ .../custom/plugins/UnrealEngine5/LICENSE | 21 + .../plugins/UnrealEngine5/PluginPreLoad.py | 13 + .../plugins/UnrealEngine5/UnrealEngine5.ico | Bin 0 -> 894 bytes .../UnrealEngine5/UnrealEngine5.options | 59 ++ .../plugins/UnrealEngine5/UnrealEngine5.param | 33 + .../plugins/UnrealEngine5/UnrealEngine5.py | 806 ++++++++++++++++++ .../Config/BaseMoviePipelineDeadline.ini | 12 + .../Config/FilterPlugin.ini | 8 + .../Content/Python/init_unreal.py | 42 + .../Content/Python/mrq_cli.py | 228 +++++ .../Content/Python/mrq_cli_modes/__init__.py | 15 + .../Python/mrq_cli_modes/render_manifest.py | 165 ++++ .../Python/mrq_cli_modes/render_queue.py | 155 ++++ .../Python/mrq_cli_modes/render_queue_jobs.py | 112 +++ .../Python/mrq_cli_modes/render_sequence.py | 177 ++++ .../Content/Python/mrq_cli_modes/utils.py | 360 ++++++++ .../Content/Python/mrq_rpc.py | 485 +++++++++++ .../Python/pipeline_actions/__init__.py | 0 .../pipeline_actions/render_queue_action.py | 242 ++++++ .../Content/Python/remote_executor.py | 479 +++++++++++ .../Widgets/QueueAssetSubmitter.uasset | Bin 0 -> 267854 bytes .../MoviePipelineDeadline.uplugin | 44 + .../Resources/Icon128.png | Bin 0 -> 12699 bytes .../MoviePipelineDeadline.Build.cs | 32 + .../DeadlineJobPresetCustomization.cpp | 338 ++++++++ .../MoviePipelineDeadlineExecutorJob.cpp | 102 +++ ...pelineDeadlineExecutorJobCustomization.cpp | 30 + .../Private/MoviePipelineDeadlineModule.cpp | 39 + .../Private/MoviePipelineDeadlineSettings.cpp | 26 + .../Public/DeadlineJobPresetCustomization.h | 36 + .../Public/MoviePipelineDeadlineExecutorJob.h | 66 ++ ...PipelineDeadlineExecutorJobCustomization.h | 22 + .../Public/MoviePipelineDeadlineModule.h | 13 + .../Public/MoviePipelineDeadlineSettings.h | 57 ++ .../UnrealEnginePlugins/README.md | 36 + .../Config/DefaultUnrealDeadlineService.ini | 24 + .../Config/FilterPlugin.ini | 8 + .../Content/Python/Lib/requirements.txt | 1 + .../Content/Python/deadline_command.py | 140 +++ .../Content/Python/deadline_enums.py | 55 ++ .../Content/Python/deadline_http.py | 118 +++ .../Content/Python/deadline_job.py | 250 ++++++ .../Content/Python/deadline_menus/__init__.py | 8 + .../Python/deadline_menus/base_menu_action.py | 58 ++ .../deadline_menus/deadline_toolbar_menu.py | 131 +++ .../Content/Python/deadline_rpc/__init__.py | 8 + .../Python/deadline_rpc/base_server.py | 272 ++++++ .../Python/deadline_rpc/base_ue_rpc.py | 241 ++++++ .../Content/Python/deadline_rpc/client.py | 103 +++ .../Content/Python/deadline_rpc/exceptions.py | 78 ++ .../Content/Python/deadline_rpc/factory.py | 249 ++++++ .../Content/Python/deadline_rpc/server.py | 29 + .../Python/deadline_rpc/validations.py | 105 +++ .../Content/Python/deadline_service.py | 755 ++++++++++++++++ .../Content/Python/deadline_utils.py | 220 +++++ .../Content/Python/init_unreal.py | 54 ++ .../Python/service_actions/__init__.py | 0 .../service_actions/submit_job_action.py | 113 +++ .../Widgets/DeadlineJobSubmitter.uasset | Bin 0 -> 130606 bytes .../DeadlineService/DeadlineService.Build.cs | 26 + .../Private/DeadlineJobPreset.cpp | 66 ++ .../Private/DeadlineJobPresetFactory.cpp | 30 + .../Private/DeadlineServiceEditorSettings.cpp | 3 + .../Private/DeadlineServiceModule.cpp | 7 + .../AssetDefinition_DeadlineJobPreset.h | 26 + .../Public/DeadlineJobPreset.h | 230 +++++ .../Public/DeadlineJobPresetFactory.h | 23 + .../Public/DeadlineServiceEditorHelpers.h | 137 +++ .../Public/DeadlineServiceEditorSettings.h | 59 ++ .../Public/DeadlineServiceModule.h | 9 + .../Public/DeadlineServiceTimerManager.h | 74 ++ .../UnrealDeadlineService.uplugin | 35 + .../plugins/UnrealEngine5/UnrealSyncUtil.py | 652 ++++++++++++++ .../UnrealEngine5/ue_utils/__init__.py | 1 + .../UnrealEngine5/ue_utils/rpc/__init__.py | 9 + .../UnrealEngine5/ue_utils/rpc/base_server.py | 275 ++++++ .../UnrealEngine5/ue_utils/rpc/client.py | 106 +++ .../UnrealEngine5/ue_utils/rpc/exceptions.py | 81 ++ .../UnrealEngine5/ue_utils/rpc/factory.py | 252 ++++++ .../UnrealEngine5/ue_utils/rpc/server.py | 32 + .../UnrealEngine5/ue_utils/rpc/validations.py | 108 +++ .../ue_utils/submit_deadline_job.py | 72 ++ client/ayon_deadline/repository/readme.md | 14 +- 85 files changed, 10128 insertions(+), 3 deletions(-) create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/DeadlineRPC.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/LICENSE create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/PluginPreLoad.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.ico create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.options create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.param create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/BaseMoviePipelineDeadline.ini create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/FilterPlugin.ini create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/init_unreal.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/__init__.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_manifest.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue_jobs.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_sequence.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/utils.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_rpc.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/pipeline_actions/__init__.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/pipeline_actions/render_queue_action.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/remote_executor.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Widgets/QueueAssetSubmitter.uasset create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/MoviePipelineDeadline.uplugin create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Resources/Icon128.png create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/MoviePipelineDeadline.Build.cs create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/DeadlineJobPresetCustomization.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJob.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJobCustomization.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineModule.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineSettings.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/DeadlineJobPresetCustomization.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJob.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJobCustomization.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineModule.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineSettings.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/README.md create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/DefaultUnrealDeadlineService.ini create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/FilterPlugin.ini create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/Lib/requirements.txt create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_command.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_enums.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_http.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_job.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/__init__.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/base_menu_action.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/deadline_toolbar_menu.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/__init__.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_server.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_ue_rpc.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/client.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/exceptions.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/factory.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/server.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/validations.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_service.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_utils.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/init_unreal.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/service_actions/__init__.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/service_actions/submit_job_action.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Widgets/DeadlineJobSubmitter.uasset create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/DeadlineService.Build.cs create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPreset.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPresetFactory.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceEditorSettings.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceModule.cpp create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/AssetDefinition_DeadlineJobPreset.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPreset.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPresetFactory.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorHelpers.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorSettings.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceModule.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceTimerManager.h create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/UnrealDeadlineService.uplugin create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/__init__.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/__init__.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/base_server.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/client.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/exceptions.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/factory.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/server.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/validations.py create mode 100644 client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/submit_deadline_job.py diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/DeadlineRPC.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/DeadlineRPC.py new file mode 100644 index 0000000000..500fa32eec --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/DeadlineRPC.py @@ -0,0 +1,319 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +from ue_utils.rpc.server import RPCServerThread +from ue_utils.rpc.base_server import BaseRPCServerManager + +from Deadline.Scripting import RepositoryUtils + + +class BaseDeadlineRPCJobManager: + """ + This is a base class for exposing commonly used deadline function on RPC + """ + + def __init__(self): + """ + Constructor + """ + # get the instance of the deadline plugin from the python globals + self._deadline_plugin = self.__get_instance_from_globals() + + # Get the current running job + self._job = self._deadline_plugin.GetJob() + self._is_connected = False + + # Track all completed tasks + self._completed_tasks = set() + + def connect(self): + """ + First mode of contact to the rpc server. It is very critical the + client calls this function first as it will let the Deadline process + know a client has connected and to wait on the task to complete. + Else, Deadline will assume the connection was never made and requeue + the job after a few minutes + :return: bool representing the connection + """ + self._is_connected = True + print("Server connection established!") + return self._is_connected + + def is_connected(self): + """ + Returns the connection status to a client + :return: + """ + return self._is_connected + + def is_task_complete(self, task_id): + """ + Checks and returns if a task has been marked as complete + :param task_id: job task id + :return: return True/False if the task id is present + """ + return task_id in self._completed_tasks + + @staticmethod + def __get_instance_from_globals(): + """ + Get the instance of the Deadline plugin from the python globals. + Since this class is executed in a thread, this was the best method to + get the plugin instance to the class without pass it though several + layers of abstraction + :return: + """ + import __main__ + + try: + return __main__.__deadline_plugin_instance__ + except AttributeError as err: + raise RuntimeError( + f"Could not get deadline plugin instance from globals. " + f"\n\tError: {err}" + ) + + def get_job_id(self): + """ + Returns the current JobID + :return: Job ID + """ + return self._job.JobId + + def get_task_frames(self): + """ + Returns the frames rendered by ths task + :return: + """ + return [ + self._deadline_plugin.GetStartFrame(), + self._deadline_plugin.GetEndFrame() + ] + + def get_job_extra_info_key_value(self, name): + """ + Returns the value of a key in the job extra info property + :param name: Extra Info Key + :return: Returns Extra Info Value + """ + # This function is probably the most important function in the class. + # This allows you to store different types of data and retrieve the + # data from the other side. This is what makes the Unreal plugin a bit + # more feature/task agnostic + return self._job.GetJobExtraInfoKeyValue(name) + + def fail_render(self, message): + """ + Fail a render job with a message + :param message: Failure message + """ + self._deadline_plugin.FailRender(message.strip("\n")) + return True + + def set_status_message(self, message): + """ + Sets the message on the job status + :param message: Status Message + """ + self._deadline_plugin.SetStatusMessage(message) + return True + + def set_progress(self, progress): + """ + Sets the job progress + :param progress: job progress + """ + self._deadline_plugin.SetProgress(progress) + return True + + def log_warning(self, message): + """ + Logs a warning message + :param message: Log message + """ + self._deadline_plugin.LogWarning(message) + return True + + def log_info(self, message): + """ + Logs an informational message + :param message: Log message + """ + self._deadline_plugin.LogInfo(message) + return True + + def get_task_id(self): + """ + Returns the current Task ID + :return: + """ + return self._deadline_plugin.GetCurrentTaskId() + + def get_job_user(self): + """ + Return the job user + :return: + """ + return self._job.JobUserName + + def complete_task(self, task_id): + """ + Marks a task as complete. This function should be called when a task + is complete. This will allow the Deadline render taskl process to end + and get the next render task. If this is not called, deadline will + render the task indefinitely + :param task_id: Task ID to mark as complete + :return: + """ + self._completed_tasks.add(task_id) + return True + + def update_job_output_filenames(self, filenames): + """ + Updates the file names for the current job + :param list filenames: list of filenames + """ + if not isinstance(filenames, list): + filenames = list(filenames) + + self._deadline_plugin.LogInfo( + "Setting job filenames: {filename}".format( + filename=", ".join(filenames) + ) + ) + + # Set the file names on the job + RepositoryUtils.UpdateJobOutputFileNames(self._job, filenames) + + # Make sure to save the settings just in case + RepositoryUtils.SaveJob(self._job) + + def update_job_output_directories(self, directories): + """ + Updates the output directories on job + :param list directories: List of directories + """ + if not isinstance(directories, list): + directories = list(directories) + + self._deadline_plugin.LogInfo( + "Setting job directories: {directories}".format( + directories=", ".join(directories) + ) + ) + + # Set the directory on the job + RepositoryUtils.SetJobOutputDirectories(self._job, directories) + + # Make sure to save the settings just in case + RepositoryUtils.SaveJob(self._job) + + def check_path_mappings(self, paths): + """ + Resolves any path mappings set on input path + :param [str] paths: Path string with tokens + :return: Resolved path mappings + """ + if not isinstance(paths, list): + paths = list(paths) + + # Deadline returns a System.String[] object here. Convert to a proper + # list + path_mapped_strings = RepositoryUtils.CheckPathMappingForMultiplePaths( + paths, + forceSeparator="/", + verbose=False + ) + + return [str(path) for path in path_mapped_strings] + + +class DeadlineRPCServerThread(RPCServerThread): + """ + Deadline server thread + """ + + deadline_job_manager = None + + def __init__(self, name, port): + super(DeadlineRPCServerThread, self).__init__(name, port) + if self.deadline_job_manager: + self.deadline_job_manager = self.deadline_job_manager() + else: + self.deadline_job_manager = BaseDeadlineRPCJobManager() + + # Register our instance on the server + self.server.register_instance( + self.deadline_job_manager, + allow_dotted_names=True + ) + + +class DeadlineRPCServerManager(BaseRPCServerManager): + """ + RPC server manager class. This class is responsible for registering a + server thread class and starting the thread. This can be a blocking or + non-blocking thread + """ + + def __init__(self, deadline_plugin, port): + super(DeadlineRPCServerManager, self).__init__() + self.name = "DeadlineRPCServer" + self.port = port + self.is_started = False + self.__make_plugin_instance_global(deadline_plugin) + + @staticmethod + def __make_plugin_instance_global(deadline_plugin_instance): + """ + Puts an instance of the deadline plugin in the python globals. This + allows the server thread to get the plugin instance without having + the instance passthrough abstraction layers + :param deadline_plugin_instance: Deadline plugin instance + :return: + """ + import __main__ + + if not hasattr(__main__, "__deadline_plugin_instance__"): + __main__.__deadline_plugin_instance__ = None + + __main__.__deadline_plugin_instance__ = deadline_plugin_instance + + def start(self, threaded=True): + """ + Starts the server thread + :param threaded: Run as threaded or blocking + :return: + """ + super(DeadlineRPCServerManager, self).start(threaded=threaded) + self.is_started = True + + def client_connected(self): + """ + Check if there is a client connected + :return: + """ + if self.server_thread: + return self.server_thread.deadline_job_manager.is_connected() + return False + + def get_temporary_client_proxy(self): + """ + This returns client proxy and is not necessarily expected to be used + for server communication but for mostly queries. + NOTE: This behavior is implied + :return: RPC client proxy + """ + from ue_utils.rpc.client import RPCClient + + # Get the port the server is using + server = self.get_server() + _, server_port = server.socket.getsockname() + return RPCClient(port=int(server_port)).proxy + + def shutdown(self): + """ + Stops the server and shuts down the thread + :return: + """ + super(DeadlineRPCServerManager, self).shutdown() + self.is_started = False diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py new file mode 100644 index 0000000000..40477e028e --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py @@ -0,0 +1,212 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +from Deadline.Scripting import * +import UnrealSyncUtil +import os +from Deadline.Scripting import FileUtils + + +# This is executed on the Slave prior to it attempting to execute a task. +# We use this to sync to the specified changelist and build the project +def __main__( deadlinePlugin ): + # + # Retrieve the settings from the job so we know which branch/stream/target this is. + # + stream = deadlinePlugin.GetPluginInfoEntry("PerforceStream") + if not stream: + print("Perforce info not collected, skipping!") + changelist = int(deadlinePlugin.GetPluginInfoEntryWithDefault("PerforceChangelist", "0")) + gamePath = deadlinePlugin.GetPluginInfoEntry("PerforceGamePath") + projectFile = deadlinePlugin.GetPluginInfoEntry("ProjectFile") + editorName = deadlinePlugin.GetPluginInfoEntry("EditorExecutableName") + if not editorName: + editorName = projectFile.replace('.uproject','Editor') + + bForceClean = deadlinePlugin.GetPluginInfoEntryWithDefault("ForceClean", "false").lower() == "true" + bForceFullSync = deadlinePlugin.GetPluginInfoEntryWithDefault("ForceFullSync", "false").lower() == "true" + bSyncProject = deadlinePlugin.GetPluginInfoEntryWithDefault("SyncProject", "true" ).lower() == "true" + bSyncEntireStream = deadlinePlugin.GetPluginInfoEntryWithDefault("SyncEntireStream", "false").lower() == "true" + bBuildProject = True + + print("bSyncProject::: " + str(bSyncProject)) + print("bSyncEntireStream: " + str(bSyncEntireStream)) + + + # + # Set up PerforceUtil + # + + try: + env = os.environ.copy() + env["P4PORT"] = deadlinePlugin.GetProcessEnvironmentVariable("P4PORT") + env["P4USER"] = deadlinePlugin.GetProcessEnvironmentVariable("P4USER") + env["P4PASSWD"] = deadlinePlugin.GetProcessEnvironmentVariable("P4PASSWD") + print(f"env::{env}") + perforceTools = UnrealSyncUtil.PerforceUtils(stream, gamePath, env) + except UnrealSyncUtil.PerforceError as pe: + # Catch environment configuration errors. + deadlinePlugin.FailRender(pe.message) + + + # Automatically determine a perforce workspace for this local machine + try: + deadlinePlugin.SetStatusMessage("Determining Workspace") + deadlinePlugin.LogInfo("Determining client workspace for %s on %s" % (stream, perforceTools.localHost)) + deadlinePlugin.SetProgress(0) + perforceTools.DetermineClientWorkspace() + except UnrealSyncUtil.PerforceArgumentError as argError: + deadlinePlugin.LogWarning(argError.message) + deadlinePlugin.FailRender(argError.message) + except UnrealSyncUtil.PerforceMissingWorkspaceError as argError: + deadlinePlugin.LogWarning(argError.message) + deadlinePlugin.FailRender(argError.message) + except UnrealSyncUtil.PerforceMultipleWorkspaceError as argError: + deadlinePlugin.LogWarning(argError.message) + deadlinePlugin.FailRender(argError.message) + + # Set project root + # This resolves gamePath in case it contains "..."" + try: + deadlinePlugin.SetStatusMessage("Determining project root") + deadlinePlugin.LogInfo("Determining project root for %s" % (projectFile)) + deadlinePlugin.SetProgress(0) + perforceTools.DetermineProjectRoot( projectFile ) + except UnrealSyncUtil.PerforceError as argError: + deadlinePlugin.LogWarning(argError.message) + deadlinePlugin.FailRender(argError.message) + + projectRoot = perforceTools.projectRoot.replace('\\','/') + deadlinePlugin.LogInfo( "Storing UnrealProjectRoot (\"%s\") in environment variable..." % projectRoot ) + deadlinePlugin.SetProcessEnvironmentVariable( "UnrealProjectRoot", projectRoot ) + + project_path = os.path.join(projectRoot, projectFile) + deadlinePlugin.LogInfo( "Storing UnrealUProject (\"%s\") in environment variable..." % project_path ) + deadlinePlugin.SetProcessEnvironmentVariable( "UnrealUProject", project_path ) + + + # Set the option if it's syncing entire stream or just game path + perforceTools.SetSyncEntireStream( bSyncEntireStream ) + + # + # Clean workspace + # + if bForceFullSync: + deadlinePlugin.LogWarning("A full perforce sync is queued, this will take some time.") + elif bForceClean: + # We don't bother doing a clean if they're doing a force full sync. + deadlinePlugin.LogInfo("Performing a perforce clean to bring local files in sync with depot.") + perforceTools.CleanWorkspace() + deadlinePlugin.LogInfo("Finished p4 clean.") + + deadlinePlugin.LogInfo("Perforce Command Prefix: " + " ".join(perforceTools.GetP4CommandPrefix())) + + # Determine the latest changelist to sync to if unspecified. + try: + if changelist == 0: + deadlinePlugin.LogInfo("No changelist specified, determining latest...") + perforceTools.DetermineLatestChangelist() + deadlinePlugin.LogInfo("Determined %d as latest." % perforceTools.changelist) + else: + deadlinePlugin.LogInfo("Syncing to manually specified CL %d." % changelist) + perforceTools.setChangelist(changelist) + except UnrealSyncUtil.PerforceResponseError as argError: + deadlinePlugin.LogWarning(str(argError)) + deadlinePlugin.LogWarning("Changelist will be latest in subsequent commands.") + + + # + # Sync project + # + if bSyncProject: + + # Estimate how much work there is to do for a sync operation. + try: + deadlinePlugin.SetStatusMessage("Estimating work for Project sync (CL %d)" % perforceTools.changelist) + deadlinePlugin.LogInfo("Estimating work for Project sync (CL %d)" % perforceTools.changelist) + perforceTools.DetermineSyncWorkEstimate(bForceFullSync) + except UnrealSyncUtil.PerforceResponseError as argError: + deadlinePlugin.LogWarning(str(argError)) + deadlinePlugin.LogWarning("No sync estimates will be available.") + + # If there's no files to sync, let's skip running the sync. It takes a lot of time as it's a double-estimate. + if perforceTools.syncEstimates[0] == 0 and perforceTools.syncEstimates[1] == 0 and perforceTools.syncEstimates[2] == 0: + deadlinePlugin.LogInfo("Skipping sync command as estimated says there's no work to sync!") + else: + # Sync to the changelist already calculated. + try: + deadlinePlugin.SetStatusMessage("Syncing to CL %d" % perforceTools.changelist) + deadlinePlugin.LogInfo("Syncing to CL %d" % perforceTools.changelist) + deadlinePlugin.SetProgress(0) + deadlinePlugin.LogInfo("Estimated Files %s (added/updated/deleted)" % ("/".join(map(str, perforceTools.syncEstimates)))) + + logCallback = lambda tools: deadlinePlugin.SetProgress(perforceTools.GetSyncProgress() * 100) + + + # Perform the sync. This could take a while. + perforceTools.Sync(logCallback, bForceFullSync) + + # The estimates are only estimates, so when the command is complete we'll ensure it looks complete. + deadlinePlugin.SetStatusMessage("Synced Workspace to CL " + str(perforceTools.changelist)) + deadlinePlugin.LogInfo("Synced Workspace to CL " + str(perforceTools.changelist)) + deadlinePlugin.SetProgress(100) + except IOError as ioError: + deadlinePlugin.LogWarning(str(ioError)) + deadlinePlugin.FailRender("Suspected Out of Disk Error while syncing: \"%s\"" % str(ioError)) + else: + deadlinePlugin.LogInfo("Skipping Project Sync due to job settings.") + + + + # + # Build project + # + if bBuildProject: + # BuildUtils requires engine root to determine a path to UnrealBuildTool + # Using Deadline system to determine the path to the executable + version = deadlinePlugin.GetPluginInfoEntry("EngineVersion") + deadlinePlugin.LogInfo('Version defined: %s' % version ) + version_string = str(version).replace(".", "_") + executable_key = f"UnrealEditorExecutable_{version_string}" + unreal_exe_list = (deadlinePlugin.GetEnvironmentVariable(executable_key) + or deadlinePlugin.GetEnvironmentVariable("UnrealExecutable")) + unreal_exe_list = r"C:\Program Files\Epic Games\UE_5.3\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" # TODO TEMP! + if not unreal_exe_list: + deadlinePlugin.FailRender( "Unreal Engine " + str(version) + " entry not found in .param file" ) + unreal_executable = FileUtils.SearchFileList( unreal_exe_list ) + if unreal_executable == "": + err_msg = 'Unreal Engine %s executable was not found in the semicolon separated list \"%s\".' % (str(version), str(unreal_exe_list)) + deadlinePlugin.FailRender( err_msg ) + + unreal_executable = unreal_executable.replace('\\','/') + engine_root = unreal_executable.split('/Engine/Binaries/')[0] + + uproject_path = perforceTools.uprojectPath + + buildtool = UnrealSyncUtil.BuildUtils( engine_root, uproject_path, editorName ) + + if not buildtool.IsCppProject(): + deadlinePlugin.LogInfo("Skip building process -- no need to build for BP project") + else: + deadlinePlugin.LogInfo("Starting a local build") + + try: + deadlinePlugin.LogInfo("Generating project files...") + deadlinePlugin.SetStatusMessage("Generating project files") + buildtool.GenerateProjectFiles() + except Exception as e: + deadlinePlugin.LogWarning("Caught exception while generating project files. " + str(e)) + deadlinePlugin.FailRender(str(e)) + + try: + deadlinePlugin.LogInfo("Building Engine...") + deadlinePlugin.SetStatusMessage("Building Engine") + buildtool.BuildBuildTargets() + except Exception as e: + deadlinePlugin.LogWarning("Caught exception while building engine. " + str(e)) + deadlinePlugin.FailRender(str(e)) + + + deadlinePlugin.LogInfo("Content successfully synced and engine up to date!") + deadlinePlugin.SetStatusMessage("Content Synced & Engine Up to Date") + deadlinePlugin.SetProgress(100) + diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/LICENSE b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/LICENSE new file mode 100644 index 0000000000..57aadd5d68 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Epic Games + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/PluginPreLoad.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/PluginPreLoad.py new file mode 100644 index 0000000000..20dd93274e --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/PluginPreLoad.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# Copyright Epic Games, Inc. All Rights Reserved + +import sys +from pathlib import Path + + +def __main__(): + # Add the location of the plugin package to the system path so the plugin + # can import supplemental modules if it needs to + plugin_path = Path(__file__) + if plugin_path.parent not in sys.path: + sys.path.append(plugin_path.parent.as_posix()) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.ico b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.ico new file mode 100644 index 0000000000000000000000000000000000000000..e89ce873ce64e5cd8fc3790b91bfc7bf2a6577d6 GIT binary patch literal 894 zcmZWoT}V@582+NmZUiB^?9z)aGLFF(xaBsTZk{dYc5IlKf2mk2C5`GbDUH-dVPs2( zD=<+sTd85N+T6u9V@ut1zba1Ibb=x%{`Jh0O8TDfdwIU+ectc9-}{|I$SFJt2?YKe zDL+lfSwct>CMa?qGtS42k67XvcJ32!dnYX557_Mvo6Q~yg-!&Ih~3@Y;o4B& zaJaCr^ind1mnux5IB7hgFiqt3`se26BoevFWQHDDsJW&2HSolQTP|aS0&zw8ZMC|9 z&l4e0B#_L`&cA=xp}A8Hjc3v$Ov|*{9PH~#iYu=2(~ZWj4!Z-15;1MHT1}>3I227Q zg2AA3%$djJzkQ?Acj%E`bh8xt&jUu6^AGeH8QCCV=zuyeA5FGeta4g8=5TU2sXaZt z#YN@N_xFEc^-`&9YikSoXf&!&Fu0e=Nza9(iwg@22E)e`ip%Bjux(%8fcd9c$QMg9 z|Lu?7W=j=*pC65-D6YQUfK6JOwIZP;D~k>U0yq!Dy*uyi}dB?Wh}>C<1UwL zKN5E+wXCeNuI@2=5SGXBYwp$NsnmmmL$O$lB_ol@;J0C1#Dm&L5U}K-$3gHJ6cv=H z)rHlX`xuHG6>wMAr;Uw`L)kHa(D;OVbkvGrxBp&STRSE=_zwuFNCa*nq~YNa7e{TE N*|jeDe?GpV$X}Y+Df<8b literal 0 HcmV?d00001 diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.options b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.options new file mode 100644 index 0000000000..a125dd5b54 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.options @@ -0,0 +1,59 @@ +[Executable] +Type=String +Label=Unreal Executable +Category=Executable +Index=0 +Description=Unreal Executable +Required=true +Default= +DisableIfBlank=false + +[LoggingDirectory] +Type=string +Label=Command Line Arguments +Category=Executable +Index=2 +Description=What command line arguments should be passed to the executable? +Required=false +DisableIfBlank=false + +[ProjectFile] +Type=String +Label=Project File +Category=Project +Index=0 +Description=The name of the .uproject file ("MyGame.uproject") within the Game folder. +Required=true +Default= +DisableIfBlank=false + +[CommandLineArguments] +Type=string +Label=Command Line Arguments +Category=Project +Index=1 +Description=What command line arguments should be passed to the executable? +Required=false +Default= +DisableIfBlank=false + +[CommandLineMode] +Type=boolean +Label=Command Line Mode +Category=Project +Index=1 +Description=Should the Editor process commands as commandline arguments +Required=false +DisableIfBlank=false +Default=true + +[StartupDirectory] +Type=string +Label=Startup Directory +Category=Command Line Options +CategoryOrder=0 +Index=3 +Description=The directory to start the command line in (leave blank to use default) +Required=false +Default= +DisableIfBlank=true \ No newline at end of file diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.param b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.param new file mode 100644 index 0000000000..c65783d1dc --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.param @@ -0,0 +1,33 @@ +[About] +Type=label +Label=About +Category=About Plugin +CategoryOrder=-1 +Index=0 +Default=Unreal Engine 5 Plugin for Deadline +Description=Not configurable + +[ConcurrentTasks] +Type=label +Label=ConcurrentTasks +Category=About Plugin +CategoryOrder=-1 +Index=0 +Default=True +Description=Not configurable + +[Executable] +Type=String +Label=Executable to test job submission +Category=Options +Index=1 +Default=C:\Windows\System32\cmd.exe +Description=Unreal Executable to process the job + +[RPCWaitTime] +Type=Integer +Label=RPC Process Wait time +Category=Options +Index=2 +Default=300 +Description=The amount of seconds the RPC process should wait for a connection from Unreal diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.py new file mode 100644 index 0000000000..631da4df39 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEngine5.py @@ -0,0 +1,806 @@ +#!/usr/bin/env python3 +# Copyright Epic Games, Inc. All Rights Reserved + +import os +import time +import sys +from datetime import datetime +from pathlib import Path + +from Deadline.Plugins import DeadlinePlugin, PluginType +from FranticX.Processes import ManagedProcess +from Deadline.Scripting import RepositoryUtils, FileUtils, StringUtils + +from DeadlineRPC import ( + DeadlineRPCServerManager, + DeadlineRPCServerThread, + BaseDeadlineRPCJobManager +) + + +def GetDeadlinePlugin(): + """ + Deadline calls this function to get am instance of our class + """ + return UnrealEnginePlugin() + + +def CleanupDeadlinePlugin(deadline_plugin): + """ + Deadline call this function to run any cleanup code + :param deadline_plugin: An instance of the deadline plugin + """ + deadline_plugin.clean_up() + + +class UnrealEnginePlugin(DeadlinePlugin): + """ + Deadline plugin to execute an Unreal Engine job. + NB: This plugin makes no assumptions about what the render job is but has a + few expectations. This plugin runs as a server in the deadline process + and exposes a few Deadline functionalities over XML RPC. The managed process + used by this plugin waits for a client to connect and continuously polls the + RPC server till a task has been marked complete before exiting the + process. This behavior however has a drawback. If for some reason your + process does not mark a task complete after working on a command, + the plugin will run the current task indefinitely until specified to + end by the repository settings or manually. + """ + + def __init__(self): + """ + Constructor + """ + if sys.version_info.major == 3: + super().__init__() + self.InitializeProcessCallback += self._on_initialize_process + self.StartJobCallback += self._on_start_job + self.RenderTasksCallback += self._on_render_tasks + self.EndJobCallback += self._on_end_job + self.MonitoredManagedProcessExitCallback += self._on_process_exit + + # Set the name of the managed process to the current deadline process ID + self._unreal_process_name = f"UnrealEngine_{os.getpid()}" + self.unreal_managed_process = None + + # Keep track of the RPC manager + self._deadline_rpc_manager = None + + # Keep track of when Job Ended has been called + self._job_ended = False + + # set the plugin to commandline mode by default. This will launch the + # editor and wait for the process to exit. There is no communication + # with the deadline process. + self._commandline_mode = True + + def clean_up(self): + """ + Plugin cleanup + """ + del self.InitializeProcessCallback + del self.StartJobCallback + del self.RenderTasksCallback + del self.EndJobCallback + + if self.unreal_managed_process: + self.unreal_managed_process.clean_up() + del self.unreal_managed_process + + del self.MonitoredManagedProcessExitCallback + + def _on_initialize_process(self): + """ + Initialize the plugin + """ + self.LogInfo("Initializing job plugin") + self.SingleFramesOnly = False + self.StdoutHandling = True + self.PluginType = PluginType.Advanced + self._commandline_mode = StringUtils.ParseBoolean( + self.GetPluginInfoEntryWithDefault("CommandLineMode", "true") + ) + + if self._commandline_mode: + self.AddStdoutHandlerCallback( + ".*Progress: (\d+)%.*" + ).HandleCallback += self._handle_progress + self.AddStdoutHandlerCallback( + ".*" + ).HandleCallback += self._handle_stdout + + self.LogInfo("Initialization complete!") + + def _on_start_job(self): + """ + This is executed when the plugin picks up a job + """ + + # Skip if we are in commandline mode + if self._commandline_mode: + return + + self.LogInfo("Executing Start Job") + + # Get and set up the RPC manager for the plugin + self._deadline_rpc_manager = self._setup_rpc_manager() + + # Get a managed process + self.unreal_managed_process = UnrealEngineManagedProcess( + self._unreal_process_name, self, self._deadline_rpc_manager + ) + self.LogInfo("Done executing Start Job") + + def _setup_rpc_manager(self): + """ + Get an RPC manager for the plugin. + """ + self.LogInfo("Setting up RPC Manager") + # Setting the port to `0` will get a random available port for the + # processes to connect on. This will help avoid TIME_WAIT + # issues with the client if the job has to be re-queued + port = 0 + + # Get an instance of the deadline rpc manager class. This class will + # store an instance of this plugin in the python globals. This should + # allow threads in the process to get an instance of the plugin without + # passing the data down through the thread instance + _deadline_rpc_manager = DeadlineRPCServerManager(self, port) + + # We would like to run the server in a thread to not block deadline's + # process. Get the Deadline RPC thread class. Set the class that is + # going to be registered on the server on the thread class + DeadlineRPCServerThread.deadline_job_manager = BaseDeadlineRPCJobManager + + # Set the threading class on the deadline manager + _deadline_rpc_manager.threaded_server_class = DeadlineRPCServerThread + + return _deadline_rpc_manager + + def _on_render_tasks(self): + """ + Execute the render task + """ + # This starts a self-managed process that terminates based on the exit + # code of the process. 0 means success + if self._commandline_mode: + startup_dir = self._get_startup_directory() + + self.unreal_managed_process = UnrealEngineCmdManagedProcess( + self, self._unreal_process_name, startup_dir=startup_dir + ) + + # Auto execute the managed process + self.RunManagedProcess(self.unreal_managed_process) + exit_code = self.unreal_managed_process.ExitCode # type: ignore + + self.LogInfo(f"Process returned: {exit_code}") + + if exit_code != 0: + self.FailRender( + f"Process returned non-zero exit code '{exit_code}'" + ) + + else: + # Flush stdout. This is useful after executing the first task + self.FlushMonitoredManagedProcessStdout(self._unreal_process_name) + + # Start next tasks + self.LogWarning(f"Starting Task {self.GetCurrentTaskId()}") + + # Account for any re-queued jobs. Deadline will immediately execute + # render tasks if a job has been re-queued on the same process. If + # that happens get a new instance of the rpc manager + if not self._deadline_rpc_manager or self._job_ended: + self._deadline_rpc_manager = self._setup_rpc_manager() + + if not self._deadline_rpc_manager.is_started: + + # Start the manager + self._deadline_rpc_manager.start(threaded=True) + + # Get the socket the server is using and expose it to the + # process + server = self._deadline_rpc_manager.get_server() + + _, server_port = server.socket.getsockname() + + self.LogWarning( + f"Starting Deadline RPC Manager on port `{server_port}`" + ) + + # Get the port the server socket is going to use and + # allow other systems to get the port to the rpc server from the + # process environment variables + self.SetProcessEnvironmentVariable( + "DEADLINE_RPC_PORT", str(server_port) + ) + + # Fail if we don't have an instance to a managed process. + # This should typically return true + if not self.unreal_managed_process: + self.FailRender("There is no unreal process Running") + + if not self.MonitoredManagedProcessIsRunning(self._unreal_process_name): + # Start the monitored Process + self.StartMonitoredManagedProcess( + self._unreal_process_name, + self.unreal_managed_process + ) + + self.VerifyMonitoredManagedProcess(self._unreal_process_name) + + # Execute the render task + self.unreal_managed_process.render_task() + + self.LogWarning(f"Finished Task {self.GetCurrentTaskId()}") + self.FlushMonitoredManagedProcessStdout(self._unreal_process_name) + + def _on_end_job(self): + """ + Called when the job ends + """ + if self._commandline_mode: + return + + self.FlushMonitoredManagedProcessStdout(self._unreal_process_name) + self.LogWarning("EndJob called") + self.ShutdownMonitoredManagedProcess(self._unreal_process_name) + + # Gracefully shutdown the RPC manager. This will also shut down any + # threads spun up by the manager + if self._deadline_rpc_manager: + self._deadline_rpc_manager.shutdown() + + # Mark the job as ended. This also helps us to know when a job has + # been re-queued, so we can get a new instance of the RPC manager, + # as Deadline calls End Job when an error occurs + self._job_ended = True + + def _on_process_exit(self): + # If the process ends unexpectedly, make sure we shut down the manager + # gracefully + if self._commandline_mode: + return + + if self._deadline_rpc_manager: + self._deadline_rpc_manager.shutdown() + + def _handle_stdout(self): + """ + Handle stdout + """ + self._deadline_plugin.LogInfo(self.GetRegexMatch(0)) + + def _handle_progress(self): + """ + Handles any progress reports + :return: + """ + progress = float(self.GetRegexMatch(1)) + self.SetProgress(progress) + + def _get_startup_directory(self): + """ + Get startup directory + """ + startup_dir = self.GetPluginInfoEntryWithDefault( + "StartupDirectory", "" + ).strip() + # Get the project root path + project_root = self.GetProcessEnvironmentVariable("ProjectRoot") + + if startup_dir: + if project_root: + startup_dir = startup_dir.format(ProjectRoot=project_root) + + self.LogInfo("Startup Directory: {dir}".format(dir=startup_dir)) + return startup_dir.replace("\\", "/") + + +class UnrealEngineManagedProcess(ManagedProcess): + """ + Process for executing and managing an unreal jobs. + + .. note:: + + Although this process can auto start a batch process by + executing a script on startup, it is VERY important the command + that is executed on startup makes a connection to the Deadline RPC + server. + This will allow Deadline to know a task is running and will wait + until the task is complete before rendering the next one. If this + is not done, Deadline will assume something went wrong with the + process and fail the job after a few minutes. It is also VERY + critical the Deadline process is told when a task is complete, so + it can move on to the next one. See the Deadline RPC manager on how + this communication system works. + The reason for this complexity is, sometimes an unreal project can + take several minutes to load, and we only want to bare the cost of + that load time once between tasks. + + """ + + def __init__(self, process_name, deadline_plugin, deadline_rpc_manager): + """ + Constructor + :param process_name: The name of this process + :param deadline_plugin: An instance of the plugin + :param deadline_rpc_manager: An instance of the rpc manager + """ + if sys.version_info.major == 3: + super().__init__() + self.InitializeProcessCallback += self._initialize_process + self.RenderExecutableCallback += self._render_executable + self.RenderArgumentCallback += self._render_argument + self._deadline_plugin = deadline_plugin + self._deadline_rpc_manager = deadline_rpc_manager + self._temp_rpc_client = None + self._name = process_name + self._executable_path = None + + # Elapsed time to check for connection + self._process_wait_time = int(self._deadline_plugin.GetConfigEntryWithDefault("RPCWaitTime", "300")) + + def clean_up(self): + """ + Called when the plugin cleanup is called + """ + self._deadline_plugin.LogInfo("Executing managed process cleanup.") + # Clean up stdout handler callbacks. + for stdoutHandler in self.StdoutHandlers: + del stdoutHandler.HandleCallback + + del self.InitializeProcessCallback + del self.RenderExecutableCallback + del self.RenderArgumentCallback + self._deadline_plugin.LogInfo("Managed Process Cleanup Finished.") + + def _initialize_process(self): + """ + Called by Deadline to initialize the process. + """ + self._deadline_plugin.LogInfo( + "Executing managed process Initialize Process." + ) + + # Set the ManagedProcess specific settings. + self.PopupHandling = False + self.StdoutHandling = True + + # Set the stdout handlers. + + self.AddStdoutHandlerCallback( + "LogPython: Error:.*" + ).HandleCallback += self._handle_stdout_error + self.AddStdoutHandlerCallback( + "Warning:.*" + ).HandleCallback += self._handle_stdout_warning + + logs_dir = self._deadline_plugin.GetPluginInfoEntryWithDefault( + "LoggingDirectory", "" + ) + + if logs_dir: + + job = self._deadline_plugin.GetJob() + + log_file_dir = os.path.join( + job.JobName, + f"{job.JobSubmitDateTime.ToUniversalTime()}".replace(" ", "-"), + ) + + if not os.path.exists(log_file_dir): + os.makedirs(log_file_dir) + + # If a log directory is specified, this may redirect stdout to the + # log file instead. This is a builtin Deadline behavior + self.RedirectStdoutToFile( + os.path.join( + log_file_dir, + f"{self._deadline_plugin.GetSlaveName()}_{datetime.now()}.log".replace(" ", "-") + ) + ) + + def _handle_std_out(self): + self._deadline_plugin.LogInfo(self.GetRegexMatch(0)) + + # Callback for when a line of stdout contains a WARNING message. + def _handle_stdout_warning(self): + self._deadline_plugin.LogWarning(self.GetRegexMatch(0)) + + # Callback for when a line of stdout contains an ERROR message. + def _handle_stdout_error(self): + self._deadline_plugin.FailRender(self.GetRegexMatch(0)) + + def render_task(self): + """ + Render a task + """ + + # Fail the render is we do not have a manager running + if not self._deadline_rpc_manager: + self._deadline_plugin.FailRender("No rpc manager was running!") + + # Start a timer to monitor the process time + start_time = time.time() + + # Get temp client connection + if not self._temp_rpc_client: + self._temp_rpc_client = self._deadline_rpc_manager.get_temporary_client_proxy() + + + print("Is server and client connected?", self._temp_rpc_client.is_connected()) + + # Make sure we have a manager running, and we can establish a connection + if not self._temp_rpc_client.is_connected(): + # Wait for a connection. This polls the server thread till an + # unreal process client has connected. It is very important that + # a connection is established by the client to allow this process + # to execute. + while round(time.time() - start_time) <= self._process_wait_time: + try: + # keep checking to see if a client has connected + if self._temp_rpc_client.is_connected(): + self._deadline_plugin.LogInfo( + "Client connection established!!" + ) + break + except Exception: + pass + + self._deadline_plugin.LogInfo("Waiting on client connection..") + self._deadline_plugin.FlushMonitoredManagedProcessStdout( + self._name + ) + time.sleep(2) + else: + + # Fail the render after waiting too long + self._deadline_plugin.FailRender( + "A connection was not established with an unreal process" + ) + + # if we are connected, wait till the process task is marked as + # complete. + while not self._temp_rpc_client.is_task_complete( + self._deadline_plugin.GetCurrentTaskId() + ): + # Keep flushing stdout + self._deadline_plugin.FlushMonitoredManagedProcessStdout(self._name) + + # Flush one last time + self._deadline_plugin.FlushMonitoredManagedProcessStdout(self._name) + + def _render_executable(self): + """ + Get the render executable + """ + self._deadline_plugin.LogInfo("Setting up Render Executable") + + executable = self._deadline_plugin.GetEnvironmentVariable("UnrealExecutable") + + if not executable: + executable = self._deadline_plugin.GetPluginInfoEntry("Executable") + + # Resolve any path mappings required + executable = RepositoryUtils.CheckPathMapping(executable) + + project_root = self._deadline_plugin.GetEnvironmentVariable("ProjectRoot") + + # If a project root is specified in the environment, it is assumed a + # previous process resolves the root location of the executable and + # presents it in the environment. + if project_root: + # Resolve any `{ProjectRoot}` tokens present in the executable path + executable = executable.format(ProjectRoot=project_root) + + # Make sure the executable exists + if not FileUtils.FileExists(executable): + self._deadline_plugin.FailRender(f"Could not find `{executable}`") + + self._executable_path = executable.replace("\\", "/") + + self._deadline_plugin.LogInfo(f"Found executable `{executable}`") + + return self._executable_path + + def _render_argument(self): + """ + Get the arguments to startup unreal + """ + self._deadline_plugin.LogInfo("Settifdfdsfsdfsfsfasng UP Render Arguments") + + # Look for any unreal uproject paths in the process environment. This + # assumes a previous process resolves a uproject path and makes it + # available. + uproject = self._deadline_plugin.GetEnvironmentVariable("UnrealUProject") + + if not uproject: + uproject = self._deadline_plugin.GetPluginInfoEntry("ProjectFile") + self._deadline_plugin.LogInfo(f"hhhh") + # Get any path mappings required. Expects this to be a full path + uproject = RepositoryUtils.CheckPathMapping(uproject) + + # Get the project root path + project_root = self._deadline_plugin.GetEnvironmentVariable("ProjectRoot") + + # Resolve any `{ProjectRoot}` tokens in the environment + if project_root: + uproject = uproject.format(ProjectRoot=project_root) + + uproject = Path(uproject.replace("\\", "/")) + self._deadline_plugin.LogInfo(f"Suproject:: `{uproject}`") + # Check to see if the Uproject is a relative path + if str(uproject).replace("\\", "/").startswith("../"): + + if not self._executable_path: + self._deadline_plugin.FailRender("Could not find executable path to resolve relative path.") + + # Find executable root + import re + engine_dir = re.findall("([\s\S]*.Engine)", self._executable_path) + if not engine_dir: + self._deadline_plugin.FailRender("Could not find executable Engine directory.") + + executable_root = Path(engine_dir[0]).parent + + # Resolve editor relative paths + found_paths = sorted(executable_root.rglob(str(uproject).replace("\\", "/").strip("../"))) + + if not found_paths or len(found_paths) > 1: + self._deadline_plugin.FailRender( + f"Found multiple uprojects relative to the root directory. There should only be one when a relative path is defined." + ) + + uproject = found_paths[0] + + # make sure the project exists + if not FileUtils.FileExists(uproject.as_posix()): + self._deadline_plugin.FailRender(f"Could not find `{uproject.as_posix()}`") + self._deadline_plugin.GetPluginInfoEntryWithDefault("CommandLineArguments", "") + # Set up the arguments to startup unreal. + job_command_args = [ + '"{u_project}"'.format(u_project=uproject.as_posix()), + cmd_args, + # Force "-log" otherwise there is no output from the executable + "-log", + "-unattended", + "-stdout", + "-allowstdoutlogverbosity", + ] + + arguments = " ".join(job_command_args) + self._deadline_plugin.LogInfo(f"Startup Arguments: `{arguments}`") + + return arguments + + +class UnrealEngineCmdManagedProcess(ManagedProcess): + """ + Process for executing unreal over commandline + """ + + def __init__(self, deadline_plugin, process_name, startup_dir=""): + """ + Constructor + :param process_name: The name of this process + """ + if sys.version_info.major == 3: + super().__init__() + self._deadline_plugin = deadline_plugin + self._name = process_name + self.ExitCode = -1 + self._startup_dir = startup_dir + self._executable_path = None + + self.InitializeProcessCallback += self._initialize_process + self.RenderExecutableCallback += self._render_executable + self.RenderArgumentCallback += self._render_argument + self.CheckExitCodeCallback += self._check_exit_code + self.StartupDirectoryCallback += self._startup_directory + + def clean_up(self): + """ + Called when the plugin cleanup is called + """ + self._deadline_plugin.LogInfo("Executing managed process cleanup.") + # Clean up stdout handler callbacks. + for stdoutHandler in self.StdoutHandlers: + del stdoutHandler.HandleCallback + + del self.InitializeProcessCallback + del self.RenderExecutableCallback + del self.RenderArgumentCallback + del self.CheckExitCodeCallback + del self.StartupDirectoryCallback + self._deadline_plugin.LogInfo("Managed Process Cleanup Finished.") + + def _initialize_process(self): + """ + Called by Deadline to initialize the process. + """ + self._deadline_plugin.LogInfo( + "Executing managed process Initialize Process." + ) + + # Set the ManagedProcess specific settings. + self.PopupHandling = True + self.StdoutHandling = True + self.HideDosWindow = True + + # Ensure child processes are killed and the parent process is + # terminated on exit + self.UseProcessTree = True + self.TerminateOnExit = True + + shell = self._deadline_plugin.GetPluginInfoEntryWithDefault("Shell", "") + + if shell: + self._shell = shell + + self.AddStdoutHandlerCallback( + ".*Progress: (\d+)%.*" + ).HandleCallback += self._handle_progress + + # self.AddStdoutHandlerCallback("LogPython: Error:.*").HandleCallback += self._handle_stdout_error + + # Get the current frames for the task + current_task_frames = self._deadline_plugin.GetCurrentTask().TaskFrameString + + # Set the frames sting as an environment variable + self.SetEnvironmentVariable("CURRENT_RENDER_FRAMES", current_task_frames) + + def _handle_stdout_error(self): + """ + Callback for when a line of stdout contains an ERROR message. + """ + self._deadline_plugin.FailRender(self.GetRegexMatch(0)) + + def _check_exit_code(self, exit_code): + """ + Returns the process exit code + :param exit_code: + :return: + """ + self.ExitCode = exit_code + + def _startup_directory(self): + """ + Startup directory + """ + return self._startup_dir + + def _handle_progress(self): + """ + Handles progress reports + """ + progress = float(self.GetRegexMatch(1)) + self._deadline_plugin.SetProgress(progress) + + def _render_executable(self): + """ + Get the render executable + """ + + self._deadline_plugin.LogInfo("Setting up Render Executable") + + executable = self._deadline_plugin.GetEnvironmentVariable("UnrealExecutable") + + if not executable: + executable = self._deadline_plugin.GetPluginInfoEntry("Executable") + + # Get the executable from the plugin + executable = RepositoryUtils.CheckPathMapping(executable) + # Get the project root path + project_root = self._deadline_plugin.GetProcessEnvironmentVariable( + "ProjectRoot" + ) + + # Resolve any `{ProjectRoot}` tokens in the environment + if project_root: + executable = executable.format(ProjectRoot=project_root) + + if not FileUtils.FileExists(executable): + self._deadline_plugin.FailRender( + "{executable} could not be found".format(executable=executable) + ) + + # TODO: Setup getting executable from the config as well + + self._deadline_plugin.LogInfo( + "Render Executable: {exe}".format(exe=executable) + ) + self._executable_path = executable.replace("\\", "/") + + return self._executable_path + + def _render_argument(self): + """ + Get the arguments to startup unreal + :return: + """ + self._deadline_plugin.LogInfo("Setting up Render Arguments") + + # Look for any unreal uproject paths in the process environment. This + # assumes a previous process resolves a uproject path and makes it + # available. + project_file = self._deadline_plugin.GetEnvironmentVariable("UnrealUProject") + + if not project_file: + project_file = self._deadline_plugin.GetPluginInfoEntry("ProjectFile") + + # Get any path mappings required. Expects this to be a full path + project_file = RepositoryUtils.CheckPathMapping(project_file) + + # Get the project root path + project_root = self._deadline_plugin.GetProcessEnvironmentVariable( + "ProjectRoot" + ) + + # Resolve any `{ProjectRoot}` tokens in the environment + if project_root: + project_file = project_file.format(ProjectRoot=project_root) + + if not project_file: + self._deadline_plugin.FailRender( + f"Expected project file but found `{project_file}`" + ) + + project_file = Path(project_file.replace("\u201c", '"').replace( + "\u201d", '"' + ).replace("\\", "/")) + + # Check to see if the Uproject is a relative path + if str(project_file).replace("\\", "/").startswith("../"): + + if not self._executable_path: + self._deadline_plugin.FailRender("Could not find executable path to resolve relative path.") + + # Find executable root + import re + engine_dir = re.findall("([\s\S]*.Engine)", self._executable_path) + if not engine_dir: + self._deadline_plugin.FailRender("Could not find executable Engine directory.") + + executable_root = Path(engine_dir[0]).parent + + # Resolve editor relative paths + found_paths = sorted(executable_root.rglob(str(project_file).replace("\\", "/").strip("../"))) + + if not found_paths or len(found_paths) > 1: + self._deadline_plugin.FailRender( + f"Found multiple uprojects relative to the root directory. There should only be one when a relative path is defined." + ) + + project_file = found_paths[0] + self._deadline_plugin.LogInfo(f"project_file:: `{project_file}`") + # make sure the project exists + if not FileUtils.FileExists(project_file.as_posix()): + self._deadline_plugin.FailRender(f"Could not find `{project_file.as_posix()}`") + + # Get the render arguments + args = RepositoryUtils.CheckPathMapping( + self._deadline_plugin.GetPluginInfoEntry( + "CommandLineArguments" + ).strip() + ) + + args = args.replace("\u201c", '"').replace("\u201d", '"') + + startup_args = " ".join( + [ + '"{u_project}"'.format(u_project=project_file.as_posix()), + args, + "-log", + "-unattended", + "-stdout", + "-allowstdoutlogverbosity", + ] + ) + + self._deadline_plugin.LogInfo( + "Render Arguments: {args}".format(args=startup_args) + ) + + return startup_args diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/BaseMoviePipelineDeadline.ini b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/BaseMoviePipelineDeadline.ini new file mode 100644 index 0000000000..cde689a963 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/BaseMoviePipelineDeadline.ini @@ -0,0 +1,12 @@ +[CoreRedirects] ++PackageRedirects=(OldName="/Script/MoviePipelineDeadlineIntegration", NewName="/Script/MoviePipelineIntegration", MatchSubstring=true) ++PackageRedirects=(OldName="/Script/MoviePipelineIntegration", NewName="/Script/MoviePipelineDeadline", MatchSubstring=true) ++ClassRedirects=(OldName="/Script/MoviePipelineDeadlineIntegration.MoviePipelineDeadlineJob", NewName="/Script/MoviePipelineIntegration.DeadlineMoviePipelineExecutorJob") ++ClassRedirects=(OldName="/Script/MoviePipelineIntegration.DeadlineMoviePipelineExecutorJob", NewName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineExecutorJob") ++ClassRedirects=(OldName="/Script/MoviePipelineIntegration.DeadlineMoviePipelineIntegrationSettings", NewName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineSettings") ++ClassRedirects=(OldName="/Engine/PythonTypes.MoviePipelineEditorDeadlineExecutor", NewName="/Engine/PythonTypes.MoviePipelineDeadlineEditorRenderExecutor") ++FunctionRedirects=(OldName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineExecutorJob.GetDeadlineJobInfoStructWithOverridesIfApplicable",NewName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineExecutorJob.GetDeadlineJobPresetStructWithOverridesIfApplicable") ++PropertyRedirects=(OldName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineExecutorJob.PresetLibrary",NewName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineExecutorJob.JobPreset") ++PropertyRedirects=(OldName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineSettings.DefaultPresetLibrary",NewName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineSettings.DefaultJobPreset") ++PropertyRedirects=(OldName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineSettings.JobInfoPropertiesToHideInMovieRenderQueue",NewName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineSettings.JobPresetPropertiesToHideInMovieRenderQueue") ++FunctionRedirects=(OldName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineExecutorJob.GetDeadlineJobPresetStructWithOverridesIfApplicable",NewName="/Script/MoviePipelineDeadline.MoviePipelineDeadlineExecutorJob.GetDeadlineJobPresetStructWithOverrides") diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/FilterPlugin.ini b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/FilterPlugin.ini new file mode 100644 index 0000000000..ccebca2f32 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Config/FilterPlugin.ini @@ -0,0 +1,8 @@ +[FilterPlugin] +; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and +; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively. +; +; Examples: +; /README.txt +; /Extras/... +; /Binaries/ThirdParty/*.dll diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/init_unreal.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/init_unreal.py new file mode 100644 index 0000000000..e78c82381f --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/init_unreal.py @@ -0,0 +1,42 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Built-in +import sys +from pathlib import Path + +# Third-party +import unreal + +import remote_executor +import mrq_cli + +plugin_name = "MoviePipelineDeadline" + + +# Add the actions path to sys path +actions_path = Path(__file__).parent.joinpath("pipeline_actions").as_posix().lower() + +if actions_path not in sys.path: + sys.path.append(actions_path) + +from pipeline_actions import render_queue_action + +# Register the menu from the render queue actions +render_queue_action.register_menu_action() + +# The asset registry may not be fully loaded by the time this is called, +# warn the user that attempts to look assets up may fail +# unexpectedly. +# Look for a custom commandline start key `-waitonassetregistry`. This key +# is used to trigger a synchronous wait on the asset registry to complete. +# This is useful in commandline states where you explicitly want all assets +# loaded before continuing. +asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() +if asset_registry.is_loading_assets() and ("-waitonassetregistry" in unreal.SystemLibrary.get_command_line().split()): + unreal.log_warning( + f"Asset Registry is still loading. The {plugin_name} plugin will " + f"be loaded after the Asset Registry is complete." + ) + + asset_registry.wait_for_completion() + unreal.log(f"Asset Registry is complete. Loading {plugin_name} plugin.") diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli.py new file mode 100644 index 0000000000..2dce8d09bf --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli.py @@ -0,0 +1,228 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +This is a commandline script that can be used to execute local and remote renders from Unreal. +This script can be executed in Editor or via commandline. + +This script has several modes: + + manifest: + This mode allows you to specify a full path to manifest file and a queue will be created from the manifest. + + Command: + .. code-block:: shell + + $ py mrq_cli.py manifest "Full/Path/To/Manifest.utxt" + Options: + *--load*: This allows you to only load the manifest file without executing a render. + + sequence: + This mode allows you to specify a specific level sequence, map and movie render queue preset to render. + + Command: + .. code-block:: shell + + $ py mrq_cli.py sequence my_level_sequence_name my_map_name my_mrq_preset_name + + queue: + This mode allows you to load and render a queue asset. + + Command: + .. code-block:: shell + + $ py mrq_cli.py queue "/Game/path/to/queue/asset" + Options: + *--load*: This allows you to only load the queue asset without executing a render. + + *--jobs*: A queue can have more than one job. This allows you to specify particular jobs in the queue and render its current state + + render: + This mode allows you to render the jobs in the current loaded queue. This is useful when you what to execute + renders in multi steps. For example, executing in a farm context, you can load a manifest file and trigger + multiple different shots for the current worker machine based on some database without reloading the + manifest file everytime. By default, the queue is rendered in its current state if no other arguments are + specified. + + Command: + .. code-block:: shell + + $ py mrq_cli.py render + Options: + *--jobs*: The current queue can have more than one job. This allows you to specify a particular list of jobs in the queue and render in its current state + + +**Optional Arguments**: + + There a few optional arguments that can be supplied to the script and are global to the modes + + *--shots*: This option allows you to specify a list of shots to render in the queue. This optional argument can be used with both modes of the script. + + *--all-shots*: This options enables all shots on all jobs. This is useful when you want to render everything in a queue. + + *--user*: This options sets the author on the render job. If None is provided, the current logged-in user is used. + + *--remote/-r*: This option submits the render to a remote process. This remote process is whatever is set in the + MRQ remote executor option. This script is targeted for Deadline. However, it can still support + the default "Out-of-Process" executor. This flag can be used with both modes of the script. + When specifying a remote command for deadline, you'll need to also supply these commands: + + *--batch_name*: This sets the batch name on the executor. + + *--deadline_job_preset*: The deadline preset for Deadline job/plugin info + + +Editor CMD window: + .. code-block:: shell + + $ py mrq_cli.py <--remote> sequence sequence_name map mrq_preset_name + +Editor Commandline: + .. code-block:: shell + + UnrealEditor.exe uproject_name/path -execcmds="py mrq_cli.py sequence sequence_name map mrq_preset_name --cmdline" + +In a commandline interface, it is very important to append `--cmdline` to the script args as this will tell the editor +to shut down after a render is complete. Currently, this is the only method to keep the editor open till a render is +complete due to the default python commandlet assuming when a python script ends, the editor needs to shut down. +This behavior is not ideal as PIE is an asynchronous process we need to wait for during rendering. +""" + +import argparse + +from mrq_cli_modes import render_manifest, render_sequence, render_queue, render_queue_jobs + + +if __name__ == "__main__": + + # A parser to hold all arguments we want available on sub parsers. + global_parser = argparse.ArgumentParser( + description="This parser contains any global arguments we would want available on subparsers", + add_help=False + ) + # Determine if the editor was run from a commandline + global_parser.add_argument( + "--cmdline", + action="store_true", + help="Flag for noting execution from commandline. " + "This will shut the editor down after a render is complete or failed." + ) + + global_parser.add_argument( + "-u", + "--user", + type=str, + help="The user the render job will be submitted as." + ) + + # Group the flags for remote rendering. This is just a conceptual group + # and not a logical group. It is mostly shown in the help interface + remote_group = global_parser.add_argument_group("remote") + + # Determine if this is a local render or a remote render. If the remote + # flag is present, it's a remote render + remote_group.add_argument( + "-r", + "--remote", + action="store_true", + help="Determines if the render should be executed remotely." + ) + + # Option flag for remote renders. This will fail if not provided along + # with the --remote flag + remote_group.add_argument( + "--batch_name", + type=str, + help="The batch render name for the current remote render job." + ) + + remote_group.add_argument( + "--deadline_job_preset", + help="The remote job preset to use when rendering the current job." + ) + + # Setup output override groups for the jobs + output_group = global_parser.add_argument_group("output") + output_group.add_argument( + "--output_override", + type=str, + help="Output folder override for the queue asset" + ) + output_group.add_argument( + "--filename_override", + type=str, + help="Filename override for the queue asset" + ) + + # Shots group + shots_group = global_parser.add_argument_group("shots") + # Shots to render in a Queue + shots_group.add_argument( + "-s", + "--shots", + type=str, + nargs="+", + help="A list of shots to render in the level sequence. " + "If no shots are provided, all shots in the level sequence will be rendered." + ) + shots_group.add_argument( + "--all-shots", + action="store_true", + help="Render all shots in the queue. This will enable all shots for all jobs." + ) + + # Create the main entry parser + parser = argparse.ArgumentParser( + prog="PYMoviePipelineCLI", + description="Commandline Interface for rendering MRQ jobs" + ) + + # Create sub commands + sub_commands = parser.add_subparsers(help="Sub-commands help") + + # Create a sub command for rendering with a manifest file + manifest_parser = sub_commands.add_parser( + "manifest", + parents=[global_parser], + help="Command to load and render queue from a manifest file." + ) + + # Add arguments for the manifest parser + render_manifest.setup_manifest_parser(manifest_parser) + + # Create a sub command used to render a specific sequence with a map and + # mrq preset + sequence_parser = sub_commands.add_parser( + "sequence", + parents=[global_parser], + help="Command to render a specific sequence, map, and mrq preset." + ) + + # Add arguments for the sequence parser + render_sequence.setup_sequence_parser(sequence_parser) + + # Setup a parser for rendering a specific queue asset + queue_parser = sub_commands.add_parser( + "queue", + parents=[global_parser], + help="Command to render a movie pipeline queue." + ) + + # Add arguments for the queue parser + render_queue.setup_queue_parser(queue_parser) + + # Add arguments for the rendering the current queue + render_parser = sub_commands.add_parser( + "render", + parents=[global_parser], + help="Command to render the current loaded render queue." + ) + + # Add arguments for the queue parser + render_queue_jobs.setup_render_parser(render_parser) + + # Process the args using the argument execution functions. + # Parse known arguments returns a tuple of arguments that are recognized + # and others. Get the recognized arguments and execute their set defaults + args, _ = parser.parse_known_args() + print(args) + args.func(args) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/__init__.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/__init__.py new file mode 100644 index 0000000000..eef4820d47 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/__init__.py @@ -0,0 +1,15 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +from .render_manifest import render_queue_manifest +from .render_queue import render_queue_asset +from .render_queue_jobs import render_jobs +from .render_sequence import render_current_sequence +from . import utils + +__all__ = [ + "render_jobs", + "render_queue_manifest", + "render_queue_asset", + "render_current_sequence", + "utils", +] diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_manifest.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_manifest.py new file mode 100644 index 0000000000..26bbb66b72 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_manifest.py @@ -0,0 +1,165 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +This script handles processing manifest files for rendering in MRQ +""" + +import unreal +from getpass import getuser +from pathlib import Path + +from .render_queue_jobs import render_jobs +from .utils import movie_pipeline_queue + + +def setup_manifest_parser(subparser): + """ + This method adds a custom execution function and args to a subparser + + :param subparser: Subparser for processing manifest files + """ + # Movie pipeline manifest file from disk + subparser.add_argument( + "manifest", type=Path, help="Full local path to a MRQ manifest file." + ) + + # Add option to only load the contents of the manifest file. By default, + # this will render after loading the manifest file + subparser.add_argument( + "--load", + action="store_true", + help="Only load the contents of the manifest file. " + "By default the manifest will be loaded and rendered.", + ) + + # Function to process arguments + subparser.set_defaults(func=_process_args) + + +def render_queue_manifest( + manifest, + load_only=False, + shots=None, + user=None, + is_remote=False, + is_cmdline=False, + remote_batch_name=None, + remote_job_preset=None, + executor_instance=None, + output_dir_override=None, + output_filename_override=None +): + """ + Function to execute a render using a manifest file + + :param str manifest: Manifest file to render + :param bool load_only: Only load the manifest file + :param list shots: Shots to render from the queue + :param str user: Render user + :param bool is_remote: Flag to determine if the jobs should be rendered remote + :param bool is_cmdline: Flag to determine if the job is a commandline job + :param str remote_batch_name: Batch name for remote renders + :param str remote_job_preset: Remote render preset library + :param executor_instance: Movie Pipeline executor instance + :param str output_dir_override: Movie Pipeline output directory override + :param str output_filename_override: Movie Pipeline filename format override + :return: MRQ Executor + """ + # The queue subsystem behaves like a singleton so + # clear all the jobs in the current queue. + movie_pipeline_queue.delete_all_jobs() + + # Manifest args returns a Pathlib object, get the results as a string and + # load the manifest + movie_pipeline_queue.copy_from( + unreal.MoviePipelineLibrary.load_manifest_file_from_string(manifest) + ) + + # If we only want to load the manifest file, then exit after loading + # the manifest. + # If we want to shut down the editor as well, then do so + if load_only: + + if is_cmdline: + unreal.SystemLibrary.quit_editor() + + return None + + # Manifest files are a per job configuration. So there should only be one + # job in a manifest file + + # Todo: Make sure there are always only one job in the manifest file + if movie_pipeline_queue.get_jobs(): + render_job = movie_pipeline_queue.get_jobs()[0] + else: + raise RuntimeError("There are no jobs in the queue!!") + + # MRQ added the ability to enable and disable jobs. Check to see if a job + # is disabled and enable it. + # The assumption is we want to render this particular job. + # Note this try except block is for backwards compatibility + try: + if not render_job.enabled: + render_job.enabled = True + except AttributeError: + pass + + # Set the author on the job + render_job.author = user or getuser() + + # If we have a shot list, iterate over the shots in the sequence + # and disable anything that's not in the shot list. If no shot list is + # provided render all the shots in the sequence + if shots: + for shot in render_job.shot_info: + if shot.inner_name in shots or (shot.outer_name in shots): + shot.enabled = True + else: + unreal.log_warning( + f"Disabling shot `{shot.inner_name}` from current render job `{render_job.job_name}`" + ) + shot.enabled = False + + try: + # Execute the render. + # This will execute the render based on whether its remote or local + executor = render_jobs( + is_remote, + remote_batch_name=remote_batch_name, + remote_job_preset=remote_job_preset, + executor_instance=executor_instance, + is_cmdline=is_cmdline, + output_dir_override=output_dir_override, + output_filename_override=output_filename_override + ) + + except Exception as err: + unreal.log_error( + f"An error occurred executing the render.\n\tError: {err}" + ) + raise + + return executor + + +def _process_args(args): + """ + Function to process the arguments for the manifest subcommand + :param args: Parsed Arguments from parser + """ + # This is a Path object + # Convert to string representation + manifest = args.manifest.as_posix() + + return render_queue_manifest( + manifest, + load_only=args.load, + shots=args.shots, + user=args.user, + is_remote=args.remote, + is_cmdline=args.cmdline, + remote_batch_name=args.batch_name, + remote_job_preset=args.deadline_job_preset, + output_dir_override=args.output_override, + output_filename_override=args.filename_override + ) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue.py new file mode 100644 index 0000000000..8c66293040 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue.py @@ -0,0 +1,155 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +This script handles processing jobs for a specific queue asset +""" +import unreal + +from .render_queue_jobs import render_jobs +from .utils import ( + get_asset_data, + movie_pipeline_queue, + update_queue +) + + +def setup_queue_parser(subparser): + """ + This method adds a custom execution function and args to a queue subparser + + :param subparser: Subparser for processing custom sequences + """ + # Set the name of the job + subparser.add_argument( + "queue", + type=str, + help="The name or path to a movie pipeline queue." + ) + + # Add option to only load the contents of the queue. By default, + # this will only load the queue and render its contents + subparser.add_argument( + "--load", + action="store_true", + help="Load the contents of the queue asset. By default the queue asset will loaded and render its contents.", + ) + + # We will use the level sequence and the map as our context for + # other subsequence arguments. + subparser.add_argument( + "--jobs", + type=str, + nargs="+", + help="A list of jobs to execute in the queue. " + "If no jobs are provided, all jobs in the queue will be rendered.", + ) + + # Function to process arguments + subparser.set_defaults(func=_process_args) + + +def render_queue_asset( + queue_name, + only_load=False, + shots=None, + jobs=None, + all_shots=False, + is_cmdline=False, + is_remote=False, + user=None, + remote_batch_name=None, + remote_job_preset=None, + executor_instance=None, + output_dir_override=None, + output_filename_override=None +): + """ + Render using a Movie Render Queue asset + + :param str queue_name: The name of the Queue asset + :param bool only_load: Only load the queue asset. This is usually used when you need to process intermediary steps before rendering + :param list shots: Shots to render from the queue. + :param list jobs: The list job to render in the Queue asset. + :param bool all_shots: Flag to render all shots in a job in the queue. + :param bool is_cmdline: Flag to determine if the job is a commandline job + :param bool is_remote: Flag to determine if the jobs should be rendered remote + :param str user: Render user + :param str remote_batch_name: Batch name for remote renders + :param str remote_job_preset: Remote render job preset + :param executor_instance: Movie Pipeline executor instance + :param str output_dir_override: Movie Pipeline output directory override + :param str output_filename_override: Movie Pipeline filename format override + :return: MRQ Executor + """ + + # The queue subsystem behaves like a singleton so + # clear all the jobs in the current queue. + movie_pipeline_queue.delete_all_jobs() + + # Get the queue data asset package path by name or by path + # Create a new queue from the queue asset + movie_pipeline_queue.copy_from( + get_asset_data(queue_name, "MoviePipelineQueue").get_asset() + ) + + # If we only want to load the queue asset, then exit after loading. + # If we want to shut down the editor as well, then do so + if only_load: + + if is_cmdline: + unreal.SystemLibrary.quit_editor() + + return None + + if not movie_pipeline_queue.get_jobs(): + # Make sure we have jobs in the queue to work with + raise RuntimeError("There are no jobs in the queue!!") + + # Allow executing the render queue in its current loaded state + if all_shots or (any([shots, jobs])): + update_queue( + jobs=jobs, + shots=shots, + all_shots=all_shots, + user=user + ) + + try: + # Execute the render. This will execute the render based on whether + # its remote or local + executor = render_jobs( + is_remote, + remote_batch_name=remote_batch_name, + remote_job_preset=remote_job_preset, + is_cmdline=is_cmdline, + executor_instance=executor_instance, + output_dir_override=output_dir_override, + output_filename_override=output_filename_override + ) + + except Exception: + raise + + return executor + + +def _process_args(args): + """ + Function to process the arguments for the sequence subcommand + :param args: Parsed Arguments from parser + """ + + return render_queue_asset( + args.queue, + only_load=args.load, + shots=args.shots, + jobs=args.jobs, + all_shots=args.all_shots, + is_remote=args.remote, + is_cmdline=args.cmdline, + user=args.user, + remote_batch_name=args.batch_name, + remote_job_preset=args.deadline_job_preset, + output_dir_override=args.output_override, + output_filename_override=args.filename_override + ) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue_jobs.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue_jobs.py new file mode 100644 index 0000000000..de1665d5aa --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_queue_jobs.py @@ -0,0 +1,112 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +This script handles processing jobs and shots in the current loaded queue +""" +import unreal + +from .utils import ( + movie_pipeline_queue, + execute_render, + setup_remote_render_jobs, + update_render_output +) + + +def setup_render_parser(subparser): + """ + This method adds a custom execution function and args to a render subparser + + :param subparser: Subparser for processing custom sequences + """ + + # Function to process arguments + subparser.set_defaults(func=_process_args) + + +def render_jobs( + is_remote=False, + is_cmdline=False, + executor_instance=None, + remote_batch_name=None, + remote_job_preset=None, + output_dir_override=None, + output_filename_override=None +): + """ + This renders the current state of the queue + + :param bool is_remote: Is this a remote render + :param bool is_cmdline: Is this a commandline render + :param executor_instance: Movie Pipeline Executor instance + :param str remote_batch_name: Batch name for remote renders + :param str remote_job_preset: Remote render job preset + :param str output_dir_override: Movie Pipeline output directory override + :param str output_filename_override: Movie Pipeline filename format override + :return: MRQ executor + """ + + if not movie_pipeline_queue.get_jobs(): + # Make sure we have jobs in the queue to work with + raise RuntimeError("There are no jobs in the queue!!") + + # Update the job + for job in movie_pipeline_queue.get_jobs(): + + # If we have output job overrides and filename overrides, update it on + # the job + if output_dir_override or output_filename_override: + update_render_output( + job, + output_dir=output_dir_override, + output_filename=output_filename_override + ) + + # Get the job output settings + output_setting = job.get_configuration().find_setting_by_class( + unreal.MoviePipelineOutputSetting + ) + + # Allow flushing flies to disk per shot. + # Required for the OnIndividualShotFinishedCallback to get called. + output_setting.flush_disk_writes_per_shot = True + + if is_remote: + setup_remote_render_jobs( + remote_batch_name, + remote_job_preset, + movie_pipeline_queue.get_jobs(), + ) + + try: + # Execute the render. + # This will execute the render based on whether its remote or local + executor = execute_render( + is_remote, + executor_instance=executor_instance, + is_cmdline=is_cmdline, + ) + + except Exception as err: + unreal.log_error( + f"An error occurred executing the render.\n\tError: {err}" + ) + raise + + return executor + + +def _process_args(args): + """ + Function to process the arguments for the sequence subcommand + :param args: Parsed Arguments from parser + """ + + return render_jobs( + is_remote=args.remote, + is_cmdline=args.cmdline, + remote_batch_name=args.batch_name, + remote_job_preset=args.deadline_job_preset, + output_dir_override=args.output_override, + output_filename_override=args.filename_override + ) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_sequence.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_sequence.py new file mode 100644 index 0000000000..4348cec820 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/render_sequence.py @@ -0,0 +1,177 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +This script handles processing jobs for a specific sequence +""" + +import unreal +from getpass import getuser + +from .render_queue_jobs import render_jobs +from .utils import ( + movie_pipeline_queue, + project_settings, + get_asset_data +) + + +def setup_sequence_parser(subparser): + """ + This method adds a custom execution function and args to a sequence subparser + + :param subparser: Subparser for processing custom sequences + """ + # We will use the level sequence and the map as our context for + # other subsequence arguments. + subparser.add_argument( + "sequence", type=str, help="The level sequence that will be rendered." + ) + subparser.add_argument( + "map", + type=str, + help="The map the level sequence will be loaded with for rendering.", + ) + + # Get some information for the render queue + subparser.add_argument( + "mrq_preset", + type=str, + help="The MRQ preset used to render the current job.", + ) + + # Function to process arguments + subparser.set_defaults(func=_process_args) + + +def render_current_sequence( + sequence_name, + sequence_map, + mrq_preset, + user=None, + shots=None, + is_remote=False, + is_cmdline=False, + remote_batch_name=None, + remote_job_preset=None, + executor_instance=None, + output_dir_override=None, + output_filename_override=None +): + """ + Renders a sequence with a map and mrq preset + + :param str sequence_name: Sequence to render + :param str sequence_map: Map to load sequence + :param str mrq_preset: MRQ preset for rendering sequence + :param str user: Render user + :param list shots: Shots to render + :param bool is_remote: Flag to determine if the job should be executed remotely + :param bool is_cmdline: Flag to determine if the render was executed via commandline + :param str remote_batch_name: Remote render batch name + :param str remote_job_preset: deadline job Preset Library + :param executor_instance: Movie Pipeline executor Instance + :param str output_dir_override: Movie Pipeline output directory override + :param str output_filename_override: Movie Pipeline filename format override + :return: MRQ executor + """ + + # The queue subsystem behaves like a singleton so + # clear all the jobs in the current queue. + movie_pipeline_queue.delete_all_jobs() + + render_job = movie_pipeline_queue.allocate_new_job( + unreal.SystemLibrary.conv_soft_class_path_to_soft_class_ref( + project_settings.default_executor_job + ) + ) + + # Set the author on the job + render_job.author = user or getuser() + + sequence_data_asset = get_asset_data(sequence_name, "LevelSequence") + + # Create a job in the queue + unreal.log(f"Creating render job for `{sequence_data_asset.asset_name}`") + render_job.job_name = sequence_data_asset.asset_name + + unreal.log( + f"Setting the job sequence to `{sequence_data_asset.asset_name}`" + ) + render_job.sequence = sequence_data_asset.to_soft_object_path() + + map_data_asset = get_asset_data(sequence_map, "World") + unreal.log(f"Setting the job map to `{map_data_asset.asset_name}`") + render_job.map = map_data_asset.to_soft_object_path() + + mrq_preset_data_asset = get_asset_data( + mrq_preset, "MoviePipelineMasterConfig" + ) + unreal.log( + f"Setting the movie pipeline preset to `{mrq_preset_data_asset.asset_name}`" + ) + render_job.set_configuration(mrq_preset_data_asset.get_asset()) + + # MRQ added the ability to enable and disable jobs. Check to see is a job + # is disabled and enable it. The assumption is we want to render this + # particular job. + # Note this try/except block is for backwards compatibility + try: + if not render_job.enabled: + render_job.enabled = True + except AttributeError: + pass + + # If we have a shot list, iterate over the shots in the sequence + # and disable anything that's not in the shot list. If no shot list is + # provided render all the shots in the sequence + if shots: + for shot in render_job.shot_info: + if shot.inner_name in shots or (shot.outer_name in shots): + shot.enabled = True + else: + unreal.log_warning( + f"Disabling shot `{shot.inner_name}` from current render job `{render_job.job_name}`" + ) + shot.enabled = False + + try: + # Execute the render. This will execute the render based on whether + # its remote or local + executor = render_jobs( + is_remote, + remote_batch_name=remote_batch_name, + remote_job_preset=remote_job_preset, + is_cmdline=is_cmdline, + executor_instance=executor_instance, + output_dir_override=output_dir_override, + output_filename_override=output_filename_override + ) + + except Exception as err: + unreal.log_error( + f"An error occurred executing the render.\n\tError: {err}" + ) + raise + + return executor + + +def _process_args(args): + """ + Function to process the arguments for the sequence subcommand + :param args: Parsed Arguments from parser + """ + + return render_current_sequence( + args.sequence, + args.map, + args.mrq_preset, + user=args.user, + shots=args.shots, + is_remote=args.remote, + is_cmdline=args.cmdline, + remote_batch_name=args.batch_name, + remote_job_preset=args.deadline_job_preset, + output_dir_override=args.output_override, + output_filename_override=args.filename_override + ) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/utils.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/utils.py new file mode 100644 index 0000000000..1f75099066 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_cli_modes/utils.py @@ -0,0 +1,360 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +import unreal + +from getpass import getuser + +# Get a render queue +pipeline_subsystem = unreal.get_editor_subsystem( + unreal.MoviePipelineQueueSubsystem +) + +# Get the project settings +project_settings = unreal.get_default_object( + unreal.MovieRenderPipelineProjectSettings +) + +# Get the pipeline queue +movie_pipeline_queue = pipeline_subsystem.get_queue() + +pipeline_executor = None + + +def get_executor_instance(is_remote): + """ + Method to return an instance of a render executor + + :param bool is_remote: Flag to use the local or remote executor class + :return: Executor instance + """ + is_soft_class_object = True + # Convert the SoftClassPath into a SoftClassReference. + # local executor class from the project settings + try: + class_ref = unreal.SystemLibrary.conv_soft_class_path_to_soft_class_ref( + project_settings.default_local_executor + ) + # For Backwards compatibility. Older version returned a class object from + # the project settings + except TypeError: + class_ref = project_settings.default_local_executor + is_soft_class_object = False + + if is_remote: + try: + # Get the remote executor class + class_ref = ( + unreal.SystemLibrary.conv_soft_class_path_to_soft_class_ref( + project_settings.default_remote_executor + ) + ) + except TypeError: + class_ref = project_settings.default_remote_executor + is_soft_class_object = False + + if not class_ref: + raise RuntimeError( + "Failed to get a class reference to the default executor from the " + "project settings. Check the logs for more details." + ) + + if is_soft_class_object: + # Get the executor class as this is required to get an instance of + # the executor + executor_class = unreal.SystemLibrary.load_class_asset_blocking( + class_ref + ) + else: + executor_class = class_ref + + global pipeline_executor + pipeline_executor = unreal.new_object(executor_class) + + return pipeline_executor + + +def execute_render(is_remote=False, executor_instance=None, is_cmdline=False): + """ + Starts a render + + :param bool is_remote: Flag to use the local or remote executor class + :param executor_instance: Executor instance used for rendering + :param bool is_cmdline: Flag to determine if the render was executed from a commandline. + """ + + if not executor_instance: + executor_instance = get_executor_instance(is_remote) + + if is_cmdline: + setup_editor_exit_callback(executor_instance) + + # Start the Render + unreal.log("MRQ job started...") + unreal.log(f"Is remote render: {is_remote}") + + pipeline_subsystem.render_queue_with_executor_instance(executor_instance) + + return executor_instance + + +def setup_editor_exit_callback(executor_instance): + """ + Setup callbacks for when you need to close the editor after a render + + :param executor_instance: Movie Pipeline executor instance + """ + + unreal.log("Executed job from commandline, setting up shutdown callback..") + + # add a callable to the executor to be executed when the pipeline is done rendering + executor_instance.on_executor_finished_delegate.add_callable( + shutdown_editor + ) + # add a callable to the executor to be executed when the pipeline fails to render + executor_instance.on_executor_errored_delegate.add_callable( + executor_failed_callback + ) + + +def shutdown_editor(movie_pipeline=None, results=None): + """ + This method shutdown the editor + """ + unreal.log("Rendering is complete! Exiting...") + unreal.SystemLibrary.quit_editor() + + +def executor_failed_callback(executor, pipeline, is_fatal, error): + """ + Callback executed when a job fails in the editor + """ + unreal.log_error( + f"An error occurred while executing a render.\n\tError: {error}" + ) + + unreal.SystemLibrary.quit_editor() + + +def get_asset_data(name_or_path, asset_class): + """ + Get the asset data for the asset name or path based on its class. + + :param str name_or_path: asset name or package name + :param str asset_class: Asset class filter to use when looking for assets in registry + :raises RuntimeError + :return: Asset package if it exists + """ + # Get all the specified class assets in the project. + # This is the only mechanism we can think of at the moment to allow + # shorter path names in the commandline interface. This will allow users + # to only provide the asset name or the package path in the commandline + # interface based on the assumption that all assets are unique + asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() + + # If the asset registry is still loading, wait for it to finish + if asset_registry.is_loading_assets(): + unreal.log_warning("Asset Registry is loading, waiting to complete...") + asset_registry.wait_for_completion() + + unreal.log("Asset Registry load complete!") + + assets = asset_registry.get_assets( + unreal.ARFilter(class_names=[asset_class]) + ) + + # This lookup could potentially be very slow + for asset in assets: + # If a package name is provided lookup the package path. If a + # packages startwith a "/" this signifies a content package. Content + # packages can either be Game or plugin. Game content paths start + # with "/Game" and plugin contents startswith / + if name_or_path.startswith("/"): + # Reconstruct the package path into a package name. eg. + # /my/package_name.package_name -> /my/package_name + name_or_path = name_or_path.split(".")[0] + if asset.package_name == name_or_path: + return asset + else: + if asset.asset_name == name_or_path: + return asset + else: + raise RuntimeError(f"`{name_or_path}` could not be found!") + + +def setup_remote_render_jobs(batch_name, job_preset, render_jobs): + """ + This function sets up a render job with the options for a remote render. + This is configured currently for deadline jobs. + + :param str batch_name: Remote render batch name + :param str job_preset: Job Preset to use for job details + :param list render_jobs: The list of render jobs to apply the ars to + """ + + unreal.log("Setting up Remote render executor.. ") + + # Update the settings on the render job. + # Currently, this is designed to work with deadline + + # Make sure we have the relevant attribute on the jobs. This remote cli + # setup can be used with out-of-process rendering and not just deadline. + unset_job_properties = [] + for job in render_jobs: + if hasattr(job, "batch_name") and not batch_name: + unset_job_properties.append(job.name) + + if hasattr(job, "job_preset") and not job_preset: + unset_job_properties.append(job.name) + + # If we find a deadline property on the job, and it's not set, raise an + # error + if unset_job_properties: + raise RuntimeError( + "These jobs did not have a batch name, preset name or preset " + "library set. This is a requirement for deadline remote rendering. " + "{jobs}".format( + jobs="\n".join(unset_job_properties)) + ) + + for render_job in render_jobs: + render_job.batch_name = batch_name + render_job.job_preset = get_asset_data( + job_preset, + "DeadlineJobPreset" + ).get_asset() + + +def set_job_state(job, enable=False): + """ + This method sets the state on a current job to enabled or disabled + + :param job: MoviePipeline job to enable/disable + :param bool enable: Flag to determine if a job should be or not + """ + + if enable: + # Check for an enable attribute on the job and if not move along. + # Note: `Enabled` was added to MRQ that allows disabling all shots in + # a job. This also enables backwards compatibility. + try: + if not job.enabled: + job.enabled = True + except AttributeError: + # Legacy implementations assumes the presence of a job means its + # enabled + return + + try: + if job.enabled: + job.enabled = False + except AttributeError: + # If the attribute is not available, go through and disable all the + # associated shots. This behaves like a disabled job + for shot in job.shot_info: + unreal.log_warning( + f"Disabling shot `{shot.inner_name}` from current render job `{job.job_name}`" + ) + shot.enabled = False + + +def update_render_output(job, output_dir=None, output_filename=None): + """ + Updates that output directory and filename on a render job + + :param job: MRQ job + :param str output_dir: Output directory for renders + :param str output_filename: Output filename + """ + + # Get the job output settings + output_setting = job.get_configuration().find_setting_by_class( + unreal.MoviePipelineOutputSetting + ) + + if output_dir: + new_output_dir = unreal.DirectoryPath() + new_output_dir.set_editor_property( + "path", + output_dir + ) + unreal.log_warning( + f"Overriding output directory! New output directory is `{output_dir}`." + ) + output_setting.output_directory = new_output_dir + + if output_filename: + unreal.log_warning( + "Overriding filename format! New format is `{output_filename}`." + ) + + output_setting.file_name_format = output_filename + + +def update_queue( + jobs=None, + shots=None, + all_shots=False, + user=None, +): + """ + This function configures and renders a job based on the arguments + + :param list jobs: MRQ jobs to render + :param list shots: Shots to render from jobs + :param bool all_shots: Flag for rendering all shots + :param str user: Render user + """ + + # Iterate over all the jobs and make sure the jobs we want to + # render are enabled. + # All jobs that are not going to be rendered will be disabled if the + # job enabled attribute is not set or their shots disabled. + # The expectation is, If a job name is specified, we want to render the + # current state of that job. + # If a shot list is specified, we want to only render that shot alongside + # any other whole jobs (job states) that are explicitly specified, + # else other jobs or shots that are not + # needed are disabled + for job in movie_pipeline_queue.get_jobs(): + enable_job = False + + # Get a list of jobs to enable. + # This will enable jobs in their current queue state awaiting other + # modifications if shots are provided, if only the job name is + # specified, the job will be rendered in its current state + if jobs and (job.job_name in jobs): + enable_job = True + + # If we are told to render all shots. Enable all shots for all jobs + if all_shots: + for shot in job.shot_info: + shot.enabled = True + + # set the user for the current job + job.author = user or getuser() + + # Set the job to enabled and move on to the next job + set_job_state(job, enable=True) + + continue + + # If we have a list of shots, go through the shots associated + # with this job, enable the shots that need to be rendered and + # disable the others + if shots and (not enable_job): + for shot in job.shot_info: + if shot.inner_name in shots or (shot.outer_name in shots): + shot.enabled = True + enable_job = True + else: + unreal.log_warning( + f"Disabling shot `{shot.inner_name}` from current render job `{job.job_name}`" + ) + shot.enabled = False + + if enable_job: + # Set the author on the job + job.author = user or getuser() + + # Set the state of the job by enabling or disabling it. + set_job_state(job, enable=enable_job) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_rpc.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_rpc.py new file mode 100644 index 0000000000..3ee890a320 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/mrq_rpc.py @@ -0,0 +1,485 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +import os +import argparse +import json + +import unreal + +from deadline_rpc import BaseRPC + +from mrq_cli_modes import ( + render_queue_manifest, + render_current_sequence, + render_queue_asset, + utils, +) + + +class MRQRender(BaseRPC): + """ + Class to execute deadline MRQ renders using RPC + """ + + def __init__(self, *args, **kwargs): + """ + Constructor + """ + super(MRQRender, self).__init__(*args, **kwargs) + + self._render_cmd = ["mrq_cli.py"] + + # Keep track of the task data + self._shot_data = None + self._queue = None + self._manifest = None + self._sequence_data = None + + def _get_queue(self): + """ + Render a MRQ queue asset + + :return: MRQ queue asset name + """ + if not self._queue: + self._queue = self.proxy.get_job_extra_info_key_value("queue_name") + + return self._queue + + def _get_sequence_data(self): + """ + Get sequence data + + :return: Sequence data + """ + if not self._sequence_data: + self._sequence_data = self.proxy.get_job_extra_info_key_value( + "sequence_render" + ) + + return self._sequence_data + + def _get_serialized_pipeline(self): + """ + Get Serialized pipeline from Deadline + + :return: + """ + if not self._manifest: + serialized_pipeline = self.proxy.get_job_extra_info_key_value( + "serialized_pipeline" + ) + if not serialized_pipeline: + return + + unreal.log( + f"Executing Serialized Pipeline: `{serialized_pipeline}`" + ) + + # create temp manifest folder + movieRenderPipeline_dir = os.path.join( + unreal.SystemLibrary.get_project_saved_directory(), + "MovieRenderPipeline", + "TempManifests", + ) + + if not os.path.exists(movieRenderPipeline_dir ): + os.makedirs(movieRenderPipeline_dir ) + + # create manifest file + manifest_file = unreal.Paths.create_temp_filename( + movieRenderPipeline_dir , + prefix='TempManifest', + extension='.utxt') + + unreal.log(f"Saving Manifest file `{manifest_file}`") + + # Dump the manifest data into the manifest file + with open(manifest_file, "w") as manifest: + manifest.write(serialized_pipeline) + + self._manifest = manifest_file + + return self._manifest + + def execute(self): + """ + Starts the render execution + """ + + # shots are listed as a dictionary of task id -> shotnames + # i.e {"O": "my_new_shot"} or {"20", "shot_1,shot_2,shot_4"} + + # Get the task data and cache it + if not self._shot_data: + self._shot_data = json.loads( + self.proxy.get_job_extra_info_key_value("shot_info") + ) + + # Get any output overrides + output_dir = self.proxy.get_job_extra_info_key_value( + "output_directory_override" + ) + + # Resolve any path mappings in the directory name. The server expects + # a list of paths, but we only ever expect one. So wrap it in a list + # if we have an output directory + if output_dir: + output_dir = self.proxy.check_path_mappings([output_dir]) + output_dir = output_dir[0] + + # Get the filename format + filename_format = self.proxy.get_job_extra_info_key_value( + "filename_format_override" + ) + + # Resolve any path mappings in the filename. The server expects + # a list of paths, but we only ever expect one. So wrap it in a list + if filename_format: + filename_format = self.proxy.check_path_mappings([filename_format]) + filename_format = filename_format[0] + + # get the shots for the current task + current_task_data = self._shot_data.get(str(self.current_task_id), None) + + if not current_task_data: + self.proxy.fail_render("There are no task data to execute!") + return + + shots = current_task_data.split(",") + + if self._get_queue(): + return self.render_queue( + self._get_queue(), + shots, + output_dir_override=output_dir if output_dir else None, + filename_format_override=filename_format if filename_format else None + ) + + if self._get_serialized_pipeline(): + return self.render_serialized_pipeline( + self._get_serialized_pipeline(), + shots, + output_dir_override=output_dir if output_dir else None, + filename_format_override=filename_format if filename_format else None + ) + + if self._get_sequence_data(): + render_data = json.loads(self._get_sequence_data()) + sequence = render_data.get("sequence_name") + level = render_data.get("level_name") + mrq_preset = render_data.get("mrq_preset_name") + return self.render_sequence( + sequence, + level, + mrq_preset, + shots, + output_dir_override=output_dir if output_dir else None, + filename_format_override=filename_format if filename_format else None + ) + + def render_queue( + self, + queue_path, + shots, + output_dir_override=None, + filename_format_override=None + ): + """ + Executes a render from a queue + + :param str queue_path: Name/path of the queue asset + :param list shots: Shots to render + :param str output_dir_override: Movie Pipeline output directory + :param str filename_format_override: Movie Pipeline filename format override + """ + unreal.log(f"Executing Queue asset `{queue_path}`") + unreal.log(f"Rendering shots: {shots}") + + # Get an executor instance + executor = self._get_executor_instance() + + # Set executor callbacks + + # Set shot finished callbacks + executor.on_individual_shot_work_finished_delegate.add_callable( + self._on_individual_shot_finished_callback + ) + + # Set executor finished callbacks + executor.on_executor_finished_delegate.add_callable( + self._on_job_finished + ) + executor.on_executor_errored_delegate.add_callable(self._on_job_failed) + + # Render queue with executor + render_queue_asset( + queue_path, + shots=shots, + user=self.proxy.get_job_user(), + executor_instance=executor, + output_dir_override=output_dir_override, + output_filename_override=filename_format_override + ) + + def render_serialized_pipeline( + self, + manifest_file, + shots, + output_dir_override=None, + filename_format_override=None + ): + """ + Executes a render using a manifest file + + :param str manifest_file: serialized pipeline used to render a manifest file + :param list shots: Shots to render + :param str output_dir_override: Movie Pipeline output directory + :param str filename_format_override: Movie Pipeline filename format override + """ + unreal.log(f"Rendering shots: {shots}") + + # Get an executor instance + executor = self._get_executor_instance() + + # Set executor callbacks + + # Set shot finished callbacks + executor.on_individual_shot_work_finished_delegate.add_callable( + self._on_individual_shot_finished_callback + ) + + # Set executor finished callbacks + executor.on_executor_finished_delegate.add_callable( + self._on_job_finished + ) + executor.on_executor_errored_delegate.add_callable(self._on_job_failed) + + render_queue_manifest( + manifest_file, + shots=shots, + user=self.proxy.get_job_user(), + executor_instance=executor, + output_dir_override=output_dir_override, + output_filename_override=filename_format_override + ) + + def render_sequence( + self, + sequence, + level, + mrq_preset, + shots, + output_dir_override=None, + filename_format_override=None + ): + """ + Executes a render using a sequence level and map + + :param str sequence: Level Sequence name + :param str level: Level + :param str mrq_preset: MovieRenderQueue preset + :param list shots: Shots to render + :param str output_dir_override: Movie Pipeline output directory + :param str filename_format_override: Movie Pipeline filename format override + """ + unreal.log( + f"Executing sequence `{sequence}` with map `{level}` " + f"and mrq preset `{mrq_preset}`" + ) + unreal.log(f"Rendering shots: {shots}") + + # Get an executor instance + executor = self._get_executor_instance() + + # Set executor callbacks + + # Set shot finished callbacks + executor.on_individual_shot_work_finished_delegate.add_callable( + self._on_individual_shot_finished_callback + ) + + # Set executor finished callbacks + executor.on_executor_finished_delegate.add_callable( + self._on_job_finished + ) + executor.on_executor_errored_delegate.add_callable(self._on_job_failed) + + render_current_sequence( + sequence, + level, + mrq_preset, + shots=shots, + user=self.proxy.get_job_user(), + executor_instance=executor, + output_dir_override=output_dir_override, + output_filename_override=filename_format_override + ) + + @staticmethod + def _get_executor_instance(): + """ + Gets an instance of the movie pipeline executor + + :return: Movie Pipeline Executor instance + """ + return utils.get_executor_instance(False) + + def _on_individual_shot_finished_callback(self, shot_params): + """ + Callback to execute when a shot is done rendering + + :param shot_params: Movie pipeline shot params + """ + unreal.log("Executing On individual shot callback") + + # Since MRQ cannot parse certain parameters/arguments till an actual + # render is complete (e.g. local version numbers), we will use this as + # an opportunity to update the deadline proxy on the actual frame + # details that were rendered + + file_patterns = set() + + # Iterate over all the shots in the shot list (typically one shot as + # this callback is executed) on a shot by shot bases. + for shot in shot_params.shot_data: + for pass_identifier in shot.render_pass_data: + + # only get the first file + paths = shot.render_pass_data[pass_identifier].file_paths + + # make sure we have paths to iterate on + if len(paths) < 1: + continue + + # we only need the ext from the first file + ext = os.path.splitext(paths[0])[1].replace(".", "") + + # Make sure we actually have an extension to use + if not ext: + continue + + # Get the current job output settings + output_settings = shot_params.job.get_configuration().find_or_add_setting_by_class( + unreal.MoviePipelineOutputSetting + ) + + resolve_params = unreal.MoviePipelineFilenameResolveParams() + + # Set the camera name from the shot data + resolve_params.camera_name_override = shot_params.shot_data[ + 0 + ].shot.inner_name + + # set the shot name from the shot data + resolve_params.shot_name_override = shot_params.shot_data[ + 0 + ].shot.outer_name + + # Get the zero padding configuration + resolve_params.zero_pad_frame_number_count = ( + output_settings.zero_pad_frame_numbers + ) + + # Update the formatting of frame numbers based on the padding. + # Deadline uses # (* padding) to display the file names in a job + resolve_params.file_name_format_overrides[ + "frame_number" + ] = "#" * int(output_settings.zero_pad_frame_numbers) + + # Update the extension + resolve_params.file_name_format_overrides["ext"] = ext + + # Set the job on the resolver + resolve_params.job = shot_params.job + + # Set the initialization time on the resolver + resolve_params.initialization_time = ( + unreal.MoviePipelineLibrary.get_job_initialization_time( + shot_params.pipeline + ) + ) + + # Set the shot overrides + resolve_params.shot_override = shot_params.shot_data[0].shot + + combined_path = unreal.Paths.combine( + [ + output_settings.output_directory.path, + output_settings.file_name_format, + ] + ) + + # Resolve the paths + # The returned values are a tuple with the resolved paths as the + # first index. Get the paths and add it to a list + ( + path, + _, + ) = unreal.MoviePipelineLibrary.resolve_filename_format_arguments( + combined_path, resolve_params + ) + + # Make sure we are getting the right type from resolved + # arguments + if isinstance(path, str): + # Sanitize the paths + path = os.path.normpath(path).replace("\\", "/") + file_patterns.add(path) + + elif isinstance(path, list): + + file_patterns.update( + set( + [ + os.path.normpath(p).replace("\\", "/") + for p in path + ] + ) + ) + + else: + raise RuntimeError( + f"Expected the shot file paths to be a " + f"string or list but got: {type(path)}" + ) + + if file_patterns: + unreal.log(f'Updating remote filenames: {", ".join(file_patterns)}') + + # Update the paths on the deadline job + self.proxy.update_job_output_filenames(list(file_patterns)) + + def _on_job_finished(self, executor=None, success=None): + """ + Callback to execute on executor finished + """ + # TODO: add th ability to set the output directory for the task + unreal.log(f"Task {self.current_task_id} complete!") + self.task_complete = True + + def _on_job_failed(self, executor, pipeline, is_fatal, error): + """ + Callback to execute on job failed + """ + unreal.log_error(f"Is fatal job error: {is_fatal}") + unreal.log_error( + f"An error occurred executing task `{self.current_task_id}`: \n\t{error}" + ) + self.proxy.fail_render(error) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="This parser is used to run an mrq render with rpc" + ) + parser.add_argument( + "--port", type=int, default=None, help="Port number for rpc server" + ) + parser.add_argument( + "--verbose", action="store_true", help="Enable verbose logging" + ) + + arguments = parser.parse_args() + + MRQRender(port=arguments.port, verbose=arguments.verbose) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/pipeline_actions/__init__.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/pipeline_actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/pipeline_actions/render_queue_action.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/pipeline_actions/render_queue_action.py new file mode 100644 index 0000000000..1dfb1cba8c --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/pipeline_actions/render_queue_action.py @@ -0,0 +1,242 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Built-in +import argparse +import re +from pathlib import Path +from getpass import getuser +from collections import OrderedDict + +# Internal +from deadline_service import get_global_deadline_service_instance +from deadline_job import DeadlineJob +from deadline_menus import DeadlineToolBarMenu +from deadline_utils import get_deadline_info_from_preset + +# Third Party +import unreal + + +# Editor Utility Widget path +# NOTE: This is very fragile and can break if naming or pathing changes +EDITOR_UTILITY_WIDGET = "/MoviePipelineDeadline/Widgets/QueueAssetSubmitter" + + +def _launch_queue_asset_submitter(): + """ + Callback to execute to launch the queue asset submitter + """ + unreal.log("Launching queue submitter.") + + submitter_widget = unreal.EditorAssetLibrary.load_asset(EDITOR_UTILITY_WIDGET) + + # Get editor subsystem + subsystem = unreal.get_editor_subsystem(unreal.EditorUtilitySubsystem) + + # Spawn the submitter widget + subsystem.spawn_and_register_tab(submitter_widget) + + +def register_menu_action(): + """ + Creates the toolbar menu + """ + + if not _validate_euw_asset_exists(): + unreal.log_warning( + f"EUW `{EDITOR_UTILITY_WIDGET}` does not exist in the Asset registry!" + ) + return + + toolbar = DeadlineToolBarMenu() + + toolbar.register_submenu( + "SubmitMRQAsset", + _launch_queue_asset_submitter, + label_name="Submit Movie Render Queue Asset", + description="Submits a Movie Render Queue asset to Deadline" + ) + + +def _validate_euw_asset_exists(): + """ + Make sure our reference editor utility widget exists in + the asset registry + :returns: Array(AssetData) or None + """ + + asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() + asset_data = asset_registry.get_assets_by_package_name( + EDITOR_UTILITY_WIDGET, + include_only_on_disk_assets=True + ) + + return True if asset_data else False + + +def _execute_submission(args): + """ + Creates and submits the queue asset as a job to Deadline + :param args: Commandline args + """ + + unreal.log("Executing job submission") + + job_info, plugin_info = get_deadline_info_from_preset( + job_preset=unreal.load_asset(args.submission_job_preset) + ) + + # Due to some odd behavior in how Unreal passes string to the argparse, + # it adds extra quotes to the string, so we will strip the quotes out to get + # a single string representation. + batch_name = args.batch_name[0].strip('"') + + # Update the Job Batch Name + job_info["BatchName"] = batch_name + + # Set the name of the job if one is not set + if not job_info.get("Name"): + job_info["Name"] = Path(args.queue_asset).stem + + # Set the Author of the job + if not job_info.get("UserName"): + job_info["UserName"] = getuser() + + # Arguments to pass to the executable. + command_args = [] + + # Append all of our inherited command line arguments from the editor. + in_process_executor_settings = unreal.get_default_object( + unreal.MoviePipelineInProcessExecutorSettings + ) + inherited_cmds = in_process_executor_settings.inherited_command_line_arguments + + # Sanitize the commandline by removing any execcmds that may + # have passed through the commandline. + # We remove the execcmds because, in some cases, users may execute a + # script that is local to their editor build for some automated + # workflow but this is not ideal on the farm. We will expect all + # custom startup commands for rendering to go through the `Start + # Command` in the MRQ settings. + inherited_cmds = re.sub( + ".(?P-execcmds=[\w\W]+[\'\"])", + "", + inherited_cmds + ) + + command_args.extend(inherited_cmds.split(" ")) + command_args.extend( + in_process_executor_settings.additional_command_line_arguments.split( + " " + ) + ) + + # Build out custom queue command that will be used to render the queue on + # the farm. + queue_cmds = [ + "py", + "mrq_cli.py", + "queue", + str(args.queue_asset), + "--remote", + "--cmdline", + "--batch_name", + batch_name, + "--deadline_job_preset", + str(args.remote_job_preset) + ] + + command_args.extend( + [ + "-nohmd", + "-windowed", + "-ResX=1280", + "-ResY=720", + '-execcmds="{cmds}"'.format(cmds=" ".join(queue_cmds)) + ] + ) + + # Append the commandline args from the deadline plugin info + command_args.extend(plugin_info.get("CommandLineArguments", "").split(" ")) + + # Sanitize the commandline args + command_args = [arg for arg in command_args if arg not in [None, "", " "]] + + # Remove all duplicates from the command args + full_cmd_args = " ".join(list(OrderedDict.fromkeys(command_args))) + + # Get the current launched project file + if unreal.Paths.is_project_file_path_set(): + # Trim down to just "Game.uproject" instead of absolute path. + game_name_or_project_file = ( + unreal.Paths.convert_relative_path_to_full( + unreal.Paths.get_project_file_path() + ) + ) + + else: + raise RuntimeError( + "Failed to get a project name. Please specify a project!" + ) + + if not plugin_info.get("ProjectFile"): + project_file = plugin_info.get("ProjectFile", game_name_or_project_file) + plugin_info["ProjectFile"] = project_file + + # Update the plugin info. "CommandLineMode" tells Deadline to not use an + # interactive process to execute the job but launch it like a shell + # command and wait for the process to exit. `--cmdline` in our + # commandline arguments will tell the editor to shut down when the job is + # complete + plugin_info.update( + { + "CommandLineArguments": full_cmd_args, + "CommandLineMode": "true" + } + ) + + # Create a Deadline job from the selected preset library + deadline_job = DeadlineJob(job_info, plugin_info) + + deadline_service = get_global_deadline_service_instance() + + # Submit the Deadline Job + job_id = deadline_service.submit_job(deadline_job) + + unreal.log(f"Deadline job submitted. JobId: {job_id}") + + +if __name__ == "__main__": + unreal.log("Executing queue submitter action") + + parser = argparse.ArgumentParser( + description="Submits queue asset to Deadline", + add_help=False, + ) + parser.add_argument( + "--batch_name", + type=str, + nargs='+', + help="Deadline Batch Name" + ) + parser.add_argument( + "--submission_job_preset", + type=str, + help="Submitter Deadline Job Preset" + ) + parser.add_argument( + "--remote_job_preset", + type=str, + help="Remote Deadline Job Preset" + ) + parser.add_argument( + "--queue_asset", + type=str, + help="Movie Pipeline Queue Asset" + ) + + parser.set_defaults(func=_execute_submission) + + # Parse the arguments and execute the function callback + arguments = parser.parse_args() + arguments.func(arguments) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/remote_executor.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/remote_executor.py new file mode 100644 index 0000000000..66f5c4fc24 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Python/remote_executor.py @@ -0,0 +1,479 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Built-In +import os +import re +import json +import traceback +from collections import OrderedDict + +# External +import unreal + +from deadline_service import get_global_deadline_service_instance +from deadline_job import DeadlineJob +from deadline_utils import get_deadline_info_from_preset + + +@unreal.uclass() +class MoviePipelineDeadlineRemoteExecutor(unreal.MoviePipelineExecutorBase): + """ + This class defines the editor implementation for Deadline (what happens when you + press 'Render (Remote)', which is in charge of taking a movie queue from the UI + and processing it into something Deadline can handle. + """ + + # The queue we are working on, null if no queue has been provided. + pipeline_queue = unreal.uproperty(unreal.MoviePipelineQueue) + job_ids = unreal.uproperty(unreal.Array(str)) + + # A MoviePipelineExecutor implementation must override this. + @unreal.ufunction(override=True) + def execute(self, pipeline_queue): + """ + This is called when the user presses Render (Remote) in the UI. We will + split the queue up into multiple jobs. Each job will be submitted to + deadline separately, with each shot within the job split into one Deadline + task per shot. + """ + + unreal.log(f"Asked to execute Queue: {pipeline_queue}") + unreal.log(f"Queue has {len(pipeline_queue.get_jobs())} jobs") + + # Don't try to process empty/null Queues, no need to send them to + # Deadline. + if not pipeline_queue or (not pipeline_queue.get_jobs()): + self.on_executor_finished_impl() + return + + # The user must save their work and check it in so that Deadline + # can sync it. + dirty_packages = [] + dirty_packages.extend( + unreal.EditorLoadingAndSavingUtils.get_dirty_content_packages() + ) + dirty_packages.extend( + unreal.EditorLoadingAndSavingUtils.get_dirty_map_packages() + ) + + # Sometimes the dialog will return `False` + # even when there are no packages to save. so we are + # being explict about the packages we need to save + if dirty_packages: + if not unreal.EditorLoadingAndSavingUtils.save_dirty_packages_with_dialog( + True, True + ): + message = ( + "One or more jobs in the queue have an unsaved map/content. " + "{packages} " + "Please save and check-in all work before submission.".format( + packages="\n".join(dirty_packages) + ) + ) + + unreal.log_error(message) + unreal.EditorDialog.show_message( + "Unsaved Maps/Content", message, unreal.AppMsgType.OK + ) + self.on_executor_finished_impl() + return + + # Make sure all the maps in the queue exist on disk somewhere, + # unsaved maps can't be loaded on the remote machine, and it's common + # to have the wrong map name if you submit without loading the map. + has_valid_map = ( + unreal.MoviePipelineEditorLibrary.is_map_valid_for_remote_render( + pipeline_queue.get_jobs() + ) + ) + if not has_valid_map: + message = ( + "One or more jobs in the queue have an unsaved map as " + "their target map. " + "These unsaved maps cannot be loaded by an external process, " + "and the render has been aborted." + ) + unreal.log_error(message) + unreal.EditorDialog.show_message( + "Unsaved Maps", message, unreal.AppMsgType.OK + ) + self.on_executor_finished_impl() + return + + self.pipeline_queue = pipeline_queue + + deadline_settings = unreal.get_default_object( + unreal.MoviePipelineDeadlineSettings + ) + + # Arguments to pass to the executable. This can be modified by settings + # in the event a setting needs to be applied early. + # In the format of -foo -bar + # commandLineArgs = "" + command_args = [] + + # Append all of our inherited command line arguments from the editor. + in_process_executor_settings = unreal.get_default_object( + unreal.MoviePipelineInProcessExecutorSettings + ) + inherited_cmds = in_process_executor_settings.inherited_command_line_arguments + + # Sanitize the commandline by removing any execcmds that may + # have passed through the commandline. + # We remove the execcmds because, in some cases, users may execute a + # script that is local to their editor build for some automated + # workflow but this is not ideal on the farm. We will expect all + # custom startup commands for rendering to go through the `Start + # Command` in the MRQ settings. + inherited_cmds = re.sub( + ".*(?P-execcmds=[\s\S]+[\'\"])", + "", + inherited_cmds + ) + + command_args.extend(inherited_cmds.split(" ")) + command_args.extend( + in_process_executor_settings.additional_command_line_arguments.split( + " " + ) + ) + + command_args.extend( + ["-nohmd", "-windowed", f"-ResX=1280", f"-ResY=720"] + ) + + # Get the project level preset + project_preset = deadline_settings.default_job_preset + + # Get the job and plugin info string. + # Note: + # Sometimes a project level default may not be set, + # so if this returns an empty dictionary, that is okay + # as we primarily care about the job level preset. + # Catch any exceptions here and continue + try: + project_job_info, project_plugin_info = get_deadline_info_from_preset(job_preset=project_preset) + + except Exception: + pass + + deadline_service = get_global_deadline_service_instance() + + for job in self.pipeline_queue.get_jobs(): + + unreal.log(f"Submitting Job `{job.job_name}` to Deadline...") + + try: + # Create a Deadline job object with the default project level + # job info and plugin info + deadline_job = DeadlineJob(project_job_info, project_plugin_info) + + deadline_job_id = self.submit_job( + job, deadline_job, command_args, deadline_service + ) + + except Exception as err: + unreal.log_error( + f"Failed to submit job `{job.job_name}` to Deadline, aborting render. \n\tError: {str(err)}" + ) + unreal.log_error(traceback.format_exc()) + self.on_executor_errored_impl(None, True, str(err)) + unreal.EditorDialog.show_message( + "Submission Result", + f"Failed to submit job `{job.job_name}` to Deadline with error: {str(err)}. " + f"See log for more details.", + unreal.AppMsgType.OK, + ) + self.on_executor_finished_impl() + return + + if not deadline_job_id: + message = ( + f"A problem occurred submitting `{job.job_name}`. " + f"Either the job doesn't have any data to submit, " + f"or an error occurred getting the Deadline JobID. " + f"This job status would not be reflected in the UI. " + f"Check the logs for more details." + ) + unreal.log_warning(message) + unreal.EditorDialog.show_message( + "Submission Result", message, unreal.AppMsgType.OK + ) + return + + else: + unreal.log(f"Deadline JobId: {deadline_job_id}") + self.job_ids.append(deadline_job_id) + + # Store the Deadline JobId in our job (the one that exists in + # the queue, not the duplicate) so we can match up Movie + # Pipeline jobs with status updates from Deadline. + job.user_data = deadline_job_id + + # Now that we've sent a job to Deadline, we're going to request a status + # update on them so that they transition from "Ready" to "Queued" or + # their actual status in Deadline. self.request_job_status_update( + # deadline_service) + + message = ( + f"Successfully submitted {len(self.job_ids)} jobs to Deadline. JobIds: {', '.join(self.job_ids)}. " + f"\nPlease use Deadline Monitor to track render job statuses" + ) + unreal.log(message) + + unreal.EditorDialog.show_message( + "Submission Result", message, unreal.AppMsgType.OK + ) + + # Set the executor to finished + self.on_executor_finished_impl() + + @unreal.ufunction(override=True) + def is_rendering(self): + # Because we forward unfinished jobs onto another service when the + # button is pressed, they can always submit what is in the queue and + # there's no need to block the queue. + # A MoviePipelineExecutor implementation must override this. If you + # override a ufunction from a base class you don't specify the return + # type or parameter types. + return False + + def submit_job(self, job, deadline_job, command_args, deadline_service): + """ + Submit a new Job to Deadline + :param job: Queued job to submit + :param deadline_job: Deadline job object + :param list[str] command_args: Commandline arguments to configure for the Deadline Job + :param deadline_service: An instance of the deadline service object + :returns: Deadline Job ID + :rtype: str + """ + + # Get the Job Info and plugin Info + # If we have a preset set on the job, get the deadline submission details + try: + job_info, plugin_info = get_deadline_info_from_preset(job_preset_struct=job.get_deadline_job_preset_struct_with_overrides()) + + # Fail the submission if any errors occur + except Exception as err: + raise RuntimeError( + f"An error occurred getting the deadline job and plugin " + f"details. \n\tError: {err} " + ) + + # check for required fields in pluginInfo + if "Executable" not in plugin_info: + raise RuntimeError("An error occurred formatting the Plugin Info string. \n\tMissing \"Executable\" key") + elif not plugin_info["Executable"]: + raise RuntimeError(f"An error occurred formatting the Plugin Info string. \n\tExecutable value cannot be empty") + if "ProjectFile" not in plugin_info: + raise RuntimeError("An error occurred formatting the Plugin Info string. \n\tMissing \"ProjectFile\" key") + elif not plugin_info["ProjectFile"]: + raise RuntimeError(f"An error occurred formatting the Plugin Info string. \n\tProjectFile value cannot be empty") + + # Update the job info with overrides from the UI + if job.batch_name: + job_info["BatchName"] = job.batch_name + + if hasattr(job, "comment") and not job_info.get("Comment"): + job_info["Comment"] = job.comment + + if not job_info.get("Name") or job_info["Name"] == "Untitled": + job_info["Name"] = job.job_name + + if job.author: + job_info["UserName"] = job.author + + if unreal.Paths.is_project_file_path_set(): + # Trim down to just "Game.uproject" instead of absolute path. + game_name_or_project_file = ( + unreal.Paths.convert_relative_path_to_full( + unreal.Paths.get_project_file_path() + ) + ) + + else: + raise RuntimeError( + "Failed to get a project name. Please set a project!" + ) + + # Create a new queue with only this job in it and save it to disk, + # then load it, so we can send it with the REST API + new_queue = unreal.MoviePipelineQueue() + new_job = new_queue.duplicate_job(job) + + duplicated_queue, manifest_path = unreal.MoviePipelineEditorLibrary.save_queue_to_manifest_file( + new_queue + ) + + # Convert the queue to text (load the serialized json from disk) so we + # can send it via deadline, and deadline will write the queue to the + # local machines on job startup. + serialized_pipeline = unreal.MoviePipelineEditorLibrary.convert_manifest_file_to_string( + manifest_path + ) + + # Loop through our settings in the job and let them modify the command + # line arguments/params. + new_job.get_configuration().initialize_transient_settings() + # Look for our Game Override setting to pull the game mode to start + # with. We start with this game mode even on a blank map to override + # the project default from kicking in. + game_override_class = None + + out_url_params = [] + out_command_line_args = [] + out_device_profile_cvars = [] + out_exec_cmds = [] + for setting in new_job.get_configuration().get_all_settings(): + + out_url_params, out_command_line_args, out_device_profile_cvars, out_exec_cmds = setting.build_new_process_command_line_args( + out_url_params, + out_command_line_args, + out_device_profile_cvars, + out_exec_cmds, + ) + + # Set the game override + if setting.get_class() == unreal.MoviePipelineGameOverrideSetting.static_class(): + game_override_class = setting.game_mode_override + + # This triggers the editor to start looking for render jobs when it + # finishes loading. + out_exec_cmds.append("py mrq_rpc.py") + + # Convert the arrays of command line args, device profile cvars, + # and exec cmds into actual commands for our command line. + command_args.extend(out_command_line_args) + + if out_device_profile_cvars: + # -dpcvars="arg0,arg1,..." + command_args.append( + '-dpcvars="{dpcvars}"'.format( + dpcvars=",".join(out_device_profile_cvars) + ) + ) + + if out_exec_cmds: + # -execcmds="cmd0,cmd1,..." + command_args.append( + '-execcmds="{cmds}"'.format(cmds=",".join(out_exec_cmds)) + ) + + # Add support for telling the remote process to wait for the + # asset registry to complete synchronously + command_args.append("-waitonassetregistry") + + # Build a shot-mask from this sequence, to split into the appropriate + # number of tasks. Remove any already-disabled shots before we + # generate a list, otherwise we make unneeded tasks which get sent to + # machines + shots_to_render = [] + for shot_index, shot in enumerate(new_job.shot_info): + if not shot.enabled: + unreal.log( + f"Skipped submitting shot {shot_index} in {job.job_name} " + f"to server due to being already disabled!" + ) + else: + shots_to_render.append(shot.outer_name) + + # If there are no shots enabled, + # "these are not the droids we are looking for", move along ;) + # We will catch this later and deal with it + if not shots_to_render: + unreal.log_warning("No shots enabled in shot mask, not submitting.") + return + + # Divide the job to render by the chunk size + # i.e {"O": "my_new_shot"} or {"0", "shot_1,shot_2,shot_4"} + chunk_size = int(job_info.get("ChunkSize", 1)) + shots = {} + frame_list = [] + for index in range(0, len(shots_to_render), chunk_size): + + shots[str(index)] = ",".join(shots_to_render[index : index + chunk_size]) + + frame_list.append(str(index)) + + job_info["Frames"] = ",".join(frame_list) + + # Get the current index of the ExtraInfoKeyValue pair, we will + # increment the index, so we do not stomp other settings + extra_info_key_indexs = set() + for key in job_info.keys(): + if key.startswith("ExtraInfoKeyValue"): + _, index = key.split("ExtraInfoKeyValue") + extra_info_key_indexs.add(int(index)) + + # Get the highest number in the index list and increment the number + # by one + current_index = max(extra_info_key_indexs) + 1 if extra_info_key_indexs else 0 + + # Put the serialized Queue into the Job data but hidden from + # Deadline UI + job_info[f"ExtraInfoKeyValue{current_index}"] = f"serialized_pipeline={serialized_pipeline}" + + # Increment the index + current_index += 1 + + # Put the shot info in the job extra info keys + job_info[f"ExtraInfoKeyValue{current_index}"] = f"shot_info={json.dumps(shots)}" + current_index += 1 + + # Set the job output directory override on the deadline job + if hasattr(new_job, "output_directory_override"): + if new_job.output_directory_override.path: + job_info[f"ExtraInfoKeyValue{current_index}"] = f"output_directory_override={new_job.output_directory_override.path}" + + current_index += 1 + + # Set the job filename format override on the deadline job + if hasattr(new_job, "filename_format_override"): + if new_job.filename_format_override: + job_info[f"ExtraInfoKeyValue{current_index}"] = f"filename_format_override={new_job.filename_format_override}" + + current_index += 1 + + # Build the command line arguments the remote machine will use. + # The Deadline plugin will provide the executable since it is local to + # the machine. It will also write out queue manifest to the correct + # location relative to the Saved folder + + # Get the current commandline args from the plugin info + plugin_info_cmd_args = [plugin_info.get("CommandLineArguments", "")] + + if not plugin_info.get("ProjectFile"): + project_file = plugin_info.get("ProjectFile", game_name_or_project_file) + plugin_info["ProjectFile"] = project_file + + # This is the map included in the plugin to boot up to. + project_cmd_args = [ + f"MoviePipelineEntryMap?game={game_override_class.get_path_name()}" + ] + + # Combine all the compiled arguments + full_cmd_args = project_cmd_args + command_args + plugin_info_cmd_args + + # Remove any duplicates in the commandline args and convert to a string + full_cmd_args = " ".join(list(OrderedDict.fromkeys(full_cmd_args))).strip() + + unreal.log(f"Deadline job command line args: {full_cmd_args}") + + # Update the plugin info with the commandline arguments + plugin_info.update( + { + "CommandLineArguments": full_cmd_args, + "CommandLineMode": "false", + } + ) + + deadline_job.job_info = job_info + deadline_job.plugin_info = plugin_info + + # Submit the deadline job + return deadline_service.submit_job(deadline_job) + + # TODO: For performance reasons, we will skip updating the UI and request + # that users use a different mechanism for checking on job statuses. + # This will be updated once we have a performant solution. diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Widgets/QueueAssetSubmitter.uasset b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Content/Widgets/QueueAssetSubmitter.uasset new file mode 100644 index 0000000000000000000000000000000000000000..442152b22ddab27205e8f62348e297f5be1067e2 GIT binary patch literal 267854 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

o;JjAu(tHS@~wizq@O_pT* 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 a;7#~yJh_ha>A&A^fRdb=Y>l)<=>Gxy=2LS3 literal 0 HcmV?d00001 diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/MoviePipelineDeadline.Build.cs b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/MoviePipelineDeadline.Build.cs new file mode 100644 index 0000000000..8ed4b7c041 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/MoviePipelineDeadline.Build.cs @@ -0,0 +1,32 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class MoviePipelineDeadline : ModuleRules +{ + public MoviePipelineDeadline(ReadOnlyTargetRules Target) : base(Target) + { + ShortName = "DMP"; + + PrivateDependencyModuleNames.AddRange( + new string[] { + "Core", + "CoreUObject", + "DeadlineService", + "DeveloperSettings", + "Engine", + "InputCore", + "MovieRenderPipelineCore", + "PropertyEditor", + "RenderCore", + "Slate", + "SlateCore" + } + ); + + PublicDependencyModuleNames.AddRange( + new string[] { + } + ); + } +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/DeadlineJobPresetCustomization.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/DeadlineJobPresetCustomization.cpp new file mode 100644 index 0000000000..44202404da --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/DeadlineJobPresetCustomization.cpp @@ -0,0 +1,338 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "DeadlineJobPresetCustomization.h" + +#include "MoviePipelineDeadlineExecutorJob.h" +#include "MoviePipelineDeadlineSettings.h" + +#include "DetailWidgetRow.h" +#include "IDetailChildrenBuilder.h" +#include "IDetailGroup.h" +#include "Widgets/Input/SCheckBox.h" +#include "Widgets/Layout/SBox.h" + +class SEyeCheckBox : public SCompoundWidget +{ +public: + SLATE_BEGIN_ARGS( SEyeCheckBox ){} + + SLATE_END_ARGS() + + void Construct(const FArguments& InArgs, const FName& InPropertyPath) + { + ChildSlot + [ + SNew(SBox) + .Visibility(EVisibility::Visible) + .HAlign(HAlign_Right) + .WidthOverride(28) + .HeightOverride(20) + .Padding(4, 0) + [ + SAssignNew(CheckBoxPtr, SCheckBox) + .Style(&FAppStyle::Get().GetWidgetStyle("ToggleButtonCheckbox")) + .Visibility_Lambda([this]() + { + return CheckBoxPtr.IsValid() && !CheckBoxPtr->IsChecked() ? EVisibility::Visible : IsHovered() ? EVisibility::Visible : EVisibility::Hidden; + }) + .CheckedImage(FAppStyle::Get().GetBrush("Icons.Visible")) + .CheckedHoveredImage(FAppStyle::Get().GetBrush("Icons.Visible")) + .CheckedPressedImage(FAppStyle::Get().GetBrush("Icons.Visible")) + .UncheckedImage(FAppStyle::Get().GetBrush("Icons.Hidden")) + .UncheckedHoveredImage(FAppStyle::Get().GetBrush("Icons.Hidden")) + .UncheckedPressedImage(FAppStyle::Get().GetBrush("Icons.Hidden")) + .ToolTipText(NSLOCTEXT("FDeadlineJobPresetLibraryCustomization", "VisibleInMoveRenderQueueToolTip", "If true this property will be visible for overriding from Movie Render Queue.")) + .OnCheckStateChanged_Lambda([InPropertyPath](ECheckBoxState CheckType) + { + if (UMoviePipelineDeadlineSettings* Settings = + GetMutableDefault()) + { + if (CheckType == ECheckBoxState::Unchecked) + { + Settings->AddPropertyToHideInMovieRenderQueue( + InPropertyPath); + } + else + { + Settings-> + RemovePropertyToHideInMovieRenderQueue( + InPropertyPath); + } + } + }) + .IsChecked_Lambda([InPropertyPath]() + { + return FDeadlineJobPresetCustomization::IsPropertyHiddenInMovieRenderQueue(InPropertyPath) + ? ECheckBoxState::Unchecked + : ECheckBoxState::Checked; + }) + ] + ]; + } + + TSharedPtr CheckBoxPtr; +}; + +TSharedRef FDeadlineJobPresetCustomization::MakeInstance() +{ + return MakeShared(); +} + +void FDeadlineJobPresetCustomization::CustomizeChildren(TSharedRef StructHandle, + IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils) +{ + TArray OuterObjects; + StructHandle->GetOuterObjects(OuterObjects); + + if (OuterObjects.Num() == 0) + { + return; + } + + const TWeakObjectPtr OuterObject = OuterObjects[0]; + if (!OuterObject.IsValid()) + { + return; + } + + UMoviePipelineDeadlineExecutorJob* OuterJob = Cast(OuterObject); + + TMap CreatedCategories; + + const FName StructName(StructHandle->GetProperty()->GetFName()); + + if (OuterJob) + { + IDetailGroup& BaseCategoryGroup = ChildBuilder.AddGroup(StructName, StructHandle->GetPropertyDisplayName()); + CreatedCategories.Add(StructName, &BaseCategoryGroup); + } + + // For each map member and each struct member in the map member value + uint32 NumChildren; + StructHandle->GetNumChildren(NumChildren); + + // For each struct member + for (uint32 ChildIndex = 0; ChildIndex < NumChildren; ++ChildIndex) + { + const TSharedRef ChildHandle = StructHandle->GetChildHandle(ChildIndex).ToSharedRef(); + + // Skip properties that are hidden so we don't end up creating empty categories in the job details + if (OuterJob && IsPropertyHiddenInMovieRenderQueue(*ChildHandle->GetProperty()->GetPathName())) + { + continue; + } + + IDetailGroup* GroupToUse = nullptr; + if (const FString* PropertyCategoryString = ChildHandle->GetProperty()->FindMetaData(TEXT("Category"))) + { + FName PropertyCategoryName(*PropertyCategoryString); + + if (IDetailGroup** FoundCategory = CreatedCategories.Find(PropertyCategoryName)) + { + GroupToUse = *FoundCategory; + } + else + { + if (OuterJob) + { + IDetailGroup& NewGroup = CreatedCategories.FindChecked(StructName)->AddGroup(PropertyCategoryName, FText::FromName(PropertyCategoryName), true); + GroupToUse = CreatedCategories.Add(PropertyCategoryName, &NewGroup); + } + else + { + IDetailGroup& NewGroup = ChildBuilder.AddGroup(PropertyCategoryName, FText::FromName(PropertyCategoryName)); + NewGroup.ToggleExpansion(true); + GroupToUse = CreatedCategories.Add(PropertyCategoryName, &NewGroup); + } + } + } + + IDetailPropertyRow& PropertyRow = GroupToUse->AddPropertyRow(ChildHandle); + + if (OuterJob) + { + CustomizeStructChildrenInMovieRenderQueue(PropertyRow, OuterJob); + } + else + { + CustomizeStructChildrenInAssetDetails(PropertyRow); + } + } + + // Force expansion of all categories + for (const TTuple& Pair : CreatedCategories) + { + if (Pair.Value) + { + Pair.Value->ToggleExpansion(true); + } + } +} + +void FDeadlineJobPresetCustomization::CustomizeStructChildrenInAssetDetails(IDetailPropertyRow& PropertyRow) const +{ + TSharedPtr NameWidget; + TSharedPtr ValueWidget; + FDetailWidgetRow Row; + PropertyRow.GetDefaultWidgets(NameWidget, ValueWidget, Row); + + PropertyRow.CustomWidget(true) + .NameContent() + .MinDesiredWidth(Row.NameWidget.MinWidth) + .MaxDesiredWidth(Row.NameWidget.MaxWidth) + .HAlign(HAlign_Fill) + [ + NameWidget.ToSharedRef() + ] + .ValueContent() + .MinDesiredWidth(Row.ValueWidget.MinWidth) + .MaxDesiredWidth(Row.ValueWidget.MaxWidth) + .VAlign(VAlign_Center) + [ + ValueWidget.ToSharedRef() + ] + .ExtensionContent() + [ + SNew(SEyeCheckBox, *PropertyRow.GetPropertyHandle()->GetProperty()->GetPathName()) + ]; +} + +void FDeadlineJobPresetCustomization::CustomizeStructChildrenInMovieRenderQueue( + IDetailPropertyRow& PropertyRow, UMoviePipelineDeadlineExecutorJob* Job) const +{ + TSharedPtr NameWidget; + TSharedPtr ValueWidget; + FDetailWidgetRow Row; + PropertyRow.GetDefaultWidgets(NameWidget, ValueWidget, Row); + + const FName PropertyPath = *PropertyRow.GetPropertyHandle()->GetProperty()->GetPathName(); + + ValueWidget->SetEnabled(TAttribute::CreateLambda([Job, PropertyPath]() + { + if (!Job) + { + // Return true so by default all properties are enabled for overrides + return true; + } + + return Job->IsPropertyRowEnabledInMovieRenderJob(PropertyPath); + })); + + PropertyRow + .OverrideResetToDefault( + FResetToDefaultOverride::Create( + FIsResetToDefaultVisible::CreateStatic( &FDeadlineJobPresetCustomization::IsResetToDefaultVisibleOverride, Job), + FResetToDefaultHandler::CreateStatic(&FDeadlineJobPresetCustomization::ResetToDefaultOverride, Job))) + .CustomWidget(true) + .NameContent() + .MinDesiredWidth(Row.NameWidget.MinWidth) + .MaxDesiredWidth(Row.NameWidget.MaxWidth) + .HAlign(HAlign_Fill) + [ + SNew(SHorizontalBox) + + SHorizontalBox::Slot() + .AutoWidth() + .Padding(4, 0) + [ + SNew(SCheckBox) + .IsChecked_Lambda([Job, PropertyPath]() + { + if (!Job) + { + // Return Checked so by default all properties are enabled for overrides + return ECheckBoxState::Checked; + } + + return Job->IsPropertyRowEnabledInMovieRenderJob(PropertyPath) ? ECheckBoxState::Checked : ECheckBoxState::Unchecked; + }) + .OnCheckStateChanged_Lambda([Job, PropertyPath](const ECheckBoxState NewState) + { + if (!Job) + { + return; + } + + return Job->SetPropertyRowEnabledInMovieRenderJob( + PropertyPath, NewState == ECheckBoxState::Checked ? true : false); + }) + ] + + SHorizontalBox::Slot() + [ + NameWidget.ToSharedRef() + ] + ] + .ValueContent() + .MinDesiredWidth(Row.ValueWidget.MinWidth) + .MaxDesiredWidth(Row.ValueWidget.MaxWidth) + .VAlign(VAlign_Center) + [ + ValueWidget.ToSharedRef() + ]; +} + +bool FDeadlineJobPresetCustomization::IsPropertyHiddenInMovieRenderQueue(const FName& InPropertyPath) +{ + if (const UMoviePipelineDeadlineSettings* Settings = GetDefault()) + { + return Settings->GetIsPropertyHiddenInMovieRenderQueue(InPropertyPath); + } + return false; +} + +bool FDeadlineJobPresetCustomization::IsPropertyRowEnabledInMovieRenderJob(const FName& InPropertyPath, + UMoviePipelineDeadlineExecutorJob* Job) +{ + return Job && Job->IsPropertyRowEnabledInMovieRenderJob(InPropertyPath); +} + +bool GetPresetValueAsString(const FProperty* PropertyPtr, UMoviePipelineDeadlineExecutorJob* Job, FString& OutFormattedValue) +{ + if (!PropertyPtr || !Job) + { + return false; + } + + UDeadlineJobPreset* SelectedJobPreset = Job->JobPreset; + if (!SelectedJobPreset) + { + return false; + } + + const void* ValuePtr = PropertyPtr->ContainerPtrToValuePtr(&SelectedJobPreset->JobPresetStruct); + PropertyPtr->ExportText_Direct(OutFormattedValue, ValuePtr, ValuePtr, nullptr, PPF_None); + return true; +} + +bool FDeadlineJobPresetCustomization::IsResetToDefaultVisibleOverride( + TSharedPtr PropertyHandle, UMoviePipelineDeadlineExecutorJob* Job) +{ + if (!PropertyHandle || !Job) + { + return true; + } + + if (FString DefaultValueAsString; GetPresetValueAsString(PropertyHandle->GetProperty(), Job, DefaultValueAsString)) + { + FString CurrentValueAsString; + PropertyHandle->GetValueAsFormattedString(CurrentValueAsString); + + return CurrentValueAsString != DefaultValueAsString; + } + + // If this fails, just show it by default + return true; +} + +void FDeadlineJobPresetCustomization::ResetToDefaultOverride( + TSharedPtr PropertyHandle, UMoviePipelineDeadlineExecutorJob* Job) +{ + if (!PropertyHandle || !Job) + { + return; + } + + if (FString DefaultValueAsString; GetPresetValueAsString(PropertyHandle->GetProperty(), Job, DefaultValueAsString)) + { + PropertyHandle->SetValueFromFormattedString(DefaultValueAsString); + } +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJob.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJob.cpp new file mode 100644 index 0000000000..94b6ed625c --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJob.cpp @@ -0,0 +1,102 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "MoviePipelineDeadlineExecutorJob.h" + +#include "MoviePipelineDeadlineSettings.h" + +UMoviePipelineDeadlineExecutorJob::UMoviePipelineDeadlineExecutorJob() + : UMoviePipelineExecutorJob() +{ + // If a Job Preset is not already defined, assign the default preset + if (!JobPreset) + { + if (const UMoviePipelineDeadlineSettings* MpdSettings = GetDefault()) + { + if (const TObjectPtr DefaultPreset = MpdSettings->DefaultJobPreset) + { + JobPreset = DefaultPreset; + } + } + } +} + +bool UMoviePipelineDeadlineExecutorJob::IsPropertyRowEnabledInMovieRenderJob(const FName& InPropertyPath) const +{ + if (const FPropertyRowEnabledInfo* Match = Algo::FindByPredicate(EnabledPropertyOverrides, + [&InPropertyPath](const FPropertyRowEnabledInfo& Info) + { + return Info.PropertyPath == InPropertyPath; + })) + { + return Match->bIsEnabled; + } + + return false; +} + +void UMoviePipelineDeadlineExecutorJob::SetPropertyRowEnabledInMovieRenderJob(const FName& InPropertyPath, bool bInEnabled) +{ + if (FPropertyRowEnabledInfo* Match = Algo::FindByPredicate(EnabledPropertyOverrides, + [&InPropertyPath](const FPropertyRowEnabledInfo& Info) + { + return Info.PropertyPath == InPropertyPath; + })) + { + Match->bIsEnabled = bInEnabled; + } + else + { + EnabledPropertyOverrides.Add({InPropertyPath, bInEnabled}); + } +} + +void UMoviePipelineDeadlineExecutorJob::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) +{ + // Check if we changed the job Preset an update the override details + if (const FName PropertyName = PropertyChangedEvent.GetPropertyName(); PropertyName == "JobPreset") + { + if (const UDeadlineJobPreset* SelectedJobPreset = this->JobPreset) + { + this->PresetOverrides = SelectedJobPreset->JobPresetStruct; + } + } +} + +FDeadlineJobPresetStruct UMoviePipelineDeadlineExecutorJob::GetDeadlineJobPresetStructWithOverrides() const +{ + // Start with preset properties + FDeadlineJobPresetStruct ReturnValue = JobPreset->JobPresetStruct; + + const UMoviePipelineDeadlineSettings* Settings = GetDefault(); + + for (TFieldIterator PropIt(FDeadlineJobPresetStruct::StaticStruct()); PropIt; ++PropIt) + { + const FProperty* Property = *PropIt; + if (!Property) + { + continue; + } + + const FName PropertyPath = *Property->GetPathName(); + + // Skip hidden properties (just return the preset value) + if (Settings && Settings->GetIsPropertyHiddenInMovieRenderQueue(PropertyPath)) + { + continue; + } + + // Also skip if it's shown but not enabled + if (!IsPropertyRowEnabledInMovieRenderJob(PropertyPath)) + { + continue; + } + + // Get Override Property Value + const void* OverridePropertyValuePtr = Property->ContainerPtrToValuePtr(&PresetOverrides); + + void* ReturnPropertyValuePtr = Property->ContainerPtrToValuePtr(&ReturnValue); + Property->CopyCompleteValue(ReturnPropertyValuePtr, OverridePropertyValuePtr); + } + + return ReturnValue; +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJobCustomization.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJobCustomization.cpp new file mode 100644 index 0000000000..988116a2a6 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineExecutorJobCustomization.cpp @@ -0,0 +1,30 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "MoviePipelineDeadlineExecutorJobCustomization.h" + +#include "DetailCategoryBuilder.h" +#include "DetailLayoutBuilder.h" + +TSharedRef FMoviePipelineDeadlineExecutorJobCustomization::MakeInstance() +{ + return MakeShared(); +} + +void FMoviePipelineDeadlineExecutorJobCustomization::CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) +{ + IDetailCategoryBuilder& MrpCategory = DetailBuilder.EditCategory("Movie Render Pipeline"); + + TArray> OutMrpCategoryProperties; + MrpCategory.GetDefaultProperties(OutMrpCategoryProperties); + + // We hide these properties because we want to use "Name", "UserName" and "Comment" from the Deadline preset + const TArray PropertiesToHide = {"JobName", "Author"}; + + for (const TSharedRef& PropertyHandle : OutMrpCategoryProperties) + { + if (PropertiesToHide.Contains(PropertyHandle->GetProperty()->GetFName())) + { + PropertyHandle->MarkHiddenByCustomization(); + } + } +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineModule.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineModule.cpp new file mode 100644 index 0000000000..c716554d8b --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineModule.cpp @@ -0,0 +1,39 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "MoviePipelineDeadlineModule.h" + +#include "DeadlineJobPreset.h" +#include "DeadlineJobPresetCustomization.h" +#include "MoviePipelineDeadlineExecutorJob.h" +#include "MoviePipelineDeadlineExecutorJobCustomization.h" + +#include "Modules/ModuleManager.h" +#include "PropertyEditorModule.h" + +void FMoviePipelineDeadlineModule::StartupModule() +{ + FPropertyEditorModule& PropertyModule = FModuleManager::LoadModuleChecked("PropertyEditor"); + + PropertyModule.RegisterCustomClassLayout( + UMoviePipelineDeadlineExecutorJob::StaticClass()->GetFName(), + FOnGetDetailCustomizationInstance::CreateStatic(&FMoviePipelineDeadlineExecutorJobCustomization::MakeInstance)); + + PropertyModule.RegisterCustomPropertyTypeLayout( + FDeadlineJobPresetStruct::StaticStruct()->GetFName(), + FOnGetPropertyTypeCustomizationInstance::CreateStatic(&FDeadlineJobPresetCustomization::MakeInstance)); + + PropertyModule.NotifyCustomizationModuleChanged(); +} + +void FMoviePipelineDeadlineModule::ShutdownModule() +{ + if (FPropertyEditorModule* PropertyModule = FModuleManager::Get().GetModulePtr("PropertyEditor")) + { + PropertyModule->UnregisterCustomPropertyTypeLayout(UMoviePipelineDeadlineExecutorJob::StaticClass()->GetFName()); + PropertyModule->UnregisterCustomPropertyTypeLayout(FDeadlineJobPresetStruct::StaticStruct()->GetFName()); + + PropertyModule->NotifyCustomizationModuleChanged(); + } +} + +IMPLEMENT_MODULE(FMoviePipelineDeadlineModule, MoviePipelineDeadline); diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineSettings.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineSettings.cpp new file mode 100644 index 0000000000..ff3c8aa103 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Private/MoviePipelineDeadlineSettings.cpp @@ -0,0 +1,26 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "MoviePipelineDeadlineSettings.h" + +UMoviePipelineDeadlineSettings::UMoviePipelineDeadlineSettings() +{ + const TArray PropertiesToShowByDefault = {"Name", "Comment", "Department", "Pool", "Group", "Priority", "UserName"}; + + // Set up default properties to show in MRQ + // We do this by setting everything to hide except some defined exceptions by name + for (TFieldIterator PropIt(FDeadlineJobPresetStruct::StaticStruct()); PropIt; ++PropIt) + { + const FProperty* Property = *PropIt; + if (!Property) + { + continue; + } + + if (PropertiesToShowByDefault.Contains(Property->GetName())) + { + continue; + } + + JobPresetPropertiesToHideInMovieRenderQueue.Add(*Property->GetPathName()); + } +} \ No newline at end of file diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/DeadlineJobPresetCustomization.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/DeadlineJobPresetCustomization.h new file mode 100644 index 0000000000..af1db76669 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/DeadlineJobPresetCustomization.h @@ -0,0 +1,36 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "IPropertyTypeCustomization.h" + +class IDetailPropertyRow; +class UMoviePipelineDeadlineExecutorJob; + +/** + * This customization lives in the MoviePipelineDeadline module because in order to get + * the preset assigned to the owning job, we need to cast the owning object to the + * UMoviePipelineDeadlineExecutorJob class. We need the assigned preset for the custom + * ResetToDefault behaviour. + */ +class FDeadlineJobPresetCustomization : public IPropertyTypeCustomization +{ +public: + + static TSharedRef< IPropertyTypeCustomization > MakeInstance(); + + /** Begin IPropertyTypeCustomization interface */ + virtual void CustomizeHeader(TSharedRef PropertyHandle, FDetailWidgetRow& HeaderRow, IPropertyTypeCustomizationUtils& CustomizationUtils) override {} + virtual void CustomizeChildren(TSharedRef StructHandle, IDetailChildrenBuilder& ChildBuilder, IPropertyTypeCustomizationUtils& CustomizationUtils) override; + /** End IPropertyTypeCustomization interface */ + + static bool IsPropertyHiddenInMovieRenderQueue(const FName& InPropertyPath); + static bool IsPropertyRowEnabledInMovieRenderJob(const FName& InPropertyPath, UMoviePipelineDeadlineExecutorJob* Job); + +protected: + void CustomizeStructChildrenInAssetDetails(IDetailPropertyRow& PropertyRow) const; + void CustomizeStructChildrenInMovieRenderQueue(IDetailPropertyRow& PropertyRow, UMoviePipelineDeadlineExecutorJob* Job) const; + + static bool IsResetToDefaultVisibleOverride(TSharedPtr PropertyHandle, UMoviePipelineDeadlineExecutorJob* Job); + static void ResetToDefaultOverride(TSharedPtr PropertyHandle, UMoviePipelineDeadlineExecutorJob* Job); +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJob.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJob.h new file mode 100644 index 0000000000..57c1cd9916 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJob.h @@ -0,0 +1,66 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "MoviePipelineQueue.h" +#include "DeadlineJobPreset.h" + +#include "MoviePipelineDeadlineExecutorJob.generated.h" + +USTRUCT() +struct FPropertyRowEnabledInfo +{ + GENERATED_BODY() + + FName PropertyPath; + bool bIsEnabled = false; +}; + +UCLASS(BlueprintType, config = EditorPerProjectUserSettings) +class MOVIEPIPELINEDEADLINE_API UMoviePipelineDeadlineExecutorJob : public UMoviePipelineExecutorJob +{ + GENERATED_BODY() +public: + UMoviePipelineDeadlineExecutorJob(); + + bool IsPropertyRowEnabledInMovieRenderJob(const FName& InPropertyPath) const; + + void SetPropertyRowEnabledInMovieRenderJob(const FName& InPropertyPath, bool bInEnabled); + + /** UObject interface */ + #if WITH_EDITOR + virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override; +#endif + + + /** + * Returns the Deadline job info with overrides applied, if enabled. + * Skips any property not + */ + UFUNCTION(BlueprintCallable, Category = "DeadlineService") + FDeadlineJobPresetStruct GetDeadlineJobPresetStructWithOverrides() const; + + /** `Batch Name` groups similar jobs together in the Deadline Monitor UI. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + FString BatchName; + + /* Deadline Job Preset. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Deadline") + TObjectPtr JobPreset; + + /* Output directory override on Deadline. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + FDirectoryPath OutputDirectoryOverride; + + /* Filename Format override on Deadline. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + FString FilenameFormatOverride; + + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + FDeadlineJobPresetStruct PresetOverrides = FDeadlineJobPresetStruct(); + +protected: + + UPROPERTY(config) + TArray EnabledPropertyOverrides; +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJobCustomization.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJobCustomization.h new file mode 100644 index 0000000000..e0220d2b19 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineExecutorJobCustomization.h @@ -0,0 +1,22 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "IDetailCustomization.h" + +/** + * This customization lives in the MoviePipelineDeadline module because in order to get + * the preset assigned to the owning job, we need to cast the owning object to the + * UMoviePipelineDeadlineExecutorJob class. We need the assigned preset for the custom + * ResetToDefault behaviour. + */ +class FMoviePipelineDeadlineExecutorJobCustomization : public IDetailCustomization +{ +public: + + static TSharedRef< IDetailCustomization > MakeInstance(); + + /** Begin IDetailCustomization interface */ + virtual void CustomizeDetails(IDetailLayoutBuilder& DetailBuilder) override; + /** End IDetailCustomization interface */ +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineModule.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineModule.h new file mode 100644 index 0000000000..603730dda9 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineModule.h @@ -0,0 +1,13 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Modules/ModuleInterface.h" + +class FMoviePipelineDeadlineModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + + virtual void ShutdownModule() override; +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineSettings.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineSettings.h new file mode 100644 index 0000000000..0a53f58ea3 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/MoviePipelineDeadline/Source/MoviePipelineDeadline/Public/MoviePipelineDeadlineSettings.h @@ -0,0 +1,57 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Engine/DeveloperSettings.h" + +#include "DeadlineJobPreset.h" + +#include "MoviePipelineDeadlineSettings.generated.h" + +/** +* Project-wide settings for Deadline Movie Pipeline. +*/ +UCLASS(BlueprintType, config = Editor, defaultconfig, meta = (DisplayName = "Movie Pipeline Deadline")) +class UMoviePipelineDeadlineSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + UMoviePipelineDeadlineSettings(); + + /** Gets the settings container name for the settings, either Project or Editor */ + virtual FName GetContainerName() const override { return FName("Project"); } + /** Gets the category for the settings, some high level grouping like, Editor, Engine, Game...etc. */ + virtual FName GetCategoryName() const override { return FName("Plugins"); } + + /** UObject interface */ + virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override + { + Super::PostEditChangeProperty(PropertyChangedEvent); + SaveConfig(); + } + + /** The project level Deadline preset Data Asset */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Movie Pipeline Deadline") + TObjectPtr DefaultJobPreset; + + void AddPropertyToHideInMovieRenderQueue(const FName& InPropertyPath) + { + JobPresetPropertiesToHideInMovieRenderQueue.Add(InPropertyPath); + } + + void RemovePropertyToHideInMovieRenderQueue(const FName& InPropertyPath) + { + JobPresetPropertiesToHideInMovieRenderQueue.Remove(InPropertyPath); + } + + bool GetIsPropertyHiddenInMovieRenderQueue(const FName& InPropertyPath) const + { + return JobPresetPropertiesToHideInMovieRenderQueue.Contains(InPropertyPath); + } + +protected: + + UPROPERTY(config) + TArray JobPresetPropertiesToHideInMovieRenderQueue; +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/README.md b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/README.md new file mode 100644 index 0000000000..3e7d20595b --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/README.md @@ -0,0 +1,36 @@ +# Deadline Unreal Engine Service plugin +To use this plugin copy the `UnrealDeadlineService` and the `MoviePipelineDeadline` to the `Plugins` directory located in your Unreal Project's directory. + +For further documentation on this plugin, please refer to the [Unreal Engine 5](https://docs.thinkboxsoftware.com/products/deadline/10.3/1_User%20Manual/manual/app-index.html#u) documentation available on our doc website. +> **_Note:_** +> This plugin's web service mode has a dependency on `urllib3` that is not packaged with this +> plugin. To resolve this, execute the `requirements.txt` file in the +> `unreal/UnrealDeadlineService/Content/Python/Lib` directory and save the `urllib3` +> site packages in the `Win64` directory of the above path. +> The engine will automatically add this library to the Python path and make it +> available to the Python interpreter. + +# Unreal Movie Pipeline Deadline plugin + +Although usage documentation for this plugin is a work in progress, +it does not limit the use of other Deadline service features. +This plugin serves as an example of how to use the aforementioned Deadline services. + +> **_Note:_** +> Currently, it is recommended to build the Engine from source as the current +> state of the plugins do not have compiled versions for the released Editor binaries. +> Building the Engine from source allows you to install the necessary dependencies +> for compiling the Engine plugins locally. This issue will be remedied in future releases. Follow +> the instructions on [Downloading Unreal Engine Source Code](https://docs.unrealengine.com/5.1/en-US/downloading-unreal-engine-source-code/) +> to download the Engine versions from source and build the Engine locally. + +# Local Testing + +To test the functionality of the plugins, use the [Meerkat Demo](https://www.unrealengine.com/marketplace/en-US/product/meerkat-demo-02) +from the marketplace. This project is a self-contained cinematic project that +allows you to test movie rendering with the latest version of the Engine binaries. +This is the project we use for internal testing of the plugins. + +> **_Note:_** +> When you enable the plugins for this project, the Engine may need to +> recompile the custom Editor for this project. diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/DefaultUnrealDeadlineService.ini b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/DefaultUnrealDeadlineService.ini new file mode 100644 index 0000000000..0bfa6a8bb4 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/DefaultUnrealDeadlineService.ini @@ -0,0 +1,24 @@ +[CoreRedirects] ++ClassRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct",NewName="/Script/DeadlineService.DeadlineJobInfo") ++ClassRedirects=(OldName="/Script/DeadlineService.DeadlineJobPresetAsset",NewName="/Script/DeadlineService.DeadlineJobPreset") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobPresetLibrary.DeadlineJobPresets",NewName="/Script/DeadlineService.DeadlineJobPreset.JobPresetStruct") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.OverrideTaskExtraInfoNames",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.bOverrideTaskExtraInfoNames") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.TaskExtraInfo",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.TaskExtraInfoNames") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.TaskExtraInfoName",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.TaskExtraInfoNames") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.FramesList",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.Frames") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.PluginInfo",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.PluginInfo") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.SecondaryPool",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.SecondPool") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.JobName",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.Name") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.FrameList",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.Frames") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.bMachineListIsABlacklist",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.bMachineListIsDenylist") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.bMachineListIsAdenylist",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.bMachineListIsDenylist") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.TaskTimeout",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.TaskTimeoutSeconds") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.bEnableAutoTaskTimeout",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.bEnableAutoTimeout") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.Limits",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.LimitGroups") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.Dependencies",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.JobDependencies") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct.FramesPerTask",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.ChunkSize") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobPreset.JobInfo",NewName="/Script/DeadlineService.DeadlineJobPreset.JobPresetStruct") ++PropertyRedirects=(OldName="/Script/DeadlineService.DeadlineJobPresetStruct.PluginInfoPreset",NewName="/Script/DeadlineService.DeadlineJobPresetStruct.PluginInfo") ++ClassRedirects=(OldName="/Script/DeadlineService.DeadlineJobPresetLibrary",NewName="/Script/DeadlineService.DeadlineJobPreset") ++StructRedirects=(OldName="/Script/DeadlineService.DeadlineJobInfoStruct",NewName="/Script/DeadlineService.DeadlineJobPresetStruct") ++FunctionRedirects=(OldName="/Script/DeadlineService.DeadlineServiceEditorHelpers.GetDeadlineJobInfoAsStringMap",NewName="/Script/DeadlineService.DeadlineServiceEditorHelpers.GetDeadlineJobInfo") diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/FilterPlugin.ini b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/FilterPlugin.ini new file mode 100644 index 0000000000..ccebca2f32 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Config/FilterPlugin.ini @@ -0,0 +1,8 @@ +[FilterPlugin] +; This section lists additional files which will be packaged along with your plugin. Paths should be listed relative to the root plugin directory, and +; may include "...", "*", and "?" wildcards to match directories, files, and individual characters respectively. +; +; Examples: +; /README.txt +; /Extras/... +; /Binaries/ThirdParty/*.dll diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/Lib/requirements.txt b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/Lib/requirements.txt new file mode 100644 index 0000000000..a42590bebe --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/Lib/requirements.txt @@ -0,0 +1 @@ +urllib3 diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_command.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_command.py new file mode 100644 index 0000000000..7610f81154 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_command.py @@ -0,0 +1,140 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Built-In +import os +import subprocess +import logging +import tempfile + +# Best-effort import for type annotations +try: + from typing import Any, List, Optional, Tuple, Union +except ImportError: + pass + +logger = logging.getLogger("DeadlineCommand") + +class DeadlineCommand: + """ + Class to manage use of DeadlineCommand + """ + def __init__(self): + self.deadlineCommand = self._get_DeadlineCommand() + + def _get_DeadlineCommand(self): + # type: () -> str + deadlineBin = "" # type: str + try: + deadlineBin = os.environ['DEADLINE_PATH'] + except KeyError: + #if the error is a key error it means that DEADLINE_PATH is not set. however Deadline command may be in the PATH or on OSX it could be in the file /Users/Shared/Thinkbox/DEADLINE_PATH + pass + + # On OSX, we look for the DEADLINE_PATH file if the environment variable does not exist. + if deadlineBin == "" and os.path.exists( "/Users/Shared/Thinkbox/DEADLINE_PATH" ): + with open( "/Users/Shared/Thinkbox/DEADLINE_PATH" ) as f: + deadlineBin = f.read().strip() + + deadlineCommand = os.path.join(deadlineBin, "deadlinecommand") # type: str + + return deadlineCommand + + def get_repository_path(self, subdir = None): + + startupinfo = None + + args = [self.deadlineCommand, "-GetRepositoryPath "] + if subdir != None and subdir != "": + args.append(subdir) + + # Specifying PIPE for all handles to workaround a Python bug on Windows. The unused handles are then closed immediatley afterwards. + logger.debug(f"Getting repository path via deadlinecommand with subprocess args: {args}") + proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo) + + proc.stdin.close() + proc.stderr.close() + + output = proc.stdout.read() + + path = output.decode("utf_8") + path = path.replace("\r","").replace("\n","").replace("\\","/") + + return path + + def get_pools(self): + startupinfo = None + + args = [self.deadlineCommand, "-GetPoolNames"] + + # Specifying PIPE for all handles to workaround a Python bug on Windows. The unused handles are then closed immediatley afterwards. + logger.debug(f"Getting pools via deadlinecommand with subprocess args: {args}") + proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo) + + proc.stdin.close() + proc.stderr.close() + + output = proc.stdout.read() + + path = output.decode("utf_8") + + return path.split(os.linesep) + + def get_groups(self): + startupinfo = None + + args = [self.deadlineCommand, "-GetGroupNames"] + + # Specifying PIPE for all handles to workaround a Python bug on Windows. The unused handles are then closed immediatley afterwards. + logger.debug(f"Getting groupsvia deadlinecommand with subprocess args: {args}") + proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo) + + proc.stdin.close() + proc.stderr.close() + + output = proc.stdout.read() + + path = output.decode("utf_8") + + return path.split(os.linesep) + + def submit_job(self, job_data): + startupinfo = None + + # cast dict to list of strings equivilent to job file and plugin file + job_info = [k+'='+v.replace("\n","").replace("\r","").replace("\t","")+'\n' for k, v in job_data["JobInfo"].items()] + plugin_info = [k+'='+v.replace("\n","").replace("\r","").replace("\t","")+'\n' for k, v in job_data["PluginInfo"].items()] + + with tempfile.NamedTemporaryFile(mode = "w", delete=False) as f_job, tempfile.NamedTemporaryFile(mode = "w", delete=False) as f_plugin: + logger.debug(f"Creating temporary job file {f_job.name}") + logger.debug(f"Creating temporary plugin file {f_plugin.name}") + f_job.writelines(job_info) + f_plugin.writelines(plugin_info) + + f_job.close() + f_plugin.close() + + args = [self.deadlineCommand, "-SubmitJob", f_job.name, f_plugin.name] + args.extend(job_data["aux_files"]) if "aux_files" in job_data else None # If aux files present extend args + # Specifying PIPE for all handles to workaround a Python bug on Windows. The unused handles are then closed immediatley afterwards. + logger.debug(f"Submitting job via deadlinecommand with subprocess args: {args}") + proc = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=startupinfo) + + # On windows machines Temproary files cannot be opened by multiple processes so we cann use the delete=True flag and must clean up the tmp files ourselves. + # https://docs.python.org/3/library/tempfile.html#tempfile.NamedTemporaryFile + proc.wait() + os.remove(f_job.name) + os.remove(f_plugin.name) + logger.debug(f"Removed temporary job file {f_job.name}") + logger.debug(f"Removed temporary plugin file {f_plugin.name}") + + + proc.stdin.close() + proc.stderr.close() + + output = proc.stdout.read() + job_ids = [] + for line in output.decode("utf_8").split(os.linesep): + if line.startswith("JobID"): + job_ids.append(line.split("=")[1].strip()) + + return min(job_ids) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_enums.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_enums.py new file mode 100644 index 0000000000..54e08967d6 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_enums.py @@ -0,0 +1,55 @@ +# Built-in +from enum import Enum, auto + + +class AutoRequestName(Enum): + """ + Function to auto generate the enum value from its name. + Reference: https://docs.python.org/3/library/enum.html#using-automatic-values + """ + def _generate_next_value_(name, start, count, last_values): + return name + + +class HttpRequestType(AutoRequestName): + """ + Enum class for HTTP request types + """ + GET = auto() + PUT = auto() + POST = auto() + DELETE = auto() + + +class DeadlineJobState(Enum): + """Enum class for deadline states""" + + SUSPEND = "suspend" + RESUME = "resume" + REQUEUE = "requeue" + PEND = "pend" + ARCHIVE = "archive" + RESUME_FAILED = "resumefailed" + SUSPEND_NON_RENDERING = "suspendnonrendering" + RELEASE_PENDING = "releasepending" + COMPLETE = "complete" + FAIL = "fail" + UPDATE_SUBMISSION_DATE = "updatesubmissiondate" + UNDELETE = "undelete" + + +class DeadlineJobStatus(Enum): + """ + Enum class for deadline job status + Reference: https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/rest-jobs.html#job-property-values + """ + + UNKNOWN = "Unknown" + ACTIVE = "Active" + SUSPENDED = "Suspended" + COMPLETED = "Completed" + FAILED = "Failed" + RENDERING = "Rendering" + PENDING = "Pending" + IDLE = "Idle" + QUEUED = "Queued" diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_http.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_http.py new file mode 100644 index 0000000000..0ada41a79f --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_http.py @@ -0,0 +1,118 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Built-In +import logging +import json +logger = logging.getLogger("DeadlineHTTP") +try: + # Third-party + from urllib.parse import urljoin + from urllib3 import PoolManager + from urllib3.exceptions import HTTPError +except ImportError: + logger.info("module 'urllib3' not found") +# Internal +from deadline_enums import HttpRequestType + + + + +class DeadlineHttp: + """ + Class to send requests to deadline server + """ + + # ------------------------------------------------------------------------------------------------------------------ + # Magic Methods + + def __init__(self, host): + """ + Constructor + :param str host: Deadline server host + """ + self.host = host + + + # ------------------------------------------------------------------------------------------------------------------ + # Public Methods + + def send_http_request(self, request_type, api_url, payload=None, fields=None, headers=None, retries=0): + """ + This method is used to upload or receive data from the Deadline server. + :param HttpRequestType request_type: HTTP request verb. i.e GET/POST/PUT/DELETE + :param str api_url: URL relative path queries. Example: /jobs , /pools, /jobs?JobID=0000 + :param payload: Data object to POST/PUT to Deadline server + :param dict fields: Request fields. This is typically used in files and binary uploads + :param dict headers: Header data for request + :param int retries: The number of retries to attempt before failing request. Defaults to 0. + :return: JSON object response from the server. + """ + self._http_manager = PoolManager(cert_reqs='CERT_NONE') # Disable SSL certificate check + # Validate request type + if not isinstance(request_type, HttpRequestType): + raise ValueError(f"Request type must be of type {type(HttpRequestType)}") + + response = self._http_manager.request( + request_type.value, + urljoin(self.host, api_url), + body=payload, + fields=fields, + headers=headers, + retries=retries + ) + + return response.data + + def get_job_details(self, job_id): + """ + This method gets the job details for the deadline job + :param str job_id: Deadline JobID + :return: Job details object returned from the server. Usually a Json object + """ + + if not job_id: + raise ValueError(f"A JobID is required to get job details from Deadline. Got {job_id}.") + + api_query_string = f"api/jobs?JobID={job_id}&Details=true" + + job_details = self.send_http_request( + HttpRequestType.GET, + api_query_string + ) + + try: + job_details = json.loads(job_details.decode('utf-8'))[job_id] + + # If an error occurs trying to decode the json data, most likely an error occurred server side thereby + # returning a string instead of the data requested. + # Raise the decoded error + except Exception as err: + raise RuntimeError( + f"An error occurred getting the server data for {job_id}: \n{job_details.decode('utf-8')}" + ) + else: + return job_details + + def send_job_command(self, job_id, command): + """ + Send a command to the Deadline server for the job + :param str job_id: Deadline JobID + :param dict command: Command to send to the deadline server + :return: Returns the response from the server + """ + api_string = urljoin(self.host, "/api/jobs") + + if not job_id: + raise RuntimeError("There is no deadline job ID to send this command for.") + + # Add the job id to the command dictionary + command.update(JobID=job_id) + + response = self.send_http_request( + HttpRequestType.PUT, + api_string, + payload=json.dumps(command).encode('utf-8'), + headers={'Content-Type': 'application/json'} + ) + + return response.decode('utf-8') diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_job.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_job.py new file mode 100644 index 0000000000..630f190a06 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_job.py @@ -0,0 +1,250 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +Deadline Job object used to submit jobs to the render farm +""" + +# Built-In +import logging + +# Third-party +import unreal + +# Internal +from deadline_utils import merge_dictionaries, get_deadline_info_from_preset + +from deadline_enums import DeadlineJobStatus + +logger = logging.getLogger("DeadlineJob") + + +class DeadlineJob: + """ Unreal Deadline Job object """ + + # ------------------------------------------------------------------------------------------------------------------ + # Magic Methods + + def __init__(self, job_info=None, plugin_info=None, job_preset: unreal.DeadlineJobPreset=None): + """ Constructor """ + self._job_id = None + self._job_info = {} + self._plugin_info = {} + self._aux_files = [] + self._job_status: DeadlineJobStatus = DeadlineJobStatus.UNKNOWN + self._job_progress = 0.0 + + # Jobs details updated by server after submission + self._job_details = None + + # Update the job, plugin and aux file info from the data asset + if job_info and plugin_info: + self.job_info = job_info + self.plugin_info = plugin_info + + if job_preset: + self.job_info, self.plugin_info = get_deadline_info_from_preset(job_preset=job_preset) + + def __repr__(self): + return f"{self.__class__.__name__}({self.job_name}, {self.job_id})" + + # ------------------------------------------------------------------------------------------------------------------ + # Public Properties + + @property + def job_info(self): + """ + Returns the Deadline job info + :return: Deadline job Info as a dictionary + :rtype: dict + """ + return self._job_info + + @job_info.setter + def job_info(self, value: dict): + """ + Sets the Deadline Job Info + :param value: Value to set on the job info. + """ + if not isinstance(value, dict): + raise TypeError(f"Expected `dict` found {type(value)}") + + self._job_info = merge_dictionaries(self.job_info, value) + + if "AuxFiles" in self._job_info: + # Set the auxiliary files for this instance + self._aux_files = self._job_info.get("AuxFiles", []) + + # Remove the aux files array from the dictionary, doesn't belong there + self._job_info.pop("AuxFiles") + + @property + def plugin_info(self): + """ + Returns the Deadline plugin info + :return: Deadline plugin Info as a dictionary + :rtype: dict + """ + return self._plugin_info + + @plugin_info.setter + def plugin_info(self, value: dict): + """ + Sets the Deadline Plugin Info + :param value: Value to set on plugin info. + """ + if not isinstance(value, dict): + raise TypeError(f"Expected `dict` found {type(value)}") + + self._plugin_info = merge_dictionaries(self.plugin_info, value) + + @property + def job_id(self): + """ + Return the deadline job ID. This is the ID returned by the service after the job has been submitted + """ + return self._job_id + + @property + def job_name(self): + """ + Return the deadline job name. + """ + return self.job_info.get("Name", "Unnamed Job") + + @job_name.setter + def job_name(self, value): + """ + Updates the job name on the instance. This also updates the job name in the job info dictionary + :param str value: job name + """ + self.job_info.update({"Name": value}) + + @property + def aux_files(self): + """ + Returns the Auxiliary files for this job + :return: List of Auxiliary files + """ + return self._aux_files + + @property + def job_status(self): + """ + Return the current job status + :return: Deadline status + """ + + if not self.job_details: + return DeadlineJobStatus.UNKNOWN + + if "Job" not in self.job_details and "Status" not in self.job_details["Job"]: + return DeadlineJobStatus.UNKNOWN + + # Some Job statuses are represented as "Rendering (1)" to indicate the + # current status of the job and the number of tasks performing the + # current status. We only care about the job status so strip out the + # extra information. Task details are returned to the job details + # object which can be queried in a different implementation + return self.get_job_status_enum(self.job_details["Job"]["Status"].split()[0]) + + @job_status.setter + def job_status(self, value): + """ + Return the current job status + :param DeadlineJobStatus value: Job status to set on the object. + :return: Deadline status + """ + + # Statuses are expected to live in the job details object. Usually this + # property is only explicitly set if the status of a job is unknown. + # for example if the service detects a queried job is non-existent on + # the farm + + # NOTE: If the structure of how job status are represented in the job + # details changes, this implementation will need to be updated. + # Currently job statuses are represented in the jobs details as + # {"Job": {"Status": "Unknown"}} + + # "value" is expected to be an Enum so get the name of the Enum and set + # it on the job details. When the status property is called, + # this will be re-translated back into an enum. The reason for this is, + # the native job details object returned from the service has no + # concept of the job status enum. This is an internal + # representation which allows for more robust comparison operator logic + if self.job_details and isinstance(self.job_details, dict): + self.job_details.update({"Job": {"Status": value.name}}) + + @property + def job_progress(self): + """ + Returns the current job progress + :return: Deadline job progress as a float value + """ + + if not self.job_details: + return 0.0 + + if "Job" in self.job_details and "Progress" in self.job_details["Job"]: + progress_str = self._job_details["Job"]["Progress"] + progress_str = progress_str.split()[0] + + return float(progress_str) / 100 # 0-1 progress + + @property + def job_details(self): + """ + Returns the job details from the deadline service. + :return: Deadline Job details + """ + return self._job_details + + @job_details.setter + def job_details(self, value): + """ + Sets the job details from the deadline service. This is typically set + by the service, but can be used as a general container for job + information. + """ + self._job_details = value + + # ------------------------------------------------------------------------------------------------------------------ + # Public Methods + + def get_submission_data(self): + """ + Returns the submission data used by the Deadline service to submit a job + :return: Dictionary with job, plugin, auxiliary info + :rtype: dict + """ + return { + "JobInfo": self.job_info, + "PluginInfo": self.plugin_info, + "AuxFiles": self.aux_files + } + + # ------------------------------------------------------------------------------------------------------------------ + # Protected Methods + + @staticmethod + def get_job_status_enum(job_status): + """ + This method returns an enum representing the job status from the server + :param job_status: Deadline job status + :return: Returns the job_status as an enum + :rtype DeadlineJobStatus + """ + # Convert this job status returned by the server into the job status + # enum representation + + # Check if the job status name has an enum representation, if not check + # the value of the job_status. + # Reference: https://docs.thinkboxsoftware.com/products/deadline/10.1/1_User%20Manual/manual/rest-jobs.html#job-property-values + try: + status = DeadlineJobStatus(job_status) + except ValueError: + try: + status = getattr(DeadlineJobStatus, job_status) + except Exception as exp: + raise RuntimeError(f"An error occurred getting the Enum status type of {job_status}. Error: \n\t{exp}") + + return status diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/__init__.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/__init__.py new file mode 100644 index 0000000000..bd742b5e25 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/__init__.py @@ -0,0 +1,8 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +from .deadline_toolbar_menu import DeadlineToolBarMenu + + +__all__ = [ + "DeadlineToolBarMenu" +] diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/base_menu_action.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/base_menu_action.py new file mode 100644 index 0000000000..a54a182776 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/base_menu_action.py @@ -0,0 +1,58 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Third-party +import unreal + + +@unreal.uclass() +class BaseActionMenuEntry(unreal.ToolMenuEntryScript): + """ + This is a custom Unreal Class that adds executable python menus to the + Editor + """ + + def __init__(self, callable_method, parent=None): + """ + Constructor + :param callable_method: Callable method to execute + """ + super(BaseActionMenuEntry, self).__init__() + + self._callable = callable_method + self.parent = parent + + @unreal.ufunction(override=True) + def execute(self, context): + """ + Executes the callable method + :param context: + :return: + """ + self._callable() + + @unreal.ufunction(override=True) + def can_execute(self, context): + """ + Determines if a menu can be executed + :param context: + :return: + """ + return True + + @unreal.ufunction(override=True) + def get_tool_tip(self, context): + """ + Returns the tool tip for the menu + :param context: + :return: + """ + return self.data.tool_tip + + @unreal.ufunction(override=True) + def get_label(self, context): + """ + Returns the label of the menu + :param context: + :return: + """ + return self.data.name diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/deadline_toolbar_menu.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/deadline_toolbar_menu.py new file mode 100644 index 0000000000..9163a1a0cf --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_menus/deadline_toolbar_menu.py @@ -0,0 +1,131 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Third party +import unreal + +# Internal +from .base_menu_action import BaseActionMenuEntry + + +class DeadlineToolBarMenu(object): + """ + Class for Deadline Unreal Toolbar menu + """ + + TOOLBAR_NAME = "Deadline" + TOOLBAR_OWNER = "deadline.toolbar.menu" + PARENT_MENU = "LevelEditor.MainMenu" + SECTION_NAME = "deadline_section" + + def __init__(self): + """Constructor""" + + # Keep reference to tool menus from Unreal + self._tool_menus = None + + # Keep track of all the action menus that have been registered to + # Unreal. Without keeping these around, the Unreal GC will remove the + # menu objects and break the in-engine menu + self.menu_entries = [] + + self._top_level_menu = f"{self.PARENT_MENU}.{self.TOOLBAR_NAME}" + + self._initialize_toolbar() + + # Set up a shutdown callback for when python is existing to cleanly + # clear the menus + unreal.register_python_shutdown_callback(self._shutdown) + + @property + def _unreal_tools_menu(self): + """Get Unreal Editor Tool menu""" + if not self._tool_menus or self._tool_menus is None: + self._tool_menus = unreal.ToolMenus.get() + + return self._tool_menus + + def _initialize_toolbar(self): + """Initialize our custom toolbar with the Editor""" + + tools_menu = self._unreal_tools_menu + + # Create the custom menu and add it to Unreal Main Menu + main_menu = tools_menu.extend_menu(self.PARENT_MENU) + + # Create the submenu object + main_menu.add_sub_menu( + self.TOOLBAR_OWNER, + "", + self.TOOLBAR_NAME, + self.TOOLBAR_NAME + ) + + # Register the custom deadline menu to the Editor Main Menu + tools_menu.register_menu( + self._top_level_menu, + "", + unreal.MultiBoxType.MENU, + False + ) + + def _shutdown(self): + """Method to call when the editor is shutting down""" + + # Unregister all menus owned by the integration + self._tool_menus.unregister_owner_by_name(self.TOOLBAR_OWNER) + + # Clean up all the menu instances we are tracking + del self.menu_entries[:] + + def register_submenu( + self, + menu_name, + callable_method, + label_name=None, + description=None + ): + """ + Register a menu to the toolbar. + Note: This currently creates a flat submenu in the Main Menu + + :param str menu_name: The name of the submenu + :param object callable_method: A callable method to execute on menu + activation + :param str label_name: Nice Label name to display the menu + :param str description: Description of the menu. This will eb + displayed in the tooltip + """ + + # Get an instance of a custom `unreal.ToolMenuEntryScript` class + # Wrap it in a try except block for instances where + # the unreal module has not loaded yet. + + try: + entry = BaseActionMenuEntry( + callable_method, + parent=self + ) + menu_entry_name = menu_name.replace(" ", "") + + entry.init_entry( + self.TOOLBAR_OWNER, + f"{self._top_level_menu}.{menu_entry_name}", + menu_entry_name, + label_name or menu_name, + tool_tip=description or "" + ) + + # Add the entry to our tracked list + self.menu_entries.append(entry) + + # Get the registered top level menu + menu = self._tool_menus.find_menu(self._top_level_menu) + + # Add the entry object to the menu + menu.add_menu_entry_object(entry) + + except Exception as err: + raise RuntimeError( + "Its possible unreal hasn't loaded yet. Here's the " + "error that occurred: {err}".format(err=err) + ) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/__init__.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/__init__.py new file mode 100644 index 0000000000..6e68ce8285 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/__init__.py @@ -0,0 +1,8 @@ +from . import client, factory +from .base_ue_rpc import BaseRPC + +__all__ = [ + "client", + "factory", + "BaseRPC" +] diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_server.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_server.py new file mode 100644 index 0000000000..c48e996e47 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_server.py @@ -0,0 +1,272 @@ +import os +import sys +import abc +import queue +import time +import logging +import threading +from xmlrpc.server import SimpleXMLRPCServer + +# importlib machinery needs to be available for importing client modules +from importlib.machinery import SourceFileLoader + +logger = logging.getLogger(__name__) + +EXECUTION_QUEUE = queue.Queue() +RETURN_VALUE_NAME = 'RPC_SERVER_RETURN_VALUE' +ERROR_VALUE_NAME = 'RPC_SERVER_ERROR_VALUE' + + +def run_in_main_thread(callable_instance, *args): + """ + Runs the provided callable instance in the main thread by added it to a que + that is processed by a recurring event in an integration like a timer. + + :param call callable_instance: A callable. + :return: The return value of any call from the client. + """ + timeout = int(os.environ.get('RPC_TIME_OUT', 20)) + + globals().pop(RETURN_VALUE_NAME, None) + globals().pop(ERROR_VALUE_NAME, None) + EXECUTION_QUEUE.put((callable_instance, args)) + + for attempt in range(timeout * 10): + if RETURN_VALUE_NAME in globals(): + return globals().get(RETURN_VALUE_NAME) + elif ERROR_VALUE_NAME in globals(): + raise globals()[ERROR_VALUE_NAME] + else: + time.sleep(0.1) + + if RETURN_VALUE_NAME not in globals(): + raise TimeoutError( + f'The call "{callable_instance.__name__}" timed out because it hit the timeout limit' + f' of {timeout} seconds.' + ) + + +def execute_queued_calls(*extra_args): + """ + Runs calls in the execution que till they are gone. Designed to be passed to a + recurring event in an integration like a timer. + """ + while not EXECUTION_QUEUE.empty(): + if RETURN_VALUE_NAME not in globals(): + callable_instance, args = EXECUTION_QUEUE.get() + try: + globals()[RETURN_VALUE_NAME] = callable_instance(*args) + except Exception as error: + # store the error in the globals and re-raise it + globals()[ERROR_VALUE_NAME] = error + raise error + + +class BaseServer(SimpleXMLRPCServer): + def serve_until_killed(self): + """ + Serves till killed by the client. + """ + self.quit = False + while not self.quit: + self.handle_request() + + +class BaseRPCServer: + def __init__(self, name, port, is_thread=False): + """ + Initialize the base server. + + :param str name: The name of the server. + :param int port: The number of the server port. + :param bool is_thread: Whether or not the server is encapsulated in a thread. + """ + self.server = BaseServer( + (os.environ.get('RPC_HOST', '127.0.0.1'), port), + logRequests=False, + allow_none=True + ) + self.is_thread = is_thread + self.server.register_function(self.add_new_callable) + self.server.register_function(self.kill) + self.server.register_function(self.is_running) + self.server.register_function(self.set_env) + self.server.register_introspection_functions() + self.server.register_multicall_functions() + logger.info(f'Started RPC server "{name}".') + + @staticmethod + def is_running(): + """ + Responds if the server is running. + """ + return True + + @staticmethod + def set_env(name, value): + """ + Sets an environment variable in the server's python environment. + + :param str name: The name of the variable. + :param str value: The value. + """ + os.environ[name] = str(value) + + def kill(self): + """ + Kill the running server from the client. Only if running in blocking mode. + """ + self.server.quit = True + return True + + def add_new_callable(self, callable_name, code, client_system_path, remap_pairs=None): + """ + Adds a new callable defined in the client to the server. + + :param str callable_name: The name of the function that will added to the server. + :param str code: The code of the callable that will be added to the server. + :param list[str] client_system_path: The list of python system paths from the client. + :param list(tuple) remap_pairs: A list of tuples with first value being the client python path root and the + second being the new server path root. This can be useful if the client and server are on two different file + systems and the root of the import paths need to be dynamically replaced. + :return str: A response message back to the client. + """ + for path in client_system_path: + # if a list of remap pairs are provided, they will be remapped before being added to the system path + for client_path_root, matching_server_path_root in remap_pairs or []: + if path.startswith(client_path_root): + path = os.path.join( + matching_server_path_root, + path.replace(client_path_root, '').replace(os.sep, '/').strip('/') + ) + + if path not in sys.path: + sys.path.append(path) + + # run the function code + exec(code) + callable_instance = locals().copy().get(callable_name) + + # grab it from the locals and register it with the server + if callable_instance: + if self.is_thread: + self.server.register_function( + self.thread_safe_call(callable_instance), + callable_name + ) + else: + self.server.register_function( + callable_instance, + callable_name + ) + return f'The function "{callable_name}" has been successfully registered with the server!' + + +class BaseRPCServerThread(threading.Thread, BaseRPCServer): + def __init__(self, name, port): + """ + Initialize the base rpc server. + + :param str name: The name of the server. + :param int port: The number of the server port. + """ + threading.Thread.__init__(self, name=name, daemon=True) + BaseRPCServer.__init__(self, name, port, is_thread=True) + + def run(self): + """ + Overrides the run method. + """ + self.server.serve_forever() + + @abc.abstractmethod + def thread_safe_call(self, callable_instance, *args): + """ + Implements thread safe execution of a call. + """ + return + + +class BaseRPCServerManager: + @abc.abstractmethod + def __init__(self): + """ + Initialize the server manager. + Note: when this class is subclassed `name`, `port`, `threaded_server_class` need to be defined. + """ + self.server_thread = None + self.server_blocking = None + self._server = None + + def start_server_thread(self): + """ + Starts the server in a thread. + """ + self.server_thread = self.threaded_server_class(self.name, self.port) + self._server = self.server_thread.server + self.server_thread.start() + + def start_server_blocking(self): + """ + Starts the server in the main thread, which blocks all other processes. This can only + be killed by the client. + """ + self.server_blocking = BaseRPCServer(self.name, self.port) + self._server = self.server_blocking.server + self._server.serve_until_killed() + + def start(self, threaded=True): + """ + Starts the server. + + :param bool threaded: Whether or not to start the server in a thread. If not threaded + it will block all other processes. + """ + # start the server in a thread + if threaded and not self.server_thread: + self.start_server_thread() + + # start the blocking server + elif not threaded and not self.server_blocking: + self.start_server_blocking() + + else: + logger.info(f'RPC server "{self.name}" is already running...') + + def is_running(self): + """ + Checks to see if a blocking or threaded RPC server is still running + """ + if self._server: + try: + return self._server.is_running() + except (AttributeError, RuntimeError, Exception): + return False + + return False + + def get_server(self): + """ + Returns the rpc server running. This is useful when executing in a + thread and not blocking + """ + if not self._server: + raise RuntimeError("There is no server configured for this Manager") + + return self._server + + def shutdown(self): + """ + Shuts down the server. + """ + if self.server_thread: + logger.info(f'RPC server "{self.name}" is shutting down...') + + # kill the server in the thread + if self._server: + self._server.shutdown() + self._server.server_close() + + self.server_thread.join() + + logger.info(f'RPC server "{self.name}" has shutdown.') diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_ue_rpc.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_ue_rpc.py new file mode 100644 index 0000000000..45bf5706e1 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/base_ue_rpc.py @@ -0,0 +1,241 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +import os +from abc import abstractmethod +import traceback + +from deadline_rpc.client import RPCClient + +import unreal +import __main__ + + +class _RPCContextManager: + """ + Context manager used for automatically marking a task as complete after + the statement is done executing + """ + + def __init__(self, proxy, task_id): + """ + Constructor + """ + # RPC Client proxy + self._proxy = proxy + + # Current task id + self._current_task_id = task_id + + def __enter__(self): + return self._proxy + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Called when the context manager exits + """ + # Tell the server the task is complete + self._proxy.complete_task(self._current_task_id) + + +class BaseRPC: + """ + Base class for communicating with a Deadline RPC server. It is + recommended this class is subclassed for any script that need to + communicate with deadline. The class automatically handles connecting and + marking tasks as complete when some abstract methods are implemented + """ + + def __init__(self, port=None, ignore_rpc=False, verbose=False): + """ + This allows you to get an instance of the class without expecting + an automatic connection to a rpc server. This will allow you to have + a class that can both be executed in a deadline commandline interface or + as a class instance. + :param port: Optional port to connect to + :param ignore_rpc: Flag to short circuit connecting to a rpc server + """ + self._ignore_rpc = ignore_rpc + self._proxy = None + if not self._ignore_rpc: + if not port: + try: + port = os.environ["DEADLINE_RPC_PORT"] + except KeyError: + raise RuntimeError( + "There was no port specified for the rpc server" + ) + + self._port = int(port) + + # Make a connection to the RPC server + self._proxy = self.__establish_connection() + + self.current_task_id = -1 # Setting this to -1 allows us to + # render the first task. i.e task 0 + self._get_next_task = True + self._tick_handle = None + + self._verbose_logging = verbose + + # Set up a property to notify the class when a task is complete + self.__create_on_task_complete_global() + self.task_complete = False + self._sent_task_status = False + + # Start getting tasks to process + self._execute() + + @staticmethod + def __create_on_task_complete_global(): + """ + Creates a property in the globals that allows fire and forget tasks + to notify the class when a task is complete and allowing it to get + the next task + :return: + """ + if not hasattr(__main__, "__notify_task_complete__"): + __main__.__notify_task_complete__ = False + + return __main__.__notify_task_complete__ + + def __establish_connection(self): + """ + Makes a connection to the Deadline RPC server + """ + print(f"Connecting to rpc server on port `{self._port}`") + try: + _client = RPCClient(port=int(self._port)) + proxy = _client.proxy + proxy.connect() + except Exception: + raise + else: + if not proxy.is_connected(): + raise RuntimeError( + "A connection could not be made with the server" + ) + print(f"Connection to server established!") + return proxy + + def _wait_for_next_task(self, delta_seconds): + """ + Checks to see if there are any new tasks and executes when there is + :param delta_seconds: + :return: + """ + + # skip if our task is the same as previous + if self.proxy.get_task_id() == self.current_task_id: + if self._verbose_logging: + print("Waiting on next task..") + return + + print("New task received!") + + # Make sure we are explicitly told the task is complete by clearing + # the globals when we get a new task + __main__.__notify_task_complete__ = False + self.task_complete = False + + # Unregister the tick handle and execute the task + unreal.unregister_slate_post_tick_callback(self._tick_handle) + self._tick_handle = None + + # Set the current task and execute + self.current_task_id = self.proxy.get_task_id() + self._get_next_task = False + + print(f"Executing task `{self.current_task_id}`") + self.proxy.set_status_message("Executing task command") + + # Execute the next task + # Make sure we fail the job if we encounter any exceptions and + # provide the traceback to the proxy server + try: + self.execute() + except Exception: + trace = traceback.format_exc() + print(trace) + self.proxy.fail_render(trace) + raise + + # Start a non-blocking loop that waits till its notified a task is + # complete + self._tick_handle = unreal.register_slate_post_tick_callback( + self._wait_on_task_complete + ) + + def _wait_on_task_complete(self, delta_seconds): + """ + Waits till a task is mark as completed + :param delta_seconds: + :return: + """ + if self._verbose_logging: + print("Waiting on task to complete..") + if not self._sent_task_status: + self.proxy.set_status_message("Waiting on task completion..") + self._sent_task_status = True + if __main__.__notify_task_complete__ or self.task_complete: + + # Exiting the waiting loop + unreal.unregister_slate_post_tick_callback(self._tick_handle) + self._tick_handle = None + + print("Task marked complete. Getting next Task") + self.proxy.set_status_message("Task complete!") + + # Reset the task status notification + self._sent_task_status = False + + # Automatically marks a task complete when the execute function + # exits + with _RPCContextManager(self.proxy, self.current_task_id): + + self._get_next_task = True + + # This will allow us to keep getting tasks till the process is + # closed + self._execute() + + def _execute(self): + """ + Start the execution process + """ + + if self._get_next_task and not self._ignore_rpc: + + # register a callback with the editor that will check and execute + # the task on editor tick + self._tick_handle = unreal.register_slate_post_tick_callback( + self._wait_for_next_task + ) + + @property + def proxy(self): + """ + Returns an instance of the Client proxy + :return: + """ + if not self._proxy: + raise RuntimeError("There is no connected proxy!") + + return self._proxy + + @property + def is_connected(self): + """ + Property that returns if a connection was made with the server + :return: + """ + return self.proxy.is_connected() + + @abstractmethod + def execute(self): + """ + Abstract methods that is executed to perform a task job/command. + This method must be implemented when communicating with a Deadline + RPC server + :return: + """ + pass diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/client.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/client.py new file mode 100644 index 0000000000..d0fc7713c3 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/client.py @@ -0,0 +1,103 @@ +import os +import re +import logging +import inspect +from xmlrpc.client import ( + ServerProxy, + Unmarshaller, + Transport, + ExpatParser, + Fault, + ResponseError +) +logger = logging.getLogger(__package__) + + +class RPCUnmarshaller(Unmarshaller): + def __init__(self, *args, **kwargs): + Unmarshaller.__init__(self, *args, **kwargs) + self.error_pattern = re.compile(r'(?P[^:]*):(?P.*$)') + self.builtin_exceptions = self._get_built_in_exceptions() + + @staticmethod + def _get_built_in_exceptions(): + """ + Gets a list of the built in exception classes in python. + + :return list[BaseException] A list of the built in exception classes in python: + """ + builtin_exceptions = [] + for builtin_name, builtin_class in globals().get('__builtins__').items(): + if inspect.isclass(builtin_class) and issubclass(builtin_class, BaseException): + builtin_exceptions.append(builtin_class) + + return builtin_exceptions + + def close(self): + """ + Override so we redefine the unmarshaller. + + :return tuple: A tuple of marshallables. + """ + if self._type is None or self._marks: + raise ResponseError() + + if self._type == 'fault': + marshallables = self._stack[0] + match = self.error_pattern.match(marshallables.get('faultString', '')) + if match: + exception_name = match.group('exception').strip("") + exception_message = match.group('exception_message') + + if exception_name: + for exception in self.builtin_exceptions: + if exception.__name__ == exception_name: + raise exception(exception_message) + + # if all else fails just raise the fault + raise Fault(**marshallables) + return tuple(self._stack) + + +class RPCTransport(Transport): + def getparser(self): + """ + Override so we can redefine our transport to use its own custom unmarshaller. + + :return tuple(ExpatParser, RPCUnmarshaller): The parser and unmarshaller instances. + """ + unmarshaller = RPCUnmarshaller() + parser = ExpatParser(unmarshaller) + return parser, unmarshaller + + +class RPCServerProxy(ServerProxy): + def __init__(self, *args, **kwargs): + """ + Override so we can redefine the ServerProxy to use our custom transport. + """ + kwargs['transport'] = RPCTransport() + ServerProxy.__init__(self, *args, **kwargs) + + +class RPCClient: + def __init__(self, port, marshall_exceptions=True): + """ + Initializes the rpc client. + + :param int port: A port number the client should connect to. + :param bool marshall_exceptions: Whether or not the exceptions should be marshalled. + """ + if marshall_exceptions: + proxy_class = RPCServerProxy + else: + proxy_class = ServerProxy + + server_ip = os.environ.get('RPC_SERVER_IP', '127.0.0.1') + + self.proxy = proxy_class( + "http://{server_ip}:{port}".format(server_ip=server_ip, port=port), + allow_none=True, + ) + self.marshall_exceptions = marshall_exceptions + self.port = port diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/exceptions.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/exceptions.py new file mode 100644 index 0000000000..b31e4db881 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/exceptions.py @@ -0,0 +1,78 @@ +class BaseRPCException(Exception): + """ + Raised when a rpc class method is not authored as a static method. + """ + def __init__(self, message=None, line_link=''): + self.message = message + line_link + super().__init__(self.message) + + +class InvalidClassMethod(BaseRPCException): + """ + Raised when a rpc class method is not authored as a static method. + """ + def __init__(self, cls, method, message=None, line_link=''): + self.message = message + + if message is None: + self.message = ( + f'\n {cls.__name__}.{method.__name__} is not a static method. Please decorate with @staticmethod.' + ) + BaseRPCException.__init__(self, self.message, line_link) + + +class InvalidTestCasePort(BaseRPCException): + """ + Raised when a rpc test case class does not have a port defined. + """ + def __init__(self, cls, message=None, line_link=''): + self.message = message + + if message is None: + self.message = f'\n You must set {cls.__name__}.port to a supported RPC port.' + BaseRPCException.__init__(self, self.message, line_link) + + +class InvalidKeyWordParameters(BaseRPCException): + """ + Raised when a rpc function has key word arguments in its parameters. + """ + def __init__(self, function, kwargs, message=None, line_link=''): + self.message = message + + if message is None: + self.message = ( + f'\n Keyword arguments "{kwargs}" were found on "{function.__name__}". The RPC client does not ' + f'support key word arguments . Please change your code to use only arguments.' + ) + BaseRPCException.__init__(self, self.message, line_link) + + +class UnsupportedArgumentType(BaseRPCException): + """ + Raised when a rpc function's argument type is not supported. + """ + def __init__(self, function, arg, supported_types, message=None, line_link=''): + self.message = message + + if message is None: + self.message = ( + f'\n "{function.__name__}" has an argument of type "{arg.__class__.__name__}". The only types that are' + f' supported by the RPC client are {[supported_type.__name__ for supported_type in supported_types]}.' + ) + BaseRPCException.__init__(self, self.message, line_link) + + +class FileNotSavedOnDisk(BaseRPCException): + """ + Raised when a rpc function is called in a context where it is not a saved file on disk. + """ + def __init__(self, function, message=None): + self.message = message + + if message is None: + self.message = ( + f'\n "{function.__name__}" is not being called from a saved file. The RPC client does not ' + f'support code that is not saved. Please save your code to a file on disk and re-run it.' + ) + BaseRPCException.__init__(self, self.message) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/factory.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/factory.py new file mode 100644 index 0000000000..b5714f87c0 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/factory.py @@ -0,0 +1,249 @@ +import os +import re +import sys +import logging +import types +import inspect +import textwrap +import unittest +from xmlrpc.client import Fault + +from .client import RPCClient +from .validations import ( + validate_key_word_parameters, + validate_class_method, + get_source_file_path, + get_line_link, + validate_arguments, + validate_file_is_saved, +) + +logger = logging.getLogger(__package__) + + +class RPCFactory: + def __init__(self, rpc_client, remap_pairs=None, default_imports=None): + self.rpc_client = rpc_client + self.file_path = None + self.remap_pairs = remap_pairs + self.default_imports = default_imports or [] + + def _get_callstack_references(self, code, function): + """ + Gets all references for the given code. + + :param list[str] code: The code of the callable. + :param callable function: A callable. + :return str: The new code of the callable with all its references added. + """ + import_code = self.default_imports + + client_module = inspect.getmodule(function) + self.file_path = get_source_file_path(function) + + # if a list of remap pairs have been set, the file path will be remapped to the new server location + # Note: The is useful when the server and client are not on the same machine. + server_module_path = self.file_path + for client_path_root, matching_server_path_root in self.remap_pairs or []: + if self.file_path.startswith(client_path_root): + server_module_path = os.path.join( + matching_server_path_root, + self.file_path.replace(client_path_root, '').replace(os.sep, '/').strip('/') + ) + break + + for key in dir(client_module): + for line_number, line in enumerate(code): + if line.startswith('def '): + continue + + if key in re.split('\.|\(| ', line.strip()): + if os.path.basename(self.file_path) == '__init__.py': + base_name = os.path.basename(os.path.dirname(self.file_path)) + else: + base_name = os.path.basename(self.file_path) + + module_name, file_extension = os.path.splitext(base_name) + import_code.append( + f'{module_name} = SourceFileLoader("{module_name}", r"{server_module_path}").load_module()' + ) + import_code.append(f'from {module_name} import {key}') + break + + return textwrap.indent('\n'.join(import_code), ' ' * 4) + + def _get_code(self, function): + """ + Gets the code from a callable. + + :param callable function: A callable. + :return str: The code of the callable. + """ + code = textwrap.dedent(inspect.getsource(function)).split('\n') + code = [line for line in code if not line.startswith('@')] + + # get import code and insert them inside the function + import_code = self._get_callstack_references(code, function) + code.insert(1, import_code) + + # log out the generated code + if os.environ.get('RPC_LOG_CODE'): + for line in code: + logger.debug(line) + + return code + + def _register(self, function): + """ + Registers a given callable with the server. + + :param callable function: A callable. + :return Any: The return value. + """ + code = self._get_code(function) + try: + # if additional paths are explicitly set, then use them. This is useful with the client is on another + # machine and the python paths are different + additional_paths = list(filter(None, os.environ.get('RPC_ADDITIONAL_PYTHON_PATHS', '').split(','))) + + if not additional_paths: + # otherwise use the current system path + additional_paths = sys.path + + response = self.rpc_client.proxy.add_new_callable( + function.__name__, '\n'.join(code), + additional_paths + ) + if os.environ.get('RPC_DEBUG'): + logger.debug(response) + + except ConnectionRefusedError: + server_name = os.environ.get(f'RPC_SERVER_{self.rpc_client.port}', self.rpc_client.port) + raise ConnectionRefusedError(f'No connection could be made with "{server_name}"') + + def run_function_remotely(self, function, args): + """ + Handles running the given function on remotely. + + :param callable function: A function reference. + :param tuple(Any) args: The function's arguments. + :return callable: A remote callable. + """ + validate_arguments(function, args) + + # get the remote function instance + self._register(function) + remote_function = getattr(self.rpc_client.proxy, function.__name__) + + current_frame = inspect.currentframe() + outer_frame_info = inspect.getouterframes(current_frame) + # step back 2 frames in the callstack + caller_frame = outer_frame_info[2][0] + # create a trace back that is relevant to the remote code rather than the code transporting it + call_traceback = types.TracebackType(None, caller_frame, caller_frame.f_lasti, caller_frame.f_lineno) + # call the remote function + if not self.rpc_client.marshall_exceptions: + # if exceptions are not marshalled then receive the default Faut + return remote_function(*args) + + # otherwise catch them and add a line link to them + try: + return remote_function(*args) + except Exception as exception: + stack_trace = str(exception) + get_line_link(function) + if isinstance(exception, Fault): + raise Fault(exception.faultCode, exception.faultString) + raise exception.__class__(stack_trace).with_traceback(call_traceback) + + +def remote_call(port, default_imports=None, remap_pairs=None): + """ + A decorator that makes this function run remotely. + + :param Enum port: The name of the port application i.e. maya, blender, unreal. + :param list[str] default_imports: A list of import commands that include modules in every call. + :param list(tuple) remap_pairs: A list of tuples with first value being the client file path root and the + second being the matching server path root. This can be useful if the client and server are on two different file + systems and the root of the import paths need to be dynamically replaced. + """ + def decorator(function): + def wrapper(*args, **kwargs): + validate_file_is_saved(function) + validate_key_word_parameters(function, kwargs) + rpc_factory = RPCFactory( + rpc_client=RPCClient(port), + remap_pairs=remap_pairs, + default_imports=default_imports + ) + return rpc_factory.run_function_remotely(function, args) + return wrapper + return decorator + + +def remote_class(decorator): + """ + A decorator that makes this class run remotely. + + :param remote_call decorator: The remote call decorator. + :return: A decorated class. + """ + def decorate(cls): + for attribute, value in cls.__dict__.items(): + validate_class_method(cls, value) + if callable(getattr(cls, attribute)): + setattr(cls, attribute, decorator(getattr(cls, attribute))) + return cls + return decorate + + +class RPCTestCase(unittest.TestCase): + """ + Subclasses unittest.TestCase to implement a RPC compatible TestCase. + """ + port = None + remap_pairs = None + default_imports = None + + @classmethod + def run_remotely(cls, method, args): + """ + Run the given method remotely. + + :param callable method: A method to wrap. + """ + default_imports = cls.__dict__.get('default_imports', None) + port = cls.__dict__.get('port', None) + remap_pairs = cls.__dict__.get('remap_pairs', None) + rpc_factory = RPCFactory( + rpc_client=RPCClient(port), + default_imports=default_imports, + remap_pairs=remap_pairs + ) + return rpc_factory.run_function_remotely(method, args) + + def _callSetUp(self): + """ + Overrides the TestCase._callSetUp method by passing it to be run remotely. + Notice None is passed as an argument instead of self. This is because only static methods + are allowed by the RPCClient. + """ + self.run_remotely(self.setUp, [None]) + + def _callTearDown(self): + """ + Overrides the TestCase._callTearDown method by passing it to be run remotely. + Notice None is passed as an argument instead of self. This is because only static methods + are allowed by the RPCClient. + """ + # notice None is passed as an argument instead of self so self can't be used + self.run_remotely(self.tearDown, [None]) + + def _callTestMethod(self, method): + """ + Overrides the TestCase._callTestMethod method by capturing the test case method that would be run and then + passing it to be run remotely. Notice no arguments are passed. This is because only static methods + are allowed by the RPCClient. + + :param callable method: A method from the test case. + """ + self.run_remotely(method, []) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/server.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/server.py new file mode 100644 index 0000000000..750e8f978b --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/server.py @@ -0,0 +1,29 @@ +import os +import sys +sys.path.append(os.path.dirname(__file__)) + +from base_server import BaseRPCServerManager, BaseRPCServerThread + + +class RPCServerThread(BaseRPCServerThread): + def thread_safe_call(self, callable_instance, *args): + """ + Implementation of a thread safe call in Unreal. + """ + return callable_instance(*args) + + +class RPCServer(BaseRPCServerManager): + def __init__(self, port=None): + """ + Initialize the blender rpc server, with its name and specific port. + """ + super(RPCServer, self).__init__() + self.name = 'RPCServer' + self.port = int(os.environ.get('RPC_PORT', port)) + self.threaded_server_class = RPCServerThread + + +if __name__ == '__main__': + rpc_server = RPCServer() + rpc_server.start(threaded=False) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/validations.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/validations.py new file mode 100644 index 0000000000..e4a9587700 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_rpc/validations.py @@ -0,0 +1,105 @@ +import inspect + +from .exceptions import ( + InvalidClassMethod, + InvalidTestCasePort, + InvalidKeyWordParameters, + UnsupportedArgumentType, + FileNotSavedOnDisk, +) + + +def get_source_file_path(function): + """ + Gets the full path to the source code. + + :param callable function: A callable. + :return str: A file path. + """ + client_module = inspect.getmodule(function) + return client_module.__file__ + + +def get_line_link(function): + """ + Gets the line number of a function. + + :param callable function: A callable. + :return int: The line number + """ + lines, line_number = inspect.getsourcelines(function) + file_path = get_source_file_path(function) + return f' File "{file_path}", line {line_number}' + + +def validate_arguments(function, args): + """ + Validates arguments to ensure they are a supported type. + + :param callable function: A function reference. + :param tuple(Any) args: A list of arguments. + """ + supported_types = [str, int, float, tuple, list, dict, bool] + line_link = get_line_link(function) + for arg in args: + if arg is None: + continue + + if type(arg) not in supported_types: + raise UnsupportedArgumentType(function, arg, supported_types, line_link=line_link) + + +def validate_test_case_class(cls): + """ + This is use to validate a subclass of RPCTestCase. While building your test + suite you can call this method on each class preemptively to validate that it + was defined correctly. + + :param RPCTestCase cls: A class. + :param str file_path: Optionally, a file path to the test case can be passed to give + further context into where the error is occurring. + """ + line_link = get_line_link(cls) + if not cls.__dict__.get('port'): + raise InvalidTestCasePort(cls, line_link=line_link) + + for attribute, method in cls.__dict__.items(): + if callable(method) and not isinstance(method, staticmethod): + if method.__name__.startswith('test'): + raise InvalidClassMethod(cls, method, line_link=line_link) + + +def validate_class_method(cls, method): + """ + Validates a method on a class. + + :param Any cls: A class. + :param callable method: A callable. + """ + if callable(method) and not isinstance(method, staticmethod): + line_link = get_line_link(method) + raise InvalidClassMethod(cls, method, line_link=line_link) + + +def validate_key_word_parameters(function, kwargs): + """ + Validates a method on a class. + + :param callable function: A callable. + :param dict kwargs: A dictionary of key word arguments. + """ + if kwargs: + line_link = get_line_link(function) + raise InvalidKeyWordParameters(function, kwargs, line_link=line_link) + + +def validate_file_is_saved(function): + """ + Validates that the file that the function is from is saved on disk. + + :param callable function: A callable. + """ + try: + inspect.getsourcelines(function) + except OSError: + raise FileNotSavedOnDisk(function) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_service.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_service.py new file mode 100644 index 0000000000..beee4a70a0 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_service.py @@ -0,0 +1,755 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +Thinkbox Deadline REST API service plugin used to submit and query jobs from a Deadline server +""" + +# Built-in +import json +import logging +import platform + +from getpass import getuser +from threading import Thread, Event + +# Internal +from deadline_job import DeadlineJob +from deadline_http import DeadlineHttp +from deadline_enums import DeadlineJobState, DeadlineJobStatus, HttpRequestType +from deadline_utils import get_editor_deadline_globals +from deadline_command import DeadlineCommand + +# Third-party +import unreal + +logger = logging.getLogger("DeadlineService") +logger.setLevel(logging.INFO) + + +class _Singleton(type): + """ + Singleton metaclass for the Deadline service + """ + # ------------------------------------------------------------------------------------------------------------------ + # Class Variables + + _instances = {} + + # ------------------------------------------------------------------------------------------------------------------ + # Magic Methods + + def __call__(cls, *args, **kwargs): + """ + Determines the initialization behavior of this class + """ + if cls not in cls._instances: + cls._instances[cls] = super(_Singleton, cls).__call__(*args, **kwargs) + + return cls._instances[cls] + + +# TODO: Make this a native Subsystem in the editor +class DeadlineService(metaclass=_Singleton): + """ + Singleton class to handle Deadline submissions. + We are using a singleton class as there is no need to have multiple instances of the service. This allows job + queries and submissions to be tracked by a single entity and be a source of truth on the client about all jobs + created in the current session. + """ + + # ------------------------------------------------------------------------------------------------------------------ + # Magic Methods + + def __init__(self, host=None, auto_start_job_updates=False, service_update_interval=1.0): + """ + Deadline service class for submitting jobs to deadline and querying data from deadline + :param str host: Deadline host + :param bool auto_start_job_updates: This flag auto starts processing jobs when the service is initialized + tracked by the service + :param float service_update_interval: Interval(seconds) for job update frequency. Default is 2.0 seconds + """ + + # Track a dictionary of jobs registered with the service. This dictionary contains job object instance ID and a + # reference to the job instance object and deadline job ID. + # i.e {"instance_object_id": {"object": , "job_id": 0001 or None}} + self._current_jobs = {} + self._submitted_jobs = {} # Similar to the current jobs, this tracks all jobs submitted + self._failed_jobs = set() + self._completed_jobs = set() + + # This flag determines if the service should deregister a job when it fails on the server + self.deregister_job_on_failure = True + + # Thread execution variables + self._event_thread = None + self._exit_auto_update = False + self._update_thread_event = Event() + + self._service_update_interval = service_update_interval + + # A timer for executing job update functions on an interval + self._event_timer_manager = self.get_event_manager() + self._event_handler = None + + # Use DeadlineCommand by defaut + self._use_deadline_command = self._get_use_deadline_cmd() # True # TODO: hardcoded for testing, change to project read setting + + # Get/Set service host + self._host = host or self._get_deadline_host() + + # Get deadline https instance + self._http_server = DeadlineHttp(self.host) + + if auto_start_job_updates: + self.start_job_updates() + + # ------------------------------------------------------------------------------------------------------------------ + # Public Properties + + @property + def pools(self): + """ + Returns the current list of pools found on the server + :return: List of pools on the server + """ + return self._get_pools() + + @property + def groups(self): + """ + Returns the current list of groups found on the server + :return: List of groups on the server + """ + return self._get_groups() + + @property + def use_deadline_command(self): + """ + Returns the current value of the use deadline command flag + :return: True if the service uses the deadline command, False otherwise + """ + return self._use_deadline_command + + @use_deadline_command.setter + def use_deadline_command(self, value): + """ + Sets the use deadline command flag + :param value: True if the service uses the deadline command, False otherwise + """ + self._use_deadline_command = value + + @property + def host(self): + """ + Returns the server url used by the service + :return: Service url + """ + return self._host + + @host.setter + def host(self, value): + """ + Set the server host on the service + :param value: host value + """ + self._host = value + + # When the host service is updated, get a new connection to that host + self._http_server = DeadlineHttp(self._host) + + @property + def current_jobs(self): + """ + Returns the global current jobs tracked by the service + :return: List of Jobs tracked by the service + """ + return [value["object"] for value in self._current_jobs.values()] + + @property + def failed_jobs(self): + """ + Returns the failed jobs tracked by the service + :return: List of failed Jobs tracked by the service + """ + return self._failed_jobs + + @property + def completed_jobs(self): + """ + Returns the completed jobs tracked by the service + :return: List of completed Jobs tracked by the service + """ + return self._completed_jobs + + # ------------------------------------------------------------------------------------------------------------------ + # Protected Methods + + def _get_pools(self): + """ + This method updates the set of pools tracked by the service + """ + if self._get_use_deadline_cmd(): # if self._use_deadline_command: + return DeadlineCommand().get_pools() + else: + response = self.send_http_request( + HttpRequestType.GET, + "api/pools", + headers={'Content-Type': 'application/json'} + ) + return json.loads(response.decode('utf-8')) + + def _get_groups(self): + """ + This method updates the set of groups tracked by the service + """ + if self._get_use_deadline_cmd(): # if self._use_deadline_command: + return DeadlineCommand().get_groups() + else: + response = self.send_http_request( + HttpRequestType.GET, + "api/groups", + headers={'Content-Type': 'application/json'} + ) + return json.loads(response.decode('utf-8')) + + def _register_job(self, job_object, deadline_job_id=None): + """ + This method registers the job object with the service + :param DeadlineJob job_object: Deadline Job object + :param str deadline_job_id: ID of job returned from the server + """ + + # Set the job Id on the job. The service + # should be allowed to set this protected property on the job object as this property should natively + # not be allowed to be set externally + job_object._job_id = deadline_job_id + + job_data = { + str(id(job_object)): + { + "object": job_object, + "job_id": deadline_job_id + } + } + + self._submitted_jobs.update(job_data) + self._current_jobs.update(job_data) + + def _deregister_job(self, job_object): + """ + This method removes the current job object from the tracked jobs + :param DeadlineJob job_object: Deadline job object + """ + + if str(id(job_object)) in self._current_jobs: + self._current_jobs.pop(str(id(job_object)), f"{job_object} could not be found") + + def _update_tracked_job_by_status(self, job_object, job_status, update_job=False): + """ + This method moves the job object from the tracked list based on the current job status + :param DeadlineJob job_object: Deadline job object + :param DeadlineJobStatus job_status: Deadline job status + :param bool update_job: Flag to update the job object's status to the passed in job status + """ + + # Convert the job status into the appropriate enum. This will raise an error if the status enum does not exist. + # If a valid enum is passed into this function, the enum is return + job_status = job_object.get_job_status_enum(job_status) + + # If the job has an unknown status, remove it from the currently tracked jobs by the service. Note we are not + # de-registering failed jobs unless explicitly set, that's because a failed job can be re-queued and + # completed on the next try. + # So we do not want to preemptively remove this job from the tracked jobs by the service. + if job_status is DeadlineJobStatus.UNKNOWN: + self._deregister_job(job_object) + self._failed_jobs.add(job_object) + + elif job_status is DeadlineJobStatus.COMPLETED: + self._deregister_job(job_object) + self._completed_jobs.add(job_object) + + elif job_status is DeadlineJobStatus.FAILED: + if self.deregister_job_on_failure: + self._deregister_job(job_object) + self._failed_jobs.add(job_object) + + if update_job: + job_object.job_status = job_status + + # ------------------------------------------------------------------------------------------------------------------ + # Public Methods + + def send_http_request(self, request_type, api_url, payload=None, fields=None, headers=None, retries=0): + """ + This method is used to upload or receive data from the Deadline server. + :param HttpRequestType request_type: HTTP request verb. i.e GET/POST/PUT/DELETE + :param str api_url: URL relative path queries. Example: /jobs , /pools, /jobs?JobID=0000 + :param payload: Data object to POST/PUT to Deadline server + :param dict fields: Request fields. This is typically used in files and binary uploads + :param dict headers: Header data for request + :param int retries: The number of retries to attempt before failing request. Defaults to 0. + :return: JSON object response from the server + """ + + # Make sure we always have the most up-to-date host + if not self.host or (self.host != self._get_deadline_host()): + self.host = self._get_deadline_host() + + try: + response = self._http_server.send_http_request( + request_type, + api_url, + payload=payload, + fields=fields, + headers=headers, + retries=retries + ) + + except Exception as err: + raise DeadlineServiceError(f"Communication with {self.host} failed with err: \n{err}") + else: + return response + + def submit_job(self, job_object): + """ + This method submits the tracked job to the Deadline server + :param DeadlineJob job_object: Deadline Job object + :returns: Deadline `JobID` if an id was returned from the server + """ + self._validate_job_object(job_object) + + logger.debug(f"Submitting {job_object} to {self.host}..") + + if str(id(job_object)) in self._current_jobs: + logger.warning(f"{job_object} has already been added to the service") + + # Return the job ID of the submitted job + return job_object.job_id + + job_id = None + + job_data = job_object.get_submission_data() + + # Set the job data to return the job ID on submission + job_data.update(IdOnly="true") + + # Update the job data to include the user and machine submitting the job + # Update the username if one is not supplied + if "UserName" not in job_data["JobInfo"]: + + # NOTE: Make sure this matches the expected naming convention by the server else the user will get + # permission errors on job submission + # Todo: Make sure the username convention matches the username on the server + job_data["JobInfo"].update(UserName=getuser()) + + job_data["JobInfo"].update(MachineName=platform.node()) + + self._validate_job_info(job_data["JobInfo"]) + + if self._get_use_deadline_cmd(): # if self._use_deadline_command: + # Submit the job to the Deadline server using the Deadline command + # Todo: Add support for the Deadline command + job_id = DeadlineCommand().submit_job(job_data) + + else: + # Submit the job to the Deadline server using the HTTP API + try: + response = self.send_http_request( + HttpRequestType.POST, + "api/jobs", + payload=json.dumps(job_data).encode('utf-8'), + headers={'Content-Type': 'application/json'} + ) + + except DeadlineServiceError as exp: + logger.error( + f"An error occurred submitting {job_object} to Deadline host `{self.host}`.\n\t{str(exp)}" + ) + self._failed_jobs.add(job_object) + + else: + try: + response = json.loads(response.decode('utf-8')) + + # If an error occurs trying to decode the json data, most likely an error occurred server side thereby + # returning a string instead of the data requested. + # Raise the decoded error + except Exception as err: + raise DeadlineServiceError(f"An error occurred getting the server data:\n\t{response.decode('utf-8')}") + + job_id = response.get('_id', None) + if not job_id: + logger.warning( + f"No JobId was returned from the server for {job_object}. " + f"The service will not be able to get job details for this job!" + ) + else: + # Register the job with the service. + self._register_job(job_object, job_id) + + logger.info(f"Submitted `{job_object.job_name}` to Deadline. JobID: {job_id}") + + return job_id + + def get_job_details(self, job_object): + """ + This method gets the job details for the Deadline job + :param DeadlineJob job_object: Custom Deadline job object + :return: Job details object returned from the server. Usually a Json object + """ + + self._validate_job_object(job_object) + + if str(id(job_object)) not in self._current_jobs: + logger.warning( + f"{job_object} is currently not tracked by the service. The job has either not been submitted, " + f"its already completed or there was a problem with the job!" + ) + elif not job_object.job_id: + logger.error( + f"There is no JobID for {job_object}!" + ) + else: + + try: + job_details = self._http_server.get_job_details(job_object.job_id) + + except (Exception, RuntimeError): + # If an error occurred, most likely the job does not exist on the server anymore. Mark the job as + # unknown + self._update_tracked_job_by_status(job_object, DeadlineJobStatus.UNKNOWN, update_job=True) + else: + # Sometimes Deadline returns a status with a parenthesis after the status indicating the number of tasks + # executing. We only care about the status here so lets split the number of tasks out. + self._update_tracked_job_by_status(job_object, job_details["Job"]["Status"].split()[0]) + return job_details + + def send_job_command(self, job_object, command): + """ + Send a command to the Deadline server for the job + :param DeadlineJob job_object: Deadline job object + :param dict command: Command to send to the Deadline server + :return: Returns the response from the server + """ + self._validate_job_object(job_object) + + if not job_object.job_id: + raise RuntimeError("There is no Deadline job ID to send this command for.") + + try: + response = self._http_server.send_job_command( + job_object.job_id, + command + ) + except Exception as exp: + logger.error( + f"An error occurred getting the command result for {job_object} from Deadline host {self.host}. " + f"\n{exp}" + ) + return "Fail" + else: + if response != "Success": + logger.error(f"An error occurred executing command for {job_object}. \nError: {response}") + return "Fail" + + return response + + def change_job_state(self, job_object, state): + """ + This modifies a submitted job's state on the Deadline server. This can be used in job orchestration. For example + a job can be submitted as suspended/pending and this command can be used to update the state of the job to + active after submission. + :param DeadlineJob job_object: Deadline job object + :param DeadlineJobState state: State to set the job + :return: Submission results + """ + + self._validate_job_object(job_object) + + # Validate jobs state + if not isinstance(state, DeadlineJobState): + raise ValueError(f"`{state}` is not a valid state.") + + return self.send_job_command(job_object, {"Command": state.value}) + + def start_job_updates(self): + """ + This method starts an auto update on jobs in the service. + + The purpose of this system is to allow the service to automatically update the job details from the server. + This allows you to submit a job from your implementation and periodically poll the changes on the job as the + service will continuously update the job details. + Note: This function must explicitly be called or the `auto_start_job_updates` flag must be passed to the service + instance for this functionality to happen. + """ + # Prevent the event from being executed several times in succession + if not self._event_handler: + if not self._event_thread: + + # Create a thread for the job update function. This function takes the current list of jobs + # tracked by the service. The Thread owns an instance of the http connection. This allows the thread + # to have its own pool of http connections separate from the main service. A thread event is passed + # into the thread which allows the process events from the timer to reactivate the function. The + # purpose of this is to prevent unnecessary re-execution while jobs are being processed. + # This also allows the main service to stop function execution within the thread and allow it to cleanly + # exit. + + # HACK: For some odd reason, passing an instance of the service into the thread seems to work as + # opposed to passing in explicit variables. I would prefer explicit variables as the thread does not + # need to have access to the entire service object + + # Threading is used here as the editor runs python on the game thread. If a function call is + # executed on an interval (as this part of the service is designed to do), this will halt the editor + # every n interval to process the update event. A separate thread for processing events allows the + # editor to continue functions without interfering with the editor + + # TODO: Figure out a way to have updated variables in the thread vs passing the whole service instance + self._event_thread = Thread( + target=self._update_all_jobs, + args=(self,), + name="deadline_service_auto_update_thread", + daemon=True + ) + + # Start the thread + self._event_thread.start() + + else: + # If the thread is stopped, restart it. + if not self._event_thread.is_alive(): + self._event_thread.start() + + def process_events(): + """ + Function ran by the tick event for monitoring function execution inside of the auto update thread. + """ + # Since the editor ticks at a high rate, this monitors the current state of the function execution in + # the update thread. When a function is done executing, this resets the event on the function. + logger.debug("Processing current jobs.") + if self._update_thread_event.is_set(): + + logger.debug("Job processing complete, restarting..") + # Send an event to tell the thread to start the job processing loop + self._update_thread_event.clear() + + # Attach the thread executions to a timer event + self._event_timer_manager.on_timer_interval_delegate.add_callable(process_events) + + # Start the timer on an interval + self._event_handler = self._event_timer_manager.start_timer(self._service_update_interval) + + # Allow the thread to stop when a python shutdown is detected + unreal.register_python_shutdown_callback(self.stop_job_updates) + + def stop_job_updates(self): + """ + This method stops the auto update thread. This method should be explicitly called to stop the service from + continuously updating the current tracked jobs. + """ + if self._event_handler: + + # Remove the event handle to the tick event + self.stop_function_timer(self._event_timer_manager, self._event_handler) + self._event_handler = None + + if self._event_thread and self._event_thread.is_alive(): + # Force stop the thread + self._exit_auto_update = True + + # immediately stop the thread. Do not wait for jobs to complete. + self._event_thread.join(1.0) + + # Usually if a thread is still alive after a timeout, then something went wrong + if self._event_thread.is_alive(): + logger.error("An error occurred closing the auto update Thread!") + + # Reset the event, thread and tick handler + self._update_thread_event.set() + self._event_thread = None + + def get_job_object_by_job_id(self, job_id): + """ + This method returns the job object tracked by the service based on the deadline job ID + :param job_id: Deadline job ID + :return: DeadlineJob object + :rtype DeadlineJob + """ + + job_object = None + + for job in self._submitted_jobs.values(): + if job_id == job["job_id"]: + job_object = job["object"] + break + + return job_object + + # ------------------------------------------------------------------------------------------------------------------ + # Static Methods + + @staticmethod + def _validate_job_info(job_info): + """ + This method validates the job info dictionary to make sure + the information provided meets a specific standard + :param dict job_info: Deadline job info dictionary + :raises ValueError + """ + + # validate the job info plugin settings + if "Plugin" not in job_info or (not job_info["Plugin"]): + raise ValueError("No plugin was specified in the Job info dictionary") + + @staticmethod + def _get_use_deadline_cmd(): + """ + Returns the deadline command flag settings from the unreal project settings + :return: Deadline command settings unreal project + """ + try: + # This will be set on the deadline editor project settings + deadline_settings = unreal.get_default_object(unreal.DeadlineServiceEditorSettings) + + # Catch any other general exceptions + except Exception as exc: + unreal.log( + f"Caught Exception while getting use deadline command flag. Error: {exc}" + ) + + else: + return deadline_settings.deadline_command + + @staticmethod + def _get_deadline_host(): + """ + Returns the host settings from the unreal project settings + :return: Deadline host settings unreal project + """ + try: + # This will be set on the deadline editor project settings + deadline_settings = unreal.get_default_object(unreal.DeadlineServiceEditorSettings) + + # Catch any other general exceptions + except Exception as exc: + unreal.log( + f"Caught Exception while getting deadline host. Error: {exc}" + ) + + else: + return deadline_settings.deadline_host + + @staticmethod + def _validate_job_object(job_object): + """ + This method ensures the object passed in is of type DeadlineJob + :param DeadlineJob job_object: Python object + :raises: RuntimeError if the job object is not of type DeadlineJob + """ + # Using type checking instead of isinstance to prevent cyclical imports + if not isinstance(job_object, DeadlineJob): + raise DeadlineServiceError(f"Job is not of type DeadlineJob. Found {type(job_object)}!") + + @staticmethod + def _update_all_jobs(service): + """ + This method updates current running job properties in a thread. + :param DeadlineService service: Deadline service instance + """ + # Get a Deadline http instance inside for this function. This function is expected to be executed in a thread. + deadline_http = DeadlineHttp(service.host) + + while not service._exit_auto_update: + + while not service._update_thread_event.is_set(): + + # Execute the job update properties on the job object + for job_object in service.current_jobs: + + logger.debug(f"Updating {job_object} job properties") + + # Get the job details for this job and update the job details on the job object. The service + # should be allowed to set this protected property on the job object as this property should + # natively not be allowed to be set externally + try: + if job_object.job_id: + job_object.job_details = deadline_http.get_job_details(job_object.job_id) + + # If a job fails to get job details, log it, mark it unknown + except Exception as err: + logger.exception(f"An error occurred getting job details for {job_object}:\n\t{err}") + service._update_tracked_job_by_status( + job_object, + DeadlineJobStatus.UNKNOWN, + update_job=True + ) + + # Iterate over the jobs and update the tracked jobs by the service + for job in service.current_jobs: + service._update_tracked_job_by_status(job, job.job_status) + + service._update_thread_event.set() + + @staticmethod + def get_event_manager(): + """ + Returns an instance of an event timer manager + """ + return unreal.DeadlineServiceTimerManager() + + @staticmethod + def start_function_timer(event_manager, function, interval_in_seconds=2.0): + """ + Start a timer on a function within an interval + :param unreal.DeadlineServiceTimerManager event_manager: Unreal Deadline service timer manager + :param object function: Function to execute + :param float interval_in_seconds: Interval in seconds between function execution. Default is 2.0 seconds + :return: Event timer handle + """ + if not isinstance(event_manager, unreal.DeadlineServiceTimerManager): + raise TypeError( + f"The event manager is not of type `unreal.DeadlineServiceTimerManager`. Got {type(event_manager)}" + ) + + event_manager.on_timer_interval_delegate.add_callable(function) + + return event_manager.start_timer(interval_in_seconds) + + @staticmethod + def stop_function_timer(event_manager, time_handle): + """ + Stops the timer event + :param unreal.DeadlineServiceTimerManager event_manager: Service Event manager + :param time_handle: Time handle returned from the event manager + """ + event_manager.stop_timer(time_handle) + + +class DeadlineServiceError(Exception): + """ + General Exception class for the Deadline Service + """ + pass + + +def get_global_deadline_service_instance(): + """ + This method returns an instance of the service from + the interpreter globals. + :return: + """ + # This behavior is a result of unreal classes not able to store python object + # directly on the class due to limitations in the reflection system. + # The expectation is that uclass's that may not be able to store the service + # as a persistent attribute on a class can use the global service instance. + + # BEWARE!!!! + # Due to the nature of the DeadlineService being a singleton, if you get the + # current instance and change the host path for the service, the connection will + # change for every other implementation that uses this service + + deadline_globals = get_editor_deadline_globals() + + if '__deadline_service_instance__' not in deadline_globals: + deadline_globals["__deadline_service_instance__"] = DeadlineService() + + return deadline_globals["__deadline_service_instance__"] diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_utils.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_utils.py new file mode 100644 index 0000000000..1b87e5752e --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/deadline_utils.py @@ -0,0 +1,220 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +""" +General Deadline utility functions +""" +# Built-in +from copy import deepcopy +import json +import re + +import unreal + + +def format_job_info_json_string(json_string, exclude_aux_files=False): + """ + Deadline Data asset returns a json string, load the string and format the job info in a dictionary + :param str json_string: Json string from deadline preset struct + :param bool exclude_aux_files: Excludes the aux files from the returned job info dictionary if True + :return: job Info dictionary + """ + + if not json_string: + raise RuntimeError(f"Expected json string value but got `{json_string}`") + + job_info = {} + + try: + intermediate_info = json.loads(json_string) + except Exception as err: + raise RuntimeError(f"An error occurred formatting the Job Info string. \n\t{err}") + + project_settings = unreal.get_default_object(unreal.DeadlineServiceEditorSettings) + script_category_mappings = project_settings.script_category_mappings + + # The json string keys are camelCased keys which are not the expected input + # types for Deadline. Format the keys to PascalCase. + for key, value in intermediate_info.items(): + + # Remove empty values + if not value: + continue + + # Deadline does not support native boolean so make it a string + if isinstance(value, bool): + value = str(value).lower() + + pascal_case_key = re.sub("(^\S)", lambda string: string.group(1).upper(), key) + + if (pascal_case_key == "AuxFiles") and not exclude_aux_files: + + # The returned json string lists AuxFiles as a list of + # Dictionaries but the expected value is a list of + # strings. reformat this input into the expected value + aux_files = [] + for files in value: + aux_files.append(files["filePath"]) + + job_info[pascal_case_key] = aux_files + + continue + + # Extra option that can be set on the job info are packed inside a + # ExtraJobOptions key in the json string. + # Extract this is and add it as a flat setting in the job info + elif pascal_case_key == "ExtraJobOptions": + job_info.update(value) + + continue + + # Resolve the job script paths to be sent to be sent to the farm. + elif pascal_case_key in ["PreJobScript", "PostJobScript", "PreTaskScript", "PostTaskScript"]: + + # The path mappings in the project settings are a dictionary + # type with the script category as a named path for specifying + # the root directory of a particular script. The User interface + # exposes the category which is what's in the json string. We + # will use this category to look up the actual path mappings in + # the project settings. + script_category = intermediate_info[key]["scriptCategory"] + script_name = intermediate_info[key]["scriptName"] + if script_category and script_name: + job_info[pascal_case_key] = f"{script_category_mappings[script_category]}/{script_name}" + + continue + + # Environment variables for Deadline are numbered key value pairs in + # the form EnvironmentKeyValue#. + # Conform the Env settings to the expected Deadline configuration + elif (pascal_case_key == "EnvironmentKeyValue") and value: + + for index, (env_key, env_value) in enumerate(value.items()): + job_info[f"EnvironmentKeyValue{index}"] = f"{env_key}={env_value}" + + continue + + # ExtraInfoKeyValue for Deadline are numbered key value pairs in the + # form ExtraInfoKeyValue#. + # Conform the setting to the expected Deadline configuration + elif (pascal_case_key == "ExtraInfoKeyValue") and value: + + for index, (env_key, env_value) in enumerate(value.items()): + job_info[f"ExtraInfoKeyValue{index}"] = f"{env_key}={env_value}" + + continue + + else: + # Set the rest of the functions + job_info[pascal_case_key] = value + + # Remove our custom representation of Environment and ExtraInfo Key value + # pairs from the dictionary as the expectation is that these have been + # conformed to deadline's expected key value representation + for key in ["EnvironmentKeyValue", "ExtraInfoKeyValue"]: + job_info.pop(key, None) + + return job_info + + +def format_plugin_info_json_string(json_string): + """ + Deadline Data asset returns a json string, load the string and format the plugin info in a dictionary + :param str json_string: Json string from deadline preset struct + :return: job Info dictionary + """ + + if not json_string: + raise RuntimeError(f"Expected json string value but got `{json_string}`") + + plugin_info = {} + + try: + info = json.loads(json_string) + plugin_info = info["pluginInfo"] + + except Exception as err: + raise RuntimeError(f"An error occurred formatting the Plugin Info string. \n\t{err}") + + # The plugin info is listed under the `plugin_info` key. + # The json string keys are camelCased on struct conversion to json. + return plugin_info + + +def get_deadline_info_from_preset(job_preset=None, job_preset_struct=None): + """ + This method returns the job info and plugin info from a deadline preset + :param unreal.DeadlineJobPreset job_preset: Deadline preset asset + :param unreal.DeadlineJobPresetStruct job_preset_struct: The job info and plugin info in the job preset + :return: Returns a tuple with the job info and plugin info dictionary + :rtype: Tuple + """ + + job_info = {} + plugin_info = {} + preset_struct = None + + # TODO: Make sure the preset library is a loaded asset + if job_preset is not None: + preset_struct = job_preset.job_preset_struct + + if job_preset_struct is not None: + preset_struct = job_preset_struct + + if preset_struct: + # Get the Job Info and plugin Info + try: + job_info = dict(unreal.DeadlineServiceEditorHelpers.get_deadline_job_info(preset_struct)) + + plugin_info = dict(unreal.DeadlineServiceEditorHelpers.get_deadline_plugin_info(preset_struct)) + + # Fail the submission if any errors occur + except Exception as err: + unreal.log_error( + f"Error occurred getting deadline job and plugin details. \n\tError: {err}" + ) + raise + + return job_info, plugin_info + + +def merge_dictionaries(first_dictionary, second_dictionary): + """ + This method merges two dictionaries and returns a new dictionary as a merger between the two + :param dict first_dictionary: The first dictionary + :param dict second_dictionary: The new dictionary to merge in + :return: A new dictionary based on a merger of the input dictionaries + :rtype: dict + """ + # Make sure we do not overwrite our input dictionary + output_dictionary = deepcopy(first_dictionary) + + for key in second_dictionary: + if isinstance(second_dictionary[key], dict): + if key not in output_dictionary: + output_dictionary[key] = {} + output_dictionary[key] = merge_dictionaries(output_dictionary[key], second_dictionary[key]) + else: + output_dictionary[key] = second_dictionary[key] + + return output_dictionary + + +def get_editor_deadline_globals(): + """ + Get global storage that will persist for the duration of the + current interpreter/process. + + .. tip:: + + Please namespace or otherwise ensure unique naming of any data stored + into this dictionary, as key clashes are not handled/safety checked. + + :return: Global storage + :rtype: dict + """ + import __main__ + try: + return __main__.__editor_deadline_globals__ + except AttributeError: + __main__.__editor_deadline_globals__ = {} + return __main__.__editor_deadline_globals__ diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/init_unreal.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/init_unreal.py new file mode 100644 index 0000000000..763d6745bc --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/init_unreal.py @@ -0,0 +1,54 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Built-in +import sys +from pathlib import Path + +from deadline_utils import get_editor_deadline_globals +from deadline_service import DeadlineService + +# Third-party +import unreal + +plugin_name = "DeadlineService" + + +# Add the actions path to sys path +actions_path = Path(__file__).parent.joinpath("service_actions").as_posix() + +if actions_path not in sys.path: + sys.path.append(actions_path) + +# The asset registry may not be fully loaded by the time this is called, +# warn the user that attempts to look assets up may fail +# unexpectedly. +# Look for a custom commandline start key `-waitonassetregistry`. This key +# is used to trigger a synchronous wait on the asset registry to complete. +# This is useful in commandline states where you explicitly want all assets +# loaded before continuing. +asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() +if asset_registry.is_loading_assets() and ("-waitonassetregistry" in unreal.SystemLibrary.get_command_line().split()): + unreal.log_warning( + f"Asset Registry is still loading. The {plugin_name} plugin will " + f"be loaded after the Asset Registry is complete." + ) + + asset_registry.wait_for_completion() + unreal.log(f"Asset Registry is complete. Loading {plugin_name} plugin.") + +# Create a global instance of the deadline service. This is useful for +# unreal classes that are not able to save the instance as an +# attribute on the class. Because the Deadline Service is a singleton, +# any new instance created from the service module will return the global +# instance +deadline_globals = get_editor_deadline_globals() + +try: + deadline_globals["__deadline_service_instance__"] = DeadlineService() +except Exception as err: + raise RuntimeError(f"An error occurred creating a Deadline service instance. \n\tError: {str(err)}") + +from service_actions import submit_job_action + +# Register the menu from the render queue actions +submit_job_action.register_menu_action() diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/service_actions/__init__.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/service_actions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/service_actions/submit_job_action.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/service_actions/submit_job_action.py new file mode 100644 index 0000000000..37106b2e96 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Python/service_actions/submit_job_action.py @@ -0,0 +1,113 @@ +# Copyright Epic Games, Inc. All Rights Reserved + +# Built-in +import argparse +from getpass import getuser + +# Internal +from deadline_service import get_global_deadline_service_instance +from deadline_job import DeadlineJob +from deadline_menus import DeadlineToolBarMenu + +# Third Party +import unreal + +# Editor Utility Widget path +# NOTE: This is very fragile and can break if naming or pathing changes +EDITOR_UTILITY_WIDGET = "/UnrealDeadlineService/Widgets/DeadlineJobSubmitter" + + +def _launch_job_submitter(): + """ + Callback to execute to launch the job submitter + """ + unreal.log("Launching job submitter.") + + submitter_widget = unreal.EditorAssetLibrary.load_asset(EDITOR_UTILITY_WIDGET) + + # Get editor subsystem + subsystem = unreal.get_editor_subsystem(unreal.EditorUtilitySubsystem) + + # Spawn the submitter widget + subsystem.spawn_and_register_tab(submitter_widget) + + +def register_menu_action(): + """ + Creates the toolbar menu + """ + + if not _validate_euw_asset_exists(): + unreal.log_warning( + f"EUW {EDITOR_UTILITY_WIDGET} does not exist in the Asset registry!" + ) + return + + toolbar = DeadlineToolBarMenu() + + toolbar.register_submenu( + "SubmitDeadlineJob", + _launch_job_submitter, + label_name="Submit Deadline Job", + description="Submits a job to Deadline" + ) + + +def _validate_euw_asset_exists(): + """ + Make sure our reference editor utility widget exists in + the asset registry + :returns: Array(AssetData) or None + """ + + asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() + asset_data = asset_registry.get_assets_by_package_name( + EDITOR_UTILITY_WIDGET, + include_only_on_disk_assets=True + ) + + return True if asset_data else False + + +def _execute_submission(args): + """ + Creates and submits a job to Deadline + :param args: Commandline args + """ + + unreal.log("Executing job submission") + + # Create a Deadline job from the selected job preset + deadline_job = DeadlineJob(job_preset=unreal.load_asset(args.job_preset_asset)) + + # If there is no author set, use the current user + if not deadline_job.job_info.get("UserName", None): + deadline_job.job_info = {"UserName": getuser()} + + deadline_service = get_global_deadline_service_instance() + + # Submit the Deadline Job + job_id = deadline_service.submit_job(deadline_job) + + unreal.log(f"Deadline job submitted. JobId: {job_id}") + + +if __name__ == "__main__": + unreal.log("Executing job submitter action") + + parser = argparse.ArgumentParser( + description="Submits a job to Deadline", + add_help=False, + ) + + parser.add_argument( + "--job_preset_asset", + type=str, + help="Deadline Job Preset Asset" + ) + + parser.set_defaults(func=_execute_submission) + + # Parse the arguments and execute the function callback + arguments = parser.parse_args() + arguments.func(arguments) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Widgets/DeadlineJobSubmitter.uasset b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Content/Widgets/DeadlineJobSubmitter.uasset new file mode 100644 index 0000000000000000000000000000000000000000..09f21066397991de4a894d4ddbc0ebd924a171ba GIT binary patch literal 130606 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 Pjt3i09=$uniNOCCtUDZp literal 0 HcmV?d00001 diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/DeadlineService.Build.cs b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/DeadlineService.Build.cs new file mode 100644 index 0000000000..bcdf2fd4db --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/DeadlineService.Build.cs @@ -0,0 +1,26 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class DeadlineService : ModuleRules +{ + public DeadlineService(ReadOnlyTargetRules Target) : base(Target) + { + + PrivateDependencyModuleNames.AddRange( + new string[] { + "AssetDefinition", + "Core", + "CoreUObject", + "EditorStyle", + "Engine", + "DeveloperSettings", + "UnrealEd", + "JsonUtilities", + "PropertyEditor", + "SlateCore", + "Slate" + } + ); + } +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPreset.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPreset.cpp new file mode 100644 index 0000000000..cb9a1bad5a --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPreset.cpp @@ -0,0 +1,66 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "DeadlineJobPreset.h" + +#include "DeadlineServiceEditorSettings.h" + +#include "Widgets/Layout/SBorder.h" +#include "Widgets/SBoxPanel.h" + +#include UE_INLINE_GENERATED_CPP_BY_NAME(DeadlineJobPreset) + +DEFINE_LOG_CATEGORY(LogDeadlineDataAsset); +DEFINE_LOG_CATEGORY(LogDeadlineStruct); + +UDeadlineJobPreset::UDeadlineJobPreset() +{ + SetupPluginInfo(); +} + +/** + * Retrieves the path of the executable file, adding the desired variant to the end. + * DesiredExecutableVariant is defined in DeadlineServiceEditorSettings. + * @return A string representing the path of the executable file. + */ +FString GetExecutablePathWithDesiredVariant() +{ + FString ExecutablePath = FPlatformProcess::ExecutablePath(); + FString ExtensionWithDot = FPaths::GetExtension(ExecutablePath, true); + ExecutablePath.RemoveFromEnd(ExtensionWithDot); + FString DesiredExecutableVariant = GetDefault()->DesiredExecutableVariant; + ExecutablePath.RemoveFromEnd(DesiredExecutableVariant); + + TStringBuilder<1024> StringBuilder; + StringBuilder.Append(ExecutablePath); + StringBuilder.Append(DesiredExecutableVariant); + StringBuilder.Append(ExtensionWithDot); + + return StringBuilder.ToString(); +} + +void UDeadlineJobPreset::SetupPluginInfo() +{ + // Set default values good for most users + if (!JobPresetStruct.PluginInfo.FindKey("Executable")) + { + JobPresetStruct.PluginInfo.Add("Executable", GetExecutablePathWithDesiredVariant()); + } + if (!JobPresetStruct.PluginInfo.FindKey("ProjectFile")) + { + FString ProjectPath = FPaths::GetProjectFilePath(); + + if (FPaths::IsRelative(ProjectPath)) + { + if (const FString FullPath = FPaths::ConvertRelativePathToFull(ProjectPath); FPaths::FileExists(FullPath)) + { + ProjectPath = FullPath; + } + } + + JobPresetStruct.PluginInfo.Add("ProjectFile", ProjectPath); + } + if (!JobPresetStruct.PluginInfo.FindKey("CommandLineArguments")) + { + JobPresetStruct.PluginInfo.Add("CommandLineArguments","-log"); + } +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPresetFactory.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPresetFactory.cpp new file mode 100644 index 0000000000..8205595674 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineJobPresetFactory.cpp @@ -0,0 +1,30 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "DeadlineJobPresetFactory.h" + +#include "DeadlineJobPreset.h" + +#include "AssetTypeCategories.h" + +UDeadlineJobPresetFactory::UDeadlineJobPresetFactory() +{ + bCreateNew = true; + bEditAfterNew = false; + bEditorImport = false; + SupportedClass = UDeadlineJobPreset::StaticClass(); +} + + UObject* UDeadlineJobPresetFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + return NewObject(InParent, Class, Name, Flags); +} + +FText UDeadlineJobPresetFactory::GetDisplayName() const +{ + return NSLOCTEXT("AssetTypeActions", "AssetTypeActions_DeadlineJobPreset", "Deadline Job Preset"); +} + +uint32 UDeadlineJobPresetFactory::GetMenuCategories() const +{ + return EAssetTypeCategories::Misc; +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceEditorSettings.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceEditorSettings.cpp new file mode 100644 index 0000000000..6aa12a2838 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceEditorSettings.cpp @@ -0,0 +1,3 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "DeadlineServiceEditorSettings.h" diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceModule.cpp b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceModule.cpp new file mode 100644 index 0000000000..12fe491657 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Private/DeadlineServiceModule.cpp @@ -0,0 +1,7 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#include "DeadlineServiceModule.h" + +#include "Modules/ModuleManager.h" + +IMPLEMENT_MODULE(FDeadlineServiceModule, DeadlineService); diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/AssetDefinition_DeadlineJobPreset.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/AssetDefinition_DeadlineJobPreset.h new file mode 100644 index 0000000000..3315147e24 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/AssetDefinition_DeadlineJobPreset.h @@ -0,0 +1,26 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "DeadlineJobPreset.h" +#include "AssetDefinitionDefault.h" + +#include "AssetDefinition_DeadlineJobPreset.generated.h" + +UCLASS() +class UAssetDefinition_DeadlineJobPreset : public UAssetDefinitionDefault +{ + GENERATED_BODY() + +public: + // UAssetDefinition Begin + virtual FText GetAssetDisplayName() const override { return NSLOCTEXT("AssetTypeActions", "AssetTypeActions_DeadlineJobPreset", "Deadline Job Preset"); } + virtual FLinearColor GetAssetColor() const override { return FLinearColor::Red; } + virtual TSoftClassPtr GetAssetClass() const override { return UDeadlineJobPreset::StaticClass(); } + virtual TConstArrayView GetAssetCategories() const override + { + static const auto Categories = { EAssetCategoryPaths::Misc }; + return Categories; + } + // UAssetDefinition End +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPreset.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPreset.h new file mode 100644 index 0000000000..e9d77308ba --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPreset.h @@ -0,0 +1,230 @@ +// Copyright Epic Games, Inc. All Rights Reserved. +#pragma once + +#include "Engine/DataAsset.h" +#include "DeadlineJobPreset.generated.h" + +// Forward declarations +class UDeadlineJobPreset; +class UScriptCategories; + +DECLARE_LOG_CATEGORY_EXTERN(LogDeadlineDataAsset, Log, All); +DECLARE_LOG_CATEGORY_EXTERN(LogDeadlineStruct, Log, All); + +/** + * Deadline Job Info Struct + */ +USTRUCT(BlueprintType) +struct DEADLINESERVICE_API FDeadlineJobPresetStruct +{ + /** + * If any of these variable names must change for any reason, be sure to update the string literals in the source as well + * such as in DeadlineJobDataAsset.cpp and MoviePipelineDeadline/DeadlineJobPresetCustomization.cpp, et al. + */ + GENERATED_BODY() + + /** Specifies the name of the job. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Description") + FString Name = "Untitled"; + + /** Specifies a comment for the job. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Description", meta = (MultiLine = true)) + FString Comment; + + /** + * Specifies the department that the job belongs to. + * This is simply a way to group jobs together, and does not affect rendering in any way. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Description") + FString Department; + + /** Specifies the pool that the job is being submitted to. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + FString Pool; + + /** + * Specifies the secondary pool that the job can spread to if machines are available. + * If not specified, the job will not use a secondary pool. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + FString SecondaryPool; + + /** Specifies the group that the job is being submitted to. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + FString Group; + + /** Specifies the priority of a job with 0 being the lowest and 100 being the highest unless configured otherwise in Repository Options. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options", meta = (ClampMin = 0)) + int32 Priority = 50; + + /** Specifies the time, in seconds, a Worker has to render a task before it times out. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options", meta = (ClampMin = 0)) + int32 TaskTimeoutSeconds = 0; + + /** + * If true, a Worker will automatically figure out if it has been rendering too long based on some + * Repository Configuration settings and the render times of previously completed tasks. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + bool bEnableAutoTimeout = false; + + /** Deadline Plugin used to execute the current job. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Plugin") + FString Plugin = TEXT("UnrealEngine5"); + + /** + * Specifies the maximum number of tasks that a Worker can render at a time. + * This is useful for script plugins that support multithreading. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options", meta = (ClampMin = 1, ClampMax = 16)) + int32 ConcurrentTasks = 1; + + /** If ConcurrentTasks is greater than 1, setting this to true will ensure that a Worker will not dequeue more tasks than it has processors. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + bool bLimitConcurrentTasksToNumberOfCpus = true; + + /** Specifies the maximum number of machines this job can be rendered on at the same time (0 means unlimited). */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options", meta = (ClampMin = 0)) + int32 MachineLimit = 0; + + /** If true, the machine names in MachineList will be avoided. todo */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options", DisplayName = "Machine List Is A Deny List") + bool bMachineListIsADenyList = false; + + /** Job machines to use. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + FString MachineList; + + /** Specifies the limit groups that this job is a member of. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + FString LimitGroups; + + /** + * Specifies what jobs must finish before this job will resume (default = blank). + * These dependency jobs must be identified using their unique job ID, + * which is outputted after the job is submitted, and can be found in the Monitor in the “Job ID” column. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + FString JobDependencies; + + /** + * Specifies the frame range of the render job. + * See the Frame List Formatting Options in the Job Submission documentation for more information. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + FString Frames = TEXT("0"); + + /** Specifies how many frames to render per task. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options", meta = (ClampMin = 1)) + int32 ChunkSize = 1; + + /** Specifies what should happen to a job after it completes. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options", meta = (GetOptions = "GetOnJobCompleteOptions")) + FString OnJobComplete = "Nothing"; + + /** whether the submitted job should be set to 'suspended' status. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Options") + bool bSubmitJobAsSuspended = false; + + /** Specifies the job’s user. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Advanced Job Options") + FString UserName; + + /** Specifies an optional name to logically group jobs together. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Advanced Job Options") + FString BatchName; + + /** + * Specifies a full path to a python script to execute when the job initially starts rendering. + * Note: + * This location is expected to already be path mapped on the farm else it will fail. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options", meta = (FilePathFilter = "Python files (*.py)|*.py")) + FFilePath PreJobScript; + + /** + * Specifies a full path to a python script to execute when the job completes. + * Note: + * This location is expected to already be path mapped on the farm else it will fail. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options", meta = (FilePathFilter = "Python files (*.py)|*.py")) + FFilePath PostJobScript; + + /** + * Specifies a full path to a python script to execute before each task starts rendering. + * Note: + * This location is expected to already be path mapped on the farm else it will fail. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options", meta = (FilePathFilter = "Python files (*.py)|*.py")) + FFilePath PreTaskScript; + + /** + * Specifies a full path to a python script to execute after each task completes. + * Note: + * This location is expected to already be path mapped on the farm else it will fail. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options", meta = (FilePathFilter = "Python files (*.py)|*.py")) + FFilePath PostTaskScript; + + /** Specifies environment variables to set when the job renders. This is only set in the Deadline environment not the Unreal environment. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options") + TMap EnvironmentKeyValue; + + /** Key Value pair environment variables to set when the job renders. This is only set in the Deadline environment not the Unreal environment. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options") + TMap EnvironmentInfo; + + /** Key-Value pair Job Extra Info keys for storing user data on the job. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options") + TMap ExtraInfoKeyValue; + + /** Replace the Task extra info column names with task extra info value. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options") + bool bOverrideTaskExtraInfoNames = false; + + /** + * Key Value pair Task Extra Info keys for storing deadline info. This is split up into unique + * settings as there is a limited amount of settings + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options") + TMap TaskExtraInfoNames; + + /** Extra Deadline Job options. Note: Match the naming convention on Deadline's Manual Job Submission website for the options. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, AdvancedDisplay, Category = "Advanced Job Options") + TMap ExtraJobOptions; + + /** Deadline Plugin info key value pair. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Advanced Job Options") + TMap PluginInfo; +}; + + +/** + * Deadline Job Preset + */ +UCLASS(BlueprintType, DontCollapseCategories) +class DEADLINESERVICE_API UDeadlineJobPreset : public UObject +{ + GENERATED_BODY() +public: + + UDeadlineJobPreset(); + + /** Job preset struct */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Job Preset") + FDeadlineJobPresetStruct JobPresetStruct; + + UFUNCTION() + static TArray GetOnJobCompleteOptions() + { + return {"Nothing","Delete","Archive"}; + } + +protected: + + /** + * Sets up the PluginInfo struct for the FDeadlineJobPresetStruct. + */ + void SetupPluginInfo(); + +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPresetFactory.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPresetFactory.h new file mode 100644 index 0000000000..f08266f975 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineJobPresetFactory.h @@ -0,0 +1,23 @@ +// Copyright Epic Games, Inc. All Rights Reserved. +#pragma once + +#include "Factories/Factory.h" + +#include "DeadlineJobPresetFactory.generated.h" + +UCLASS() +class UDeadlineJobPresetFactory : public UFactory +{ + GENERATED_BODY() + +public: + + UDeadlineJobPresetFactory(); + + // Begin UFactory Interface + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual FText GetDisplayName() const override; + virtual uint32 GetMenuCategories() const override; + // End UFactory Interface +}; + diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorHelpers.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorHelpers.h new file mode 100644 index 0000000000..f54a88fbc0 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorHelpers.h @@ -0,0 +1,137 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "DeadlineJobPreset.h" +#include "DeadlineServiceEditorSettings.h" +#include "Kismet/BlueprintFunctionLibrary.h" + +#include "DeadlineServiceEditorHelpers.generated.h" + +/** +* Using UCLASS instead of a namespace because we need reflection to call from python +*/ +UCLASS() +class DEADLINESERVICE_API UDeadlineServiceEditorHelpers : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + + /** + * Returns the given Deadline job info struct a TMap, python compatible + * Excludes "PluginInfo". Use GetPluginInfo to collect this separately. + */ + UFUNCTION(BlueprintCallable, Category = "DeadlineService") + static TMap GetDeadlineJobInfo(const FDeadlineJobPresetStruct& JobPresetStruct) + { + TMap ReturnValue = {{"Plugin", "UnrealEngine"}}; + + if (const UDeadlineServiceEditorSettings* Settings = GetDefault()) + { + ReturnValue["Plugin"] = Settings->PluginName; + } + + for (TFieldIterator PropIt(FDeadlineJobPresetStruct::StaticStruct()); PropIt; ++PropIt) + { + const FProperty* Property = *PropIt; + if (!Property) + { + continue; + } + + FName PropertyName = Property->GetFName(); + + // Custom Handlers for specific properties prioritizing UX + if (PropertyName.IsEqual("bSubmitJobAsSuspended")) + { + ReturnValue.Add("InitialStatus", JobPresetStruct.bSubmitJobAsSuspended ? "Suspended" : "Active"); + } + else if (PropertyName.IsEqual("bMachineListIsADenyList")) + { + ReturnValue.Add(JobPresetStruct.bMachineListIsADenyList ? "Denylist" : "Allowlist", JobPresetStruct.MachineList); + } + else if (PropertyName.IsEqual("PreJobScript")) + { + ReturnValue.Add(PropertyName.ToString(), JobPresetStruct.PreJobScript.FilePath); + } + else if (PropertyName.IsEqual("PostJobScript")) + { + ReturnValue.Add(PropertyName.ToString(), JobPresetStruct.PostJobScript.FilePath); + } + else if (PropertyName.IsEqual("PreTaskScript")) + { + ReturnValue.Add(PropertyName.ToString(), JobPresetStruct.PreTaskScript.FilePath); + } + else if (PropertyName.IsEqual("PostTaskScript")) + { + ReturnValue.Add(PropertyName.ToString(), JobPresetStruct.PostTaskScript.FilePath); + } + else if (PropertyName.IsEqual("MachineList") || PropertyName.IsEqual("PluginInfo")) + { + // MachineList is handled above, PluginInfo is handled in a separate function + continue; + } + else if (const FMapProperty* MapProperty = CastField(Property)) + { + // Custom handler for Maps + const void* MapValuePtr = MapProperty->ContainerPtrToValuePtr(&JobPresetStruct); + FScriptMapHelper MapHelper(MapProperty, MapValuePtr); + for (int32 MapSparseIndex = 0; MapSparseIndex < MapHelper.GetMaxIndex(); ++MapSparseIndex) + { + if (MapHelper.IsValidIndex(MapSparseIndex)) + { + const uint8* MapKeyData = MapHelper.GetKeyPtr(MapSparseIndex); + const uint8* MapValueData = MapHelper.GetValuePtr(MapSparseIndex); + + FString KeyDataAsString; + MapHelper.GetKeyProperty()->ExportText_Direct(KeyDataAsString, MapKeyData, MapKeyData, nullptr, PPF_None); + FString ValueDataAsString; + MapHelper.GetValueProperty()->ExportText_Direct(ValueDataAsString, MapValueData, MapValueData, nullptr, PPF_None); + + // Custom support for Extra Job Options. These properties are part of the top level Job Info map + if (PropertyName.IsEqual("ExtraJobOptions")) + { + ReturnValue.Add(*KeyDataAsString, *ValueDataAsString); + } + else + { + FString PropertyNameAsString = FString::Printf(TEXT("%s%d"), *PropertyName.ToString(), MapSparseIndex); + FString PropertyValueAsString = FString::Printf(TEXT("%s=%s"), *KeyDataAsString, *ValueDataAsString); + ReturnValue.Add(PropertyNameAsString, PropertyValueAsString); + } + // UE_LOG(LogTemp, Warning, TEXT("%s: %s"), *PropertyNameAsString, *PropertyValueAsString); + } + } + } + else + { + const void* ValuePtr = Property->ContainerPtrToValuePtr(&JobPresetStruct); + FString PropertyNameAsString = PropertyName.ToString(); + FString PropertyValueAsString; + Property->ExportText_Direct(PropertyValueAsString, ValuePtr, ValuePtr, nullptr, PPF_None); + + if (PropertyValueAsString.TrimStartAndEnd().IsEmpty()) + { + continue; + } + + // Sanitize bool + if (Property->IsA(FBoolProperty::StaticClass())) + { + PropertyNameAsString.RemoveFromStart(TEXT("b"), ESearchCase::CaseSensitive); + PropertyValueAsString = PropertyValueAsString.ToLower(); + } + + ReturnValue.Add(PropertyNameAsString, PropertyValueAsString); + // UE_LOG(LogTemp, Warning, TEXT("%s: %s"), *PropertyNameAsString, *PropertyValueAsString); + } + } + + return ReturnValue; + } + + UFUNCTION(BlueprintCallable, Category = "DeadlineService") + static TMap GetDeadlinePluginInfo(const FDeadlineJobPresetStruct& JobPresetStruct) + { + return JobPresetStruct.PluginInfo; + } +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorSettings.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorSettings.h new file mode 100644 index 0000000000..5e8a74f052 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceEditorSettings.h @@ -0,0 +1,59 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Engine/DeveloperSettings.h" +#include "DeadlineServiceEditorSettings.generated.h" + +/** +* Project-wide settings for the Deadline Service. +*/ +UCLASS(BlueprintType, config = Editor, defaultconfig, meta = (DisplayName = "Deadline Service")) +class DEADLINESERVICE_API UDeadlineServiceEditorSettings : public UDeveloperSettings +{ + GENERATED_BODY() + +public: + + /** Gets the settings container name for the settings, either Project or Editor */ + virtual FName GetContainerName() const override { return FName("Project"); } + /** Gets the category for the settings, some high level grouping like, Editor, Engine, Game...etc. */ + virtual FName GetCategoryName() const override { return FName("Plugins"); } + + /** UObject interface */ + virtual void PostEditChangeProperty(struct FPropertyChangedEvent& PropertyChangedEvent) override + { + Super::PostEditChangeProperty(PropertyChangedEvent); + SaveConfig(); + } + + /** + * Toggle use Deadline command for submission. + * If used Deadline command preempts use of the web service. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + bool bDeadlineCommand = true; + + + /** + * What is the host name for the Deadline Server that the REST API is running on? + * Only needs the host name and port (ie: http://localhost:port) + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + FString DeadlineHost; + + /** + * The name of the plugin to load in Deadline. Usually the default is used. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + FString PluginName = "UnrealEngine"; + + /** + * If you'd like the plugin to use a separate executable variant when creating a new DeadlineJobPreset, specify it here. + * For example, to use UnrealEditor-Cmd.exe instead of UnrealEditor.exe, specify "-Cmd". + * Leave blank to use no variant. + */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, config, Category = "Deadline") + FString DesiredExecutableVariant = "-Cmd"; + +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceModule.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceModule.h new file mode 100644 index 0000000000..c7291b003d --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceModule.h @@ -0,0 +1,9 @@ +// Copyright Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Modules/ModuleInterface.h" + +class FDeadlineServiceModule : public IModuleInterface +{ +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceTimerManager.h b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceTimerManager.h new file mode 100644 index 0000000000..e52eea63e0 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/Source/DeadlineService/Public/DeadlineServiceTimerManager.h @@ -0,0 +1,74 @@ +// Copyright Epic Games, Inc. All Rights Reserved + +#pragma once + +#include "Editor.h" +#include "CoreMinimal.h" +#include "UObject/Object.h" +#include "DeadlineServiceTimerManager.generated.h" + +DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnTimerInterval); + +/** + * A Deadline Service timer class used for executing function calls on an interval. This class + * can be used by other deadline implementations that use the deadline service to get notifications + * when an update timer is executed by the service. + */ +UCLASS(Blueprintable) +class DEADLINESERVICE_API UDeadlineServiceTimerManager : public UObject +{ + GENERATED_BODY() + +public: + /** Multicast Delegate to bind callable functions */ + UPROPERTY(BlueprintAssignable, Category = "Deadline Service Timer Event") + FOnTimerInterval OnTimerIntervalDelegate; + + /** + * Set a timer to execute a delegate. This timer is also used by the deadline service to periodically get updates + * on submitted jobs. This method returns a time handle reference for this function. This handle can be used at a + * later time to stop the timer. + * + * @param TimerInterval Float timer intervals in seconds. Default is 1.0 seconds. + * @param bLoopTimer Determine whether to loop the timer. By default this is true + */ + UFUNCTION(BlueprintCallable, Category = "Deadline Service Timer") + FTimerHandle StartTimer(float TimerInterval=1.0, bool bLoopTimer=true ) + { + + GEditor->GetTimerManager()->SetTimer( + DeadlineServiceTimerHandle, + FTimerDelegate::CreateUObject(this, &UDeadlineServiceTimerManager::OnTimerEvent), + TimerInterval, + bLoopTimer + ); + + return DeadlineServiceTimerHandle; + + } + + /** + * Function to stop the service timer. + * + * @param TimerHandle Timer handle to stop + */ + UFUNCTION(BlueprintCallable, Category = "Deadline Service Timer") + void StopTimer(FTimerHandle TimerHandle) + { + // Stop the timer + GEditor->GetTimerManager()->ClearTimer(TimerHandle); + } + +private: + /** Internal Timer handle */ + FTimerHandle DeadlineServiceTimerHandle; + +protected: + + /**Internal function to broadcast timer delegate on the editor timer interval. */ + UFUNCTION() + void OnTimerEvent() const + { + OnTimerIntervalDelegate.Broadcast(); + } +}; diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/UnrealDeadlineService.uplugin b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/UnrealDeadlineService.uplugin new file mode 100644 index 0000000000..028fad1f07 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealEnginePlugins/UnrealDeadlineService/UnrealDeadlineService.uplugin @@ -0,0 +1,35 @@ +{ + "FileVersion": 1, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "Unreal Deadline Service", + "Description": "Plugin to interact with Thinkbox Deadline renderfarm. Allows job submissions, queries, and job state updates.", + "Category": "AWS Thinkbox Deadline", + "CreatedBy": "Epic Games, Inc.", + "CreatedByURL" : "http://www.epicgames.com", + "DocsURL": "", + "MarketplaceURL": "", + "SupportURL": "", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Plugins": + [ + { + "Name": "PythonScriptPlugin", + "Enabled": true + }, + { + "Name": "EditorScriptingUtilities", + "Enabled": true + } + ], + "Modules": + [ + { + "Name": "DeadlineService", + "Type" : "UncookedOnly" + } + ] +} diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py new file mode 100644 index 0000000000..741400a54d --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py @@ -0,0 +1,652 @@ +import subprocess +import re +import socket +import os +from xml.sax.handler import property_declaration_handler +import zipfile +import time +import stat +import threading +import platform + +try: + import queue +except ImportError: + import Queue as queue + +""" +Utility tools to sync and build projects in remote machines. +Currently it supports Perforce only, but user can implement other source control system (i.e. git) +""" + + +class UnrealToolError(Exception): + pass + + +class PerforceError(UnrealToolError): + def __init__(self, message): + self.message = message + + def __str__(self): + return repr(self.message) + + +class PerforceArgumentError(PerforceError): + """An exception that is raised when a perforce command is executed but is missing required arguments. + + Attributes: + message -- programmer defined message + """ + + pass + + +class PerforceMissingWorkspaceError(PerforceError): + def __init__(self, hostName, streamName): + self.message = 'Could not find a workspace for stream: "%s" on host: "%s"' % ( + streamName, + hostName, + ) + + +class PerforceMultipleWorkspaceError(PerforceError): + def __init__(self, hostName, streamName, count): + self.message = ( + 'Found multiple(%d) workspaces for stream: "%s" on host: "%s"' + % (count, streamName, hostName) + ) + + +class PerforceResponseError(PerforceError): + def __init__(self, message, command, response): + self.message = '%s. Executed Command: "%s". Got Response: "%s"' % ( + message, + " ".join(command), + response, + ) + + +class PerforceMultipleProjectError(PerforceError): + def __init__(self, path, count): + self.message = 'Found multiple(%d) uproject files with this path: "%s"' % ( + count, + path, + ) + + +class PerforceProjectNotFoundError(PerforceError): + def __init__(self, path): + self.message = 'Could not find a uproject file with this path: "%s"' % (path) + + +class StoppableThread(threading.Thread): + def __init__(self, process, _queue, *args, **kwargs): + super(StoppableThread, self).__init__(*args, **kwargs) + self.stopEvent = threading.Event() + self.process = process + self.queue = _queue + + def stop(self): + self.stopEvent.set() + + def run(self): + while True: + if self.stopEvent.isSet(): + return + try: + for line in iter(self.process.stdout.readline, b""): + self.queue.put(line) + self.process.stdout.close() + except ValueError: + # File most likely closed so stop trying to queue output. + return + + +class PerforceUtils(object): + def __init__(self, stream, gamePath, env): + # The hostname of the perforce server. Defaults to the "P4PORT" Environment Var. + self._serverName = self._FindServerHostName() + if not self._serverName: + raise PerforceError('"P4PORT" has not been set in the Slave environment!') + + # The hostname of the local computer. Defaults to the local hostname. + self._localHost = socket.gethostname() + + # Which stream should the perforce commands be executed for. + # Assumes a workspace exists on this machine for that stream. + # (Removing '/' in the end) + self._stream = re.sub("/$", "", stream) # str + + # Store game name so that we can sync project only (not entire stream) + self._gamePath = gamePath + + # The change list that the sync operations should sync to. + self._changelist = 0 # int + + # The workspace the perforce commands should be executed for. + # Can be automatically determined with DetermineClientWorkspace() + self._clientWorkspace = None # str + + # The root on the local machine that the workspace is based out of. + # Can be automatically determined with DetermineClientWorkspace() + self._workspaceRoot = None # str + + # Sync Estimates calculated by DetermineSyncWorkEstimate + self._syncEstimates = [0, 0, 0] # [int,int,int] + self._syncResults = [0, 0, 0] # [int,int, int] + + # Sync entire stream or just game path + self._bSyncAll = False + + # Name of the uproject file + self._uprojectFile = None + + self._env = env + + @property + def workspaceRoot(self): + return self._workspaceRoot + + @property + def changelist(self): + return self._changelist + + @property + def syncEstimates(self): + return tuple(self._syncEstimates) + + @property + def localHost(self): + self._localHost + + @property + def serverName(self): + self._serverName + + @property + def projectRoot(self): + return "%s/%s" % (self._workspaceRoot, self._gamePath) + + @property + def uprojectPath(self): + return "%s/%s" % (self.projectRoot, self._uprojectFile) + + def setChangelist(self, value): + self._changelist = value + + def _FindServerHostName(self): + # The hostname of the perforce server. Defaults to the "P4PORT" Environment Var. + # If it's not set, try to find it from 'p4 set' command + name = os.getenv("P4PORT") + if name: + return name + output = subprocess.check_output(["p4", "set"]) + for line in output.splitlines(): + m = re.search("(?<=P4PORT=)(.*:\d+)", line) + if m: + return m.group() + + def SetSyncEntireStream(self, bSyncAll): + self._bSyncAll = bSyncAll + + # + # Automatically determine the client workspace by iterating through + # available workspaces for the local host machine + # + # Raises PerforceMultipleWrokspaceError when multiple workspaces are found for this host/stream. + # (i.e. a render host is also artist workstation where one workspace for artist and another for render job) + # This code should be modified to handle the case (i.e. determine by workspace name) + # + def DetermineClientWorkspace(self): + if not self._stream: + raise PerforceArgumentError("stream must be set to retrieve workspaces") + if not self._localHost: + raise PerforceArgumentError( + "localHostName must be set to retrieve workspaces" + ) + + cmd = [ + "p4", + "-ztag", + "-F", + '"%client%,%Root%,%Host%"', + "workspaces", + "-S", + self._stream, + ] + + result = subprocess.check_output(cmd, env=self._env) + print(">>>>result {}".format(result)) + result = result.splitlines() + local_workspaces = [] + + for line in result: + line = str(line).strip() + match = re.search('"(.*),(.*),(.*)"', line) + if match: + workspace, root, host = match.groups() + if host.lower() == self._localHost.lower(): + local_workspaces.append((workspace, root)) + + if not local_workspaces: + raise PerforceMissingWorkspaceError(self._localHost, self._stream) + elif len(local_workspaces) > 1: + raise PerforceMultipleWorkspaceError( + self._localHost, self._stream, len(local_workspaces) + ) + + workspace, root = local_workspaces[0] + print( + "Successfully found perforce workspace: %s on this host: %s" + % (workspace, self._localHost) + ) + self._clientWorkspace = workspace + self._workspaceRoot = root + + def DetermineProjectRoot(self, uprojectFile): + # Find project file from workspaceRoot. If gamePath contains '...', it should try to search the path recursively + # 2023-04-06 18:31:56: 0: PYTHON: {'self': , 'uprojectFile': u'DLFarmTests.uproject', 'cmd': ['p4', '-p', '10.10.10.162:1666', '-c', 'DLFarmTests_bepic-devtop01', 'files', u'//dl-farm-test/mainline///DLFarmTests.uproject'], 'result': [], 'search_path': u'//dl-farm-test/mainline///DLFarmTests.uproject'} + + if not self._gamePath: + search_path = self._stream + "/" + uprojectFile + else: + search_path = self._stream + "/" + self._gamePath + "/" + uprojectFile + cmd = self.GetP4CommandPrefix() + ["files", search_path] + result = subprocess.check_output(cmd, env=self._env) + result = result.splitlines() + + if len(result) == 0: + raise PerforceProjectNotFoundError(search_path) + elif len(result) > 1: + raise PerforceMultipleProjectError(search_path, len(result)) + result = result[0] + # m = re.search("%s/(.*)/%s" % (self._stream, uprojectFile), str(result)) + m = re.search("%s/.*/?%s#.*" % (self._stream, uprojectFile), str(result)) + if not m: + raise PerforceError("Unable to parse project path: %s" % str(result)) + + # self._gamePath = m.group(1) + self._uprojectFile = uprojectFile + print("ProjectRoot: %s" % self.projectRoot) + + def DetermineLatestChangelist(self): + + sync_path = self._stream + if not self._bSyncAll: + sync_path = self._stream + + # Default to no cl so that if one of the below checks fails it still gets latest. + self._changelist = 0 + latest_cl_command = self.GetP4CommandPrefix() + [ + "changes", + "-m1", + sync_path + "/...", + ] + print("Determining latest CL using: " + " ".join(latest_cl_command)) + + info = subprocess.STARTUPINFO() + info.dwFlags |= subprocess.STARTF_USESHOWWINDOW + + # proc = subprocess.Popen(latest_cl_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, startupinfo=info) + # result = proc.stdout.readline().strip() + + result = subprocess.check_output(latest_cl_command, startupinfo=info, + env=self._env) + + print("Result: {}".format(result)) + if not result.startswith("Change "): + raise PerforceResponseError( + "Failed to get latest changelist for stream", latest_cl_command, result + ) + + clTest = re.search("(?<=Change )(\S*)(?= )", result) + if clTest is None: + raise PerforceResponseError( + "Failed to parse response for latest changelist", + latest_cl_command, + result, + ) + + self._changelist = int(clTest.group()) + print("Changelist set: %d" % self._changelist) + + def DetermineSyncWorkEstimate(self, bForceSync=False): + # Get an estimate on how much syncing there is to do. + sync_estimate_command = self._BuildSyncCommand( + bForceSync=bForceSync, bDryRun=True + ) + info = subprocess.STARTUPINFO() + info.dwFlags |= subprocess.STARTF_USESHOWWINDOW + result = subprocess.check_output(sync_estimate_command, + startupinfo=info, + env=self._env) + + print(f"Sync Estimate Result: {result}") + + estimate_success = False + lines = result.splitlines() + for line in lines: + # Should return in the form "Server network estimates: files added/updated/deleted=x/y/z, bytes..." + estimateResult = re.search("(?<=deleted=)(\S*)(?=,)", str(line)) + if estimateResult: + estimate_success = True + estimates = list(map(int, estimateResult.group().split("/"))) + self._syncEstimates[0] += estimates[0] # added + self._syncEstimates[1] += estimates[1] # updated + self._syncEstimates[2] += estimates[2] # deleted + + if not estimate_success: + self._syncEstimates = [ + -1, + -1, + -1, + ] # Progress will be wrong but no need to crash over it. Don't use 0 here because 0 is a valid estimate (already sync'd) + raise PerforceResponseError( + "Failed to get estimated work for sync operation.", + sync_estimate_command, + result, + ) + + def CleanWorkspace(self): + sync_path = self._stream + if not self._bSyncAll: + sync_path = self._stream + "/" + self._gamePath + + clean_command = self.GetP4CommandPrefix() + [ + "clean", + "-e", + "-a", + "-d", + "-m", + sync_path + "/...", + ] # "]#, "-m"] + print("Cleaning using: " + " ".join(clean_command)) + + result = "" + try: + result = subprocess.check_output(clean_command, + env=self._env) + except subprocess.CalledProcessError as e: + print("Clean: %s" % str(e)) + + print("Clean Result: " + result) + + # Build a perforce sync command based on the options + def _BuildSyncCommand(self, bForceSync=False, bDryRun=False): + + sync_files = [] + if self._bSyncAll: + sync_files.append("") + else: + sync_files.append("%s/..." % (self._stream)) + + if self._changelist > 0: + for i in range(len(sync_files)): + sync_files[i] += "@%d" % self._changelist + + sync_command = self.GetP4CommandPrefix() + ["sync"] + if bDryRun: + sync_command.append("-N") + else: + sync_command.append("--parallel=threads=8") + + if bForceSync: + sync_command.append("-f") + + sync_command.extend(sync_files) + + return sync_command + + def Sync(self, progressCallback=None, bForceSync=False): + syncCommand = self._BuildSyncCommand(bForceSync=bForceSync, bDryRun=False) + + print("Sync Command: " + " ".join(syncCommand)) + + self._syncResults = [0, 0, 0] + + process = subprocess.Popen( + syncCommand, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + env=self._env + ) + + stdoutQueue = queue.Queue() + # stdoutThread = threading.Thread(target=queueStdout, args=(process, stdoutQueue)) + stdoutThread = StoppableThread(process, stdoutQueue) + stdoutThread.daemon = True + stdoutThread.start() + + while process.poll() is None: + while not stdoutQueue.empty(): + stdOutLine = stdoutQueue.get_nowait() + print(stdOutLine) + stdOutLine = str(stdOutLine) + + if ( + "The system cannot find the file specified." in stdOutLine + or "There is not enough space on the disk." in stdOutLine + ): + raise IOError( + 'Suspected Out of Disk Error while syncing: "%s"' % stdOutLine + ) + + # Look for either "deleted", "updated", or "added" and add to our results array. + if "added" in stdOutLine: + self._syncResults[0] += 1 + if "updated" in stdOutLine: + self._syncResults[1] += 1 + if "refreshing" in stdOutLine: + self._syncResults[ + 1 + ] += ( + 1 # This is a guess that refreshing in a full sync is the same. + ) + if "deleted" in stdOutLine: + self._syncResults[2] += 1 + + if progressCallback is not None: + progressCallback(self) + + print("process.poll returned a code, sync finished. Calling Stop") + stdoutThread.stop() + print("called stop. calling join.") + stdoutThread.join() + print("called join.") + + # One more progress callback to ensure we're at 1.0 + if progressCallback is not None: + progressCallback(1) + + # Generate the prefix for perforce commands that need user/workspace for scope. + def GetP4CommandPrefix(self): # -> str[] + return ["p4", "-p", self._serverName, "-c", self._clientWorkspace] + + # Get the sync progress for the current or last sync (Range: 0-1) + def GetSyncProgress(self): # -> float + # Totals + total_operations_est = float( + self._syncEstimates[0] + self._syncEstimates[1] + self._syncEstimates[2] + ) + total_operations = float( + self._syncResults[0] + self._syncResults[1] + self._syncResults[2] + ) + + if total_operations > 0: + return total_operations / total_operations_est + + return 0 + + +class BuildUtils(object): + def __init__(self, engineRoot, uprojectPath, editorName): + + self.engineRoot = engineRoot.replace("\\", "/") + self.uprojectPath = uprojectPath.replace("\\", "/") + self.editorName = editorName + print("engine_root: %s" % self.engineRoot) + print("uproject_path: %s" % self.uprojectPath) + print("editor_name: %s" % self.editorName) + + def IsSourceBuildEngine(self): + items = os.listdir(self.engineRoot) + items = [ + item for item in items if re.search("GenerateProjectFiles", item, re.I) + ] + return len(items) > 0 + + def IsCppProject(self): + project_root = os.path.dirname(self.uprojectPath) + items = os.listdir(project_root) + items = [item for item in items if re.search("Source", item, re.I)] + return len(items) > 0 + + def GetGenerateProjectFileProgram(self): + system = platform.system() + + if system == "Windows": + paths = [ + os.path.join(self.engineRoot, "GenerateProjectFiles.bat"), + os.path.join( + self.engineRoot, + "Engine", + "Build", + "BatchFiles", + "GenerateProjectFiles.bat", + ), + os.path.join( + self.engineRoot, + "Engine", + "Binaries", + "DotNET", + "UnrealBuildTool.exe", + ), + os.path.join( + self.engineRoot, + "Engine", + "Binaries", + "DotNET", + "UnrealBuildTool", + "UnrealBuildTool.exe", + ), + ] + + elif system == "Linux": + paths = [ + os.path.join(self.engineRoot, "GenerateProjectFiles.sh"), + os.path.join( + self.engineRoot, + "Engine", + "Build", + "BatchFiles", + "Linux", + "GenerateProjectFiles.sh", + ), + ] + + elif system == "Darwin": + paths = [ + os.path.join(self.engineRoot, "GenerateProjectFiles.sh"), + os.path.join( + self.engineRoot, + "Engine", + "Build", + "BatchFiles", + "Mac", + "GenerateProjectFiles.sh", + ), + ] + else: + raise RuntimeError("Platform not supported: %s" % system) + + for path in paths: + if os.path.exists(path): + return path + raise RuntimeError("Failed to find program to generate project files") + + def GetBuildProgram(self): + system = platform.system() + if system == "Windows": + return os.path.join( + self.engineRoot, "Engine", "Build", "BatchFiles", "Build.bat" + ) + elif system == "Linux": + return os.path.join( + self.engineRoot, "Engine", "Build", "BatchFiles", "Linux", "Build.sh" + ) + elif system == "Darwin": + return os.path.join( + self.engineRoot, "Engine", "Build", "BatchFiles", "Mac", "Build.sh" + ) + else: + raise RuntimeError("Platform not supported: %s" % system) + + def GetBuildArgs(self): + system = platform.system() + if system == "Windows": + system = "Win64" + elif system == "Darwin": + system = "Mac" + + args = [system, "Development", "-NoHotReloadFromIDE", "-progress"] + return args + + def GetEditorBuildArgs(self): + system = platform.system() + if system == "Windows": + system = "Win64" + elif system == "Darwin": + system = "Mac" + + args = [ + system, + "Development", + self.uprojectPath.encode("utf-8"), + "-NoHotReloadFromIDE", + "-progress", + ] + return args + + def GenerateProjectFiles(self): + program = self.GetGenerateProjectFileProgram().replace("\\", "/") + args = [program] + if re.search("UnrealBuildTool", program.split("/")[-1]): + args.append("-ProjectFiles") + + args.append(self.uprojectPath) + args.append("-progress") + + print("Generating Project Files with: %s" % " ".join(args)) + try: + process = subprocess.check_output(args, env=self._env) + except subprocess.CalledProcessError as e: + print( + "Exception while generating project files: %s (%s)" % (str(e), e.output) + ) + raise + print("Generated Project Files.") + + def BuildBuildTargets(self): + print("Starting to build targets...") + + build_targets = [] + if self.IsSourceBuildEngine(): + build_targets.append(("UnrealHeaderTool", self.GetBuildArgs())) + build_targets.append(("ShaderCompileWorker", self.GetBuildArgs())) + build_targets.append(("CrashReportClient", self.GetBuildArgs())) + build_targets.append(("UnrealLightmass", self.GetBuildArgs())) + + build_targets.append( + (self.editorName.encode("utf-8"), self.GetEditorBuildArgs()) + ) + program = self.GetBuildProgram().replace("\\", "/") + for target, buildArgs in build_targets: + args = [program, target] + buildArgs + print("Compiling {}...".format(target)) + print("Command Line: %s" % str(args)) + try: + process = subprocess.check_output(args, env=self._env) + except subprocess.CalledProcessError as e: + print("Exception while building target: %s (%s)" % (str(e), e.output)) + raise + + print("Finished building targets.") diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/__init__.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/__init__.py new file mode 100644 index 0000000000..4857450af2 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/__init__.py @@ -0,0 +1 @@ +# Copyright Epic Games, Inc. All Rights Reserved diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/__init__.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/__init__.py new file mode 100644 index 0000000000..ce55d23926 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/__init__.py @@ -0,0 +1,9 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +from . import client, factory + +__all__ = [ + "client", + "factory" +] diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/base_server.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/base_server.py new file mode 100644 index 0000000000..3e0f87babb --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/base_server.py @@ -0,0 +1,275 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import os +import sys +import abc +import queue +import time +import logging +import threading +from xmlrpc.server import SimpleXMLRPCServer + +# importlib machinery needs to be available for importing client modules +from importlib.machinery import SourceFileLoader + +logger = logging.getLogger(__name__) + +EXECUTION_QUEUE = queue.Queue() +RETURN_VALUE_NAME = 'RPC_SERVER_RETURN_VALUE' +ERROR_VALUE_NAME = 'RPC_SERVER_ERROR_VALUE' + + +def run_in_main_thread(callable_instance, *args): + """ + Runs the provided callable instance in the main thread by added it to a que + that is processed by a recurring event in an integration like a timer. + + :param call callable_instance: A callable. + :return: The return value of any call from the client. + """ + timeout = int(os.environ.get('RPC_TIME_OUT', 20)) + + globals().pop(RETURN_VALUE_NAME, None) + globals().pop(ERROR_VALUE_NAME, None) + EXECUTION_QUEUE.put((callable_instance, args)) + + for attempt in range(timeout * 10): + if RETURN_VALUE_NAME in globals(): + return globals().get(RETURN_VALUE_NAME) + elif ERROR_VALUE_NAME in globals(): + raise globals()[ERROR_VALUE_NAME] + else: + time.sleep(0.1) + + if RETURN_VALUE_NAME not in globals(): + raise TimeoutError( + f'The call "{callable_instance.__name__}" timed out because it hit the timeout limit' + f' of {timeout} seconds.' + ) + + +def execute_queued_calls(*extra_args): + """ + Runs calls in the execution que till they are gone. Designed to be passed to a + recurring event in an integration like a timer. + """ + while not EXECUTION_QUEUE.empty(): + if RETURN_VALUE_NAME not in globals(): + callable_instance, args = EXECUTION_QUEUE.get() + try: + globals()[RETURN_VALUE_NAME] = callable_instance(*args) + except Exception as error: + # store the error in the globals and re-raise it + globals()[ERROR_VALUE_NAME] = error + raise error + + +class BaseServer(SimpleXMLRPCServer): + def serve_until_killed(self): + """ + Serves till killed by the client. + """ + self.quit = False + while not self.quit: + self.handle_request() + + +class BaseRPCServer: + def __init__(self, name, port, is_thread=False): + """ + Initialize the base server. + + :param str name: The name of the server. + :param int port: The number of the server port. + :param bool is_thread: Whether or not the server is encapsulated in a thread. + """ + self.server = BaseServer( + (os.environ.get('RPC_HOST', '127.0.0.1'), port), + logRequests=False, + allow_none=True + ) + self.is_thread = is_thread + self.server.register_function(self.add_new_callable) + self.server.register_function(self.kill) + self.server.register_function(self.is_running) + self.server.register_function(self.set_env) + self.server.register_introspection_functions() + self.server.register_multicall_functions() + logger.info(f'Started RPC server "{name}".') + + @staticmethod + def is_running(): + """ + Responds if the server is running. + """ + return True + + @staticmethod + def set_env(name, value): + """ + Sets an environment variable in the server's python environment. + + :param str name: The name of the variable. + :param str value: The value. + """ + os.environ[name] = str(value) + + def kill(self): + """ + Kill the running server from the client. Only if running in blocking mode. + """ + self.server.quit = True + return True + + def add_new_callable(self, callable_name, code, client_system_path, remap_pairs=None): + """ + Adds a new callable defined in the client to the server. + + :param str callable_name: The name of the function that will added to the server. + :param str code: The code of the callable that will be added to the server. + :param list[str] client_system_path: The list of python system paths from the client. + :param list(tuple) remap_pairs: A list of tuples with first value being the client python path root and the + second being the new server path root. This can be useful if the client and server are on two different file + systems and the root of the import paths need to be dynamically replaced. + :return str: A response message back to the client. + """ + for path in client_system_path: + # if a list of remap pairs are provided, they will be remapped before being added to the system path + for client_path_root, matching_server_path_root in remap_pairs or []: + if path.startswith(client_path_root): + path = os.path.join( + matching_server_path_root, + path.replace(client_path_root, '').replace(os.sep, '/').strip('/') + ) + + if path not in sys.path: + sys.path.append(path) + + # run the function code + exec(code) + callable_instance = locals().copy().get(callable_name) + + # grab it from the locals and register it with the server + if callable_instance: + if self.is_thread: + self.server.register_function( + self.thread_safe_call(callable_instance), + callable_name + ) + else: + self.server.register_function( + callable_instance, + callable_name + ) + return f'The function "{callable_name}" has been successfully registered with the server!' + + +class BaseRPCServerThread(threading.Thread, BaseRPCServer): + def __init__(self, name, port): + """ + Initialize the base rpc server. + + :param str name: The name of the server. + :param int port: The number of the server port. + """ + threading.Thread.__init__(self, name=name, daemon=True) + BaseRPCServer.__init__(self, name, port, is_thread=True) + + def run(self): + """ + Overrides the run method. + """ + self.server.serve_forever() + + @abc.abstractmethod + def thread_safe_call(self, callable_instance, *args): + """ + Implements thread safe execution of a call. + """ + return + + +class BaseRPCServerManager: + @abc.abstractmethod + def __init__(self): + """ + Initialize the server manager. + Note: when this class is subclassed `name`, `port`, `threaded_server_class` need to be defined. + """ + self.server_thread = None + self.server_blocking = None + self._server = None + + def start_server_thread(self): + """ + Starts the server in a thread. + """ + self.server_thread = self.threaded_server_class(self.name, self.port) + self._server = self.server_thread.server + self.server_thread.start() + + def start_server_blocking(self): + """ + Starts the server in the main thread, which blocks all other processes. This can only + be killed by the client. + """ + self.server_blocking = BaseRPCServer(self.name, self.port) + self._server = self.server_blocking.server + self._server.serve_until_killed() + + def start(self, threaded=True): + """ + Starts the server. + + :param bool threaded: Whether or not to start the server in a thread. If not threaded + it will block all other processes. + """ + # start the server in a thread + if threaded and not self.server_thread: + self.start_server_thread() + + # start the blocking server + elif not threaded and not self.server_blocking: + self.start_server_blocking() + + else: + logger.info(f'RPC server "{self.name}" is already running...') + + def is_running(self): + """ + Checks to see if a blocking or threaded RPC server is still running + """ + if self._server: + try: + return self._server.is_running() + except (AttributeError, RuntimeError, Exception): + return False + + return False + + def get_server(self): + """ + Returns the rpc server running. This is useful when executing in a + thread and not blocking + """ + if not self._server: + raise RuntimeError("There is no server configured for this Manager") + + return self._server + + def shutdown(self): + """ + Shuts down the server. + """ + if self.server_thread: + logger.info(f'RPC server "{self.name}" is shutting down...') + + # kill the server in the thread + if self._server: + self._server.shutdown() + self._server.server_close() + + self.server_thread.join() + + logger.info(f'RPC server "{self.name}" has shutdown.') diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/client.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/client.py new file mode 100644 index 0000000000..9ced182b0d --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/client.py @@ -0,0 +1,106 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import os +import re +import logging +import inspect +from xmlrpc.client import ( + ServerProxy, + Unmarshaller, + Transport, + ExpatParser, + Fault, + ResponseError +) +logger = logging.getLogger(__package__) + + +class RPCUnmarshaller(Unmarshaller): + def __init__(self, *args, **kwargs): + Unmarshaller.__init__(self, *args, **kwargs) + self.error_pattern = re.compile(r'(?P[^:]*):(?P.*$)') + self.builtin_exceptions = self._get_built_in_exceptions() + + @staticmethod + def _get_built_in_exceptions(): + """ + Gets a list of the built in exception classes in python. + + :return list[BaseException] A list of the built in exception classes in python: + """ + builtin_exceptions = [] + for builtin_name, builtin_class in globals().get('__builtins__').items(): + if inspect.isclass(builtin_class) and issubclass(builtin_class, BaseException): + builtin_exceptions.append(builtin_class) + + return builtin_exceptions + + def close(self): + """ + Override so we redefine the unmarshaller. + + :return tuple: A tuple of marshallables. + """ + if self._type is None or self._marks: + raise ResponseError() + + if self._type == 'fault': + marshallables = self._stack[0] + match = self.error_pattern.match(marshallables.get('faultString', '')) + if match: + exception_name = match.group('exception').strip("") + exception_message = match.group('exception_message') + + if exception_name: + for exception in self.builtin_exceptions: + if exception.__name__ == exception_name: + raise exception(exception_message) + + # if all else fails just raise the fault + raise Fault(**marshallables) + return tuple(self._stack) + + +class RPCTransport(Transport): + def getparser(self): + """ + Override so we can redefine our transport to use its own custom unmarshaller. + + :return tuple(ExpatParser, RPCUnmarshaller): The parser and unmarshaller instances. + """ + unmarshaller = RPCUnmarshaller() + parser = ExpatParser(unmarshaller) + return parser, unmarshaller + + +class RPCServerProxy(ServerProxy): + def __init__(self, *args, **kwargs): + """ + Override so we can redefine the ServerProxy to use our custom transport. + """ + kwargs['transport'] = RPCTransport() + ServerProxy.__init__(self, *args, **kwargs) + + +class RPCClient: + def __init__(self, port, marshall_exceptions=True): + """ + Initializes the rpc client. + + :param int port: A port number the client should connect to. + :param bool marshall_exceptions: Whether or not the exceptions should be marshalled. + """ + if marshall_exceptions: + proxy_class = RPCServerProxy + else: + proxy_class = ServerProxy + + server_ip = os.environ.get('RPC_SERVER_IP', '127.0.0.1') + + self.proxy = proxy_class( + "http://{server_ip}:{port}".format(server_ip=server_ip, port=port), + allow_none=True, + ) + self.marshall_exceptions = marshall_exceptions + self.port = port diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/exceptions.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/exceptions.py new file mode 100644 index 0000000000..0e98d15244 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/exceptions.py @@ -0,0 +1,81 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +class BaseRPCException(Exception): + """ + Raised when a rpc class method is not authored as a static method. + """ + def __init__(self, message=None, line_link=''): + self.message = message + line_link + super().__init__(self.message) + + +class InvalidClassMethod(BaseRPCException): + """ + Raised when a rpc class method is not authored as a static method. + """ + def __init__(self, cls, method, message=None, line_link=''): + self.message = message + + if message is None: + self.message = ( + f'\n {cls.__name__}.{method.__name__} is not a static method. Please decorate with @staticmethod.' + ) + BaseRPCException.__init__(self, self.message, line_link) + + +class InvalidTestCasePort(BaseRPCException): + """ + Raised when a rpc test case class does not have a port defined. + """ + def __init__(self, cls, message=None, line_link=''): + self.message = message + + if message is None: + self.message = f'\n You must set {cls.__name__}.port to a supported RPC port.' + BaseRPCException.__init__(self, self.message, line_link) + + +class InvalidKeyWordParameters(BaseRPCException): + """ + Raised when a rpc function has key word arguments in its parameters. + """ + def __init__(self, function, kwargs, message=None, line_link=''): + self.message = message + + if message is None: + self.message = ( + f'\n Keyword arguments "{kwargs}" were found on "{function.__name__}". The RPC client does not ' + f'support key word arguments . Please change your code to use only arguments.' + ) + BaseRPCException.__init__(self, self.message, line_link) + + +class UnsupportedArgumentType(BaseRPCException): + """ + Raised when a rpc function's argument type is not supported. + """ + def __init__(self, function, arg, supported_types, message=None, line_link=''): + self.message = message + + if message is None: + self.message = ( + f'\n "{function.__name__}" has an argument of type "{arg.__class__.__name__}". The only types that are' + f' supported by the RPC client are {[supported_type.__name__ for supported_type in supported_types]}.' + ) + BaseRPCException.__init__(self, self.message, line_link) + + +class FileNotSavedOnDisk(BaseRPCException): + """ + Raised when a rpc function is called in a context where it is not a saved file on disk. + """ + def __init__(self, function, message=None): + self.message = message + + if message is None: + self.message = ( + f'\n "{function.__name__}" is not being called from a saved file. The RPC client does not ' + f'support code that is not saved. Please save your code to a file on disk and re-run it.' + ) + BaseRPCException.__init__(self, self.message) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/factory.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/factory.py new file mode 100644 index 0000000000..7063c15d6e --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/factory.py @@ -0,0 +1,252 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import os +import re +import sys +import logging +import types +import inspect +import textwrap +import unittest +from xmlrpc.client import Fault + +from .client import RPCClient +from .validations import ( + validate_key_word_parameters, + validate_class_method, + get_source_file_path, + get_line_link, + validate_arguments, + validate_file_is_saved, +) + +logger = logging.getLogger(__package__) + + +class RPCFactory: + def __init__(self, rpc_client, remap_pairs=None, default_imports=None): + self.rpc_client = rpc_client + self.file_path = None + self.remap_pairs = remap_pairs + self.default_imports = default_imports or [] + + def _get_callstack_references(self, code, function): + """ + Gets all references for the given code. + + :param list[str] code: The code of the callable. + :param callable function: A callable. + :return str: The new code of the callable with all its references added. + """ + import_code = self.default_imports + + client_module = inspect.getmodule(function) + self.file_path = get_source_file_path(function) + + # if a list of remap pairs have been set, the file path will be remapped to the new server location + # Note: The is useful when the server and client are not on the same machine. + server_module_path = self.file_path + for client_path_root, matching_server_path_root in self.remap_pairs or []: + if self.file_path.startswith(client_path_root): + server_module_path = os.path.join( + matching_server_path_root, + self.file_path.replace(client_path_root, '').replace(os.sep, '/').strip('/') + ) + break + + for key in dir(client_module): + for line_number, line in enumerate(code): + if line.startswith('def '): + continue + + if key in re.split('\.|\(| ', line.strip()): + if os.path.basename(self.file_path) == '__init__.py': + base_name = os.path.basename(os.path.dirname(self.file_path)) + else: + base_name = os.path.basename(self.file_path) + + module_name, file_extension = os.path.splitext(base_name) + import_code.append( + f'{module_name} = SourceFileLoader("{module_name}", r"{server_module_path}").load_module()' + ) + import_code.append(f'from {module_name} import {key}') + break + + return textwrap.indent('\n'.join(import_code), ' ' * 4) + + def _get_code(self, function): + """ + Gets the code from a callable. + + :param callable function: A callable. + :return str: The code of the callable. + """ + code = textwrap.dedent(inspect.getsource(function)).split('\n') + code = [line for line in code if not line.startswith('@')] + + # get import code and insert them inside the function + import_code = self._get_callstack_references(code, function) + code.insert(1, import_code) + + # log out the generated code + if os.environ.get('RPC_LOG_CODE'): + for line in code: + logger.debug(line) + + return code + + def _register(self, function): + """ + Registers a given callable with the server. + + :param callable function: A callable. + :return Any: The return value. + """ + code = self._get_code(function) + try: + # if additional paths are explicitly set, then use them. This is useful with the client is on another + # machine and the python paths are different + additional_paths = list(filter(None, os.environ.get('RPC_ADDITIONAL_PYTHON_PATHS', '').split(','))) + + if not additional_paths: + # otherwise use the current system path + additional_paths = sys.path + + response = self.rpc_client.proxy.add_new_callable( + function.__name__, '\n'.join(code), + additional_paths + ) + if os.environ.get('RPC_DEBUG'): + logger.debug(response) + + except ConnectionRefusedError: + server_name = os.environ.get(f'RPC_SERVER_{self.rpc_client.port}', self.rpc_client.port) + raise ConnectionRefusedError(f'No connection could be made with "{server_name}"') + + def run_function_remotely(self, function, args): + """ + Handles running the given function on remotely. + + :param callable function: A function reference. + :param tuple(Any) args: The function's arguments. + :return callable: A remote callable. + """ + validate_arguments(function, args) + + # get the remote function instance + self._register(function) + remote_function = getattr(self.rpc_client.proxy, function.__name__) + + current_frame = inspect.currentframe() + outer_frame_info = inspect.getouterframes(current_frame) + # step back 2 frames in the callstack + caller_frame = outer_frame_info[2][0] + # create a trace back that is relevant to the remote code rather than the code transporting it + call_traceback = types.TracebackType(None, caller_frame, caller_frame.f_lasti, caller_frame.f_lineno) + # call the remote function + if not self.rpc_client.marshall_exceptions: + # if exceptions are not marshalled then receive the default Faut + return remote_function(*args) + + # otherwise catch them and add a line link to them + try: + return remote_function(*args) + except Exception as exception: + stack_trace = str(exception) + get_line_link(function) + if isinstance(exception, Fault): + raise Fault(exception.faultCode, exception.faultString) + raise exception.__class__(stack_trace).with_traceback(call_traceback) + + +def remote_call(port, default_imports=None, remap_pairs=None): + """ + A decorator that makes this function run remotely. + + :param Enum port: The name of the port application i.e. maya, blender, unreal. + :param list[str] default_imports: A list of import commands that include modules in every call. + :param list(tuple) remap_pairs: A list of tuples with first value being the client file path root and the + second being the matching server path root. This can be useful if the client and server are on two different file + systems and the root of the import paths need to be dynamically replaced. + """ + def decorator(function): + def wrapper(*args, **kwargs): + validate_file_is_saved(function) + validate_key_word_parameters(function, kwargs) + rpc_factory = RPCFactory( + rpc_client=RPCClient(port), + remap_pairs=remap_pairs, + default_imports=default_imports + ) + return rpc_factory.run_function_remotely(function, args) + return wrapper + return decorator + + +def remote_class(decorator): + """ + A decorator that makes this class run remotely. + + :param remote_call decorator: The remote call decorator. + :return: A decorated class. + """ + def decorate(cls): + for attribute, value in cls.__dict__.items(): + validate_class_method(cls, value) + if callable(getattr(cls, attribute)): + setattr(cls, attribute, decorator(getattr(cls, attribute))) + return cls + return decorate + + +class RPCTestCase(unittest.TestCase): + """ + Subclasses unittest.TestCase to implement a RPC compatible TestCase. + """ + port = None + remap_pairs = None + default_imports = None + + @classmethod + def run_remotely(cls, method, args): + """ + Run the given method remotely. + + :param callable method: A method to wrap. + """ + default_imports = cls.__dict__.get('default_imports', None) + port = cls.__dict__.get('port', None) + remap_pairs = cls.__dict__.get('remap_pairs', None) + rpc_factory = RPCFactory( + rpc_client=RPCClient(port), + default_imports=default_imports, + remap_pairs=remap_pairs + ) + return rpc_factory.run_function_remotely(method, args) + + def _callSetUp(self): + """ + Overrides the TestCase._callSetUp method by passing it to be run remotely. + Notice None is passed as an argument instead of self. This is because only static methods + are allowed by the RPCClient. + """ + self.run_remotely(self.setUp, [None]) + + def _callTearDown(self): + """ + Overrides the TestCase._callTearDown method by passing it to be run remotely. + Notice None is passed as an argument instead of self. This is because only static methods + are allowed by the RPCClient. + """ + # notice None is passed as an argument instead of self so self can't be used + self.run_remotely(self.tearDown, [None]) + + def _callTestMethod(self, method): + """ + Overrides the TestCase._callTestMethod method by capturing the test case method that would be run and then + passing it to be run remotely. Notice no arguments are passed. This is because only static methods + are allowed by the RPCClient. + + :param callable method: A method from the test case. + """ + self.run_remotely(method, []) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/server.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/server.py new file mode 100644 index 0000000000..6bc1451794 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/server.py @@ -0,0 +1,32 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import os +import sys +sys.path.append(os.path.dirname(__file__)) + +from base_server import BaseRPCServerManager, BaseRPCServerThread + + +class RPCServerThread(BaseRPCServerThread): + def thread_safe_call(self, callable_instance, *args): + """ + Implementation of a thread safe call in Unreal. + """ + return callable_instance(*args) + + +class RPCServer(BaseRPCServerManager): + def __init__(self, port=None): + """ + Initialize the blender rpc server, with its name and specific port. + """ + super(RPCServer, self).__init__() + self.name = 'RPCServer' + self.port = int(os.environ.get('RPC_PORT', port)) + self.threaded_server_class = RPCServerThread + + +if __name__ == '__main__': + rpc_server = RPCServer() + rpc_server.start(threaded=False) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/validations.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/validations.py new file mode 100644 index 0000000000..6e4e986ca3 --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/rpc/validations.py @@ -0,0 +1,108 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +import inspect + +from .exceptions import ( + InvalidClassMethod, + InvalidTestCasePort, + InvalidKeyWordParameters, + UnsupportedArgumentType, + FileNotSavedOnDisk, +) + + +def get_source_file_path(function): + """ + Gets the full path to the source code. + + :param callable function: A callable. + :return str: A file path. + """ + client_module = inspect.getmodule(function) + return client_module.__file__ + + +def get_line_link(function): + """ + Gets the line number of a function. + + :param callable function: A callable. + :return int: The line number + """ + lines, line_number = inspect.getsourcelines(function) + file_path = get_source_file_path(function) + return f' File "{file_path}", line {line_number}' + + +def validate_arguments(function, args): + """ + Validates arguments to ensure they are a supported type. + + :param callable function: A function reference. + :param tuple(Any) args: A list of arguments. + """ + supported_types = [str, int, float, tuple, list, dict, bool] + line_link = get_line_link(function) + for arg in args: + if arg is None: + continue + + if type(arg) not in supported_types: + raise UnsupportedArgumentType(function, arg, supported_types, line_link=line_link) + + +def validate_test_case_class(cls): + """ + This is use to validate a subclass of RPCTestCase. While building your test + suite you can call this method on each class preemptively to validate that it + was defined correctly. + + :param RPCTestCase cls: A class. + :param str file_path: Optionally, a file path to the test case can be passed to give + further context into where the error is occurring. + """ + line_link = get_line_link(cls) + if not cls.__dict__.get('port'): + raise InvalidTestCasePort(cls, line_link=line_link) + + for attribute, method in cls.__dict__.items(): + if callable(method) and not isinstance(method, staticmethod): + if method.__name__.startswith('test'): + raise InvalidClassMethod(cls, method, line_link=line_link) + + +def validate_class_method(cls, method): + """ + Validates a method on a class. + + :param Any cls: A class. + :param callable method: A callable. + """ + if callable(method) and not isinstance(method, staticmethod): + line_link = get_line_link(method) + raise InvalidClassMethod(cls, method, line_link=line_link) + + +def validate_key_word_parameters(function, kwargs): + """ + Validates a method on a class. + + :param callable function: A callable. + :param dict kwargs: A dictionary of key word arguments. + """ + if kwargs: + line_link = get_line_link(function) + raise InvalidKeyWordParameters(function, kwargs, line_link=line_link) + + +def validate_file_is_saved(function): + """ + Validates that the file that the function is from is saved on disk. + + :param callable function: A callable. + """ + try: + inspect.getsourcelines(function) + except OSError: + raise FileNotSavedOnDisk(function) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/submit_deadline_job.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/submit_deadline_job.py new file mode 100644 index 0000000000..e5c6e0bebb --- /dev/null +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/ue_utils/submit_deadline_job.py @@ -0,0 +1,72 @@ +# Copyright Epic Games, Inc. All Rights Reserved +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +from System.Collections.Specialized import StringCollection +from System.IO import StreamWriter, Path +from System.Text import Encoding + +from Deadline.Scripting import ClientUtils + + +def submit_job(name, job_info, plugin_info, aux_files=None): + """ + Creates a job and plugin file and submits it to deadline as a job + :param name: Name of the plugin + :param job_info: The job dictionary + :type job_info dict + :param plugin_info: The plugin dictionary + :type plugin_info dict + :param aux_files: The files submitted to the farm + :type aux_files list + """ + + # Create a job file + JobInfoFilename = Path.Combine( + ClientUtils.GetDeadlineTempPath(), + "{name}_job_info.job".format(name=name), + ) + # Get a job info file writer + writer = StreamWriter(JobInfoFilename, False, Encoding.Unicode) + + for key, value in job_info.items(): + writer.WriteLine("{key}={value}".format(key=key, value=value)) + + writer.Close() + + # Create a plugin file + PluginInfoFilename = Path.Combine( + ClientUtils.GetDeadlineTempPath(), + "{name}_plugin_info.job".format(name=name), + ) + # Get a plugin info file writer + writer = StreamWriter(PluginInfoFilename, False, Encoding.Unicode) + + for key, value in plugin_info.items(): + writer.WriteLine("{key}={value}".format(key=key, value=value)) + + # Add Aux Files if any + if aux_files: + for index, aux_files in enumerate(aux_files): + writer.WriteLine( + "File{index}={val}".format(index=index, val=aux_files) + ) + + writer.Close() + + # Create the commandline arguments + args = StringCollection() + + args.Add(JobInfoFilename) + args.Add(PluginInfoFilename) + + # Add aux files to the plugin data + if aux_files: + for scene_file in aux_files: + args.Add(scene_file) + + + results = ClientUtils.ExecuteCommandAndGetOutput(args) + + # TODO: Return the Job ID and results + + return results diff --git a/client/ayon_deadline/repository/readme.md b/client/ayon_deadline/repository/readme.md index 31ffffd0b7..1efe94c63e 100644 --- a/client/ayon_deadline/repository/readme.md +++ b/client/ayon_deadline/repository/readme.md @@ -9,8 +9,8 @@ GlobalJobPreLoad ----- -The `GlobalJobPreLoad` will retrieve the OpenPype executable path from the -`OpenPype` Deadline Plug-in's settings. Then it will call the executable to +The `GlobalJobPreLoad` will retrieve the AYON executable path from the +`Ayon` Deadline Plug-in's settings. Then it will call the executable to retrieve the environment variables needed for the Deadline Job. These environment variables are injected into rendering process. @@ -22,8 +22,16 @@ for old Pype2 and non-OpenPype triggered jobs. Plugin ------ - For each render and publishing job the `OpenPype` Deadline Plug-in is checked + For each render and publishing job the `AYON` Deadline Plug-in is checked for the configured location of the OpenPype executable (needs to be configured in `Deadline's Configure Plugins > OpenPype`) through `GlobalJobPreLoad`. + +Unreal5 Plugin +-------------- +Whole Unreal5 plugin copied here as it is not possible to add to custom folder only `JobPreLoad.py` and `UnrealSyncUtil.py` which is handling Perforce. +Might need to be revisited as this would create dependency on official Unreal5 plugin. + +`JobPreLoad.py` and `UnrealSyncUtil.py` handles Perforce syncing, must be triggered before Unreal rendering job. +It would better to have here only these two files here, but deployment wouldn't be straightforward copy as for other plugins. From de75b45f93639c882c5135a4cfd1637239ab331a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 10 Jun 2024 13:59:51 +0200 Subject: [PATCH 02/11] Added unreal to deadline plugins --- .../plugins/publish/collect_pools.py | 1 + .../publish/collect_user_credentials.py | 3 +- .../plugins/publish/submit_publish_job.py | 2 +- .../plugins/publish/submit_unreal_deadline.py | 224 ++++++++++++++++++ 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 client/ayon_deadline/plugins/publish/submit_unreal_deadline.py diff --git a/client/ayon_deadline/plugins/publish/collect_pools.py b/client/ayon_deadline/plugins/publish/collect_pools.py index b2b6bc60d4..6de68502f1 100644 --- a/client/ayon_deadline/plugins/publish/collect_pools.py +++ b/client/ayon_deadline/plugins/publish/collect_pools.py @@ -36,6 +36,7 @@ class CollectDeadlinePools(pyblish.api.InstancePlugin, "max", "houdini", "nuke", + "unreal" ] families = FARM_FAMILIES diff --git a/client/ayon_deadline/plugins/publish/collect_user_credentials.py b/client/ayon_deadline/plugins/publish/collect_user_credentials.py index 1c59c178d3..fa59139c00 100644 --- a/client/ayon_deadline/plugins/publish/collect_user_credentials.py +++ b/client/ayon_deadline/plugins/publish/collect_user_credentials.py @@ -35,7 +35,8 @@ class CollectDeadlineUserCredentials(pyblish.api.InstancePlugin): "nuke", "maya", "max", - "houdini"] + "houdini", + "unreal"] families = FARM_FAMILIES diff --git a/client/ayon_deadline/plugins/publish/submit_publish_job.py b/client/ayon_deadline/plugins/publish/submit_publish_job.py index 643dcc1c46..d773341e70 100644 --- a/client/ayon_deadline/plugins/publish/submit_publish_job.py +++ b/client/ayon_deadline/plugins/publish/submit_publish_job.py @@ -86,7 +86,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, targets = ["local"] hosts = ["fusion", "max", "maya", "nuke", "houdini", - "celaction", "aftereffects", "harmony", "blender"] + "celaction", "aftereffects", "harmony", "blender", "unreal"] families = ["render", "render.farm", "render.frames_farm", "prerender", "prerender.farm", "prerender.frames_farm", diff --git a/client/ayon_deadline/plugins/publish/submit_unreal_deadline.py b/client/ayon_deadline/plugins/publish/submit_unreal_deadline.py new file mode 100644 index 0000000000..1f7d511203 --- /dev/null +++ b/client/ayon_deadline/plugins/publish/submit_unreal_deadline.py @@ -0,0 +1,224 @@ +import os +import attr +import getpass +import pyblish.api +from datetime import datetime + +from ayon_core.lib import ( + env_value_to_bool, + collect_frames, + is_in_tests, +) +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo + + +@attr.s +class DeadlinePluginInfo(): + ProjectFile = attr.ib(default=None) + EditorExecutableName = attr.ib(default=None) + EngineVersion = attr.ib(default=None) + CommandLineMode = attr.ib(default=True) + OutputFilePath = attr.ib(default=None) + Output = attr.ib(default=None) + StartupDirectory = attr.ib(default=None) + CommandLineArguments = attr.ib(default=None) + MultiProcess = attr.ib(default=None) + PerforceStream = attr.ib(default=None) + PerforceChangelist = attr.ib(default=None) + PerforceGamePath = attr.ib(default=None) + + +class UnrealSubmitDeadline( + abstract_submit_deadline.AbstractSubmitDeadline +): + """Supports direct rendering of prepared Unreal project on Deadline + (`render` product must be created with flag for Farm publishing) OR + Perforce assisted rendering. + + For this Ayon server must contain `ayon-version-control` addon and provide + configuration for it (P4 credentials etc.)! + """ + + label = "Submit Unreal to Deadline" + order = pyblish.api.IntegratorOrder + 0.1 + hosts = ["unreal"] + families = ["render.farm"] # cannot be "render' as that is integrated + use_published = True + targets = ["local"] + + priority = 50 + chunk_size = 1000000 + group = None + department = None + multiprocess = True + + def get_job_info(self): + dln_job_info = DeadlineJobInfo(Plugin="UnrealEngine5") + + context = self._instance.context + + batch_name = self._get_batch_name() + dln_job_info.Name = self._instance.data["name"] + dln_job_info.BatchName = batch_name + dln_job_info.Plugin = "UnrealEngine5" + dln_job_info.UserName = context.data.get( + "deadlineUser", getpass.getuser()) + if self._instance.data["frameEnd"] > self._instance.data["frameStart"]: + # Deadline requires integers in frame range + frame_range = "{}-{}".format( + int(round(self._instance.data["frameStart"])), + int(round(self._instance.data["frameEnd"]))) + dln_job_info.Frames = frame_range + + dln_job_info.Priority = self.priority + dln_job_info.Pool = self._instance.data.get("primaryPool") + dln_job_info.SecondaryPool = self._instance.data.get("secondaryPool") + dln_job_info.Group = self.group + dln_job_info.Department = self.department + dln_job_info.ChunkSize = self.chunk_size + dln_job_info.OutputFilename += \ + os.path.basename(self._instance.data["file_names"][0]) + dln_job_info.OutputDirectory += \ + os.path.dirname(self._instance.data["expectedFiles"][0]) + dln_job_info.JobDelay = "00:00:00" + + keys = [ + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "AYON_PROJECT_NAME", + "AYON_FOLDER_PATH", + "AYON_TASK_NAME", + "AYON_WORKDIR", + "AYON_APP_NAME", + "AYON_LOG_NO_COLORS", + "IS_TEST", + ] + + environment = { + key: os.environ[key] + for key in keys + if key in os.environ + } + for key in keys: + value = environment.get(key) + if value: + dln_job_info.EnvironmentKeyValue[key] = value + + dln_job_info.EnvironmentKeyValue["AYON_UNREAL_VERSION"] = ( + self._instance.data)["app_version"] + + # to recognize render jobs + dln_job_info.add_render_job_env_var() + + return dln_job_info + + def get_plugin_info(self): + deadline_plugin_info = DeadlinePluginInfo() + + render_path = self._instance.data["expectedFiles"][0] + self._instance.data["outputDir"] = os.path.dirname(render_path) + self._instance.context.data["version"] = 1 #TODO + + render_dir = os.path.dirname(render_path) + file_name = self._instance.data["file_names"][0] + render_path = os.path.join(render_dir, file_name) + + deadline_plugin_info.ProjectFile = self.scene_path + deadline_plugin_info.Output = render_path.replace("\\", "/") + + deadline_plugin_info.ProjectFile = self.scene_path + deadline_plugin_info.EditorExecutableName = "UnrealEditor-Cmd.exe" # parse ayon+settings://applications/applications/unreal/variants/3/environmen + deadline_plugin_info.EngineVersion = self._instance.data["app_version"] + master_level = self._instance.data["master_level"] + render_queue_path = self._instance.data["render_queue_path"] + cmd_args = [f"{master_level} -game ", + f"-MoviePipelineConfig={render_queue_path}"] + cmd_args.extend([ + "-windowed", + "-Log", + "-StdOut", + "-allowStdOutLogVerbosity" + "-Unattended" + ]) + self.log.info(f"cmd-args::{cmd_args}") + deadline_plugin_info.CommandLineArguments = " ".join(cmd_args) + + # if Perforce - triggered by active `publish_commit` instance!! + collected_version_control = self._get_version_control() + if collected_version_control: + self._update_version_control_data(collected_version_control, + deadline_plugin_info) + + return attr.asdict(deadline_plugin_info) + + def from_published_scene(self): + """ Do not overwrite expected files. + + Use published is set to True, so rendering will be triggered + from published scene (in 'publish' folder). Default implementation + of abstract class renames expected (eg. rendered) files accordingly + which is not needed here. + """ + return super().from_published_scene(False) + + def _get_batch_name(self): + """Returns value that differentiate jobs in DL. + + For automatic tests it adds timestamp, for Perforce driven change list + """ + batch_name = os.path.basename(self._instance.data["source"]) + if is_in_tests(): + batch_name += datetime.now().strftime("%d%m%Y%H%M%S") + collected_version_control = self._get_version_control() + if collected_version_control: + change = (collected_version_control["change_info"] + ["change"]) + batch_name = f"{batch_name}_{change}" + return batch_name + + def _get_version_control(self): + """Look if publish_commit is published to get change list info. + + Context version_control contains universal connection info, instance + version_control contains detail about change list. + """ + change_list_version = {} + for inst in self._instance.context: + # get change info from `publish_commit` instance + change_list_version = inst.data.get("version_control") + if change_list_version: + context_version = ( + self._instance.context.data["version_control"]) + change_list_version.update(context_version) + break + return change_list_version + + def _update_version_control_data(self, collected_version_control, + deadline_plugin_info): + """Adds Perforce metadata which causes DL pre job to sync to change. + + It triggers only in presence of activated `publish_commit` instance, + which materialize info about commit. Artists could return to any + published commit and re-render if they choose. + `publish_commit` replaces `workfile` as there are no versioned Unreal + projects (because of size). + """ + self.log.info(f"collected_version_control::{collected_version_control}") + + unreal_project_file_name = os.path.basename(self.scene_path) + version_control_data = self._instance.context.data["version_control"] + workspace_dir = version_control_data["workspace_dir"] + unreal_project_hierarchy = self.scene_path.replace(workspace_dir, + "") + unreal_project_hierarchy = ( + unreal_project_hierarchy.replace(unreal_project_file_name, "")) + unreal_project_hierarchy = unreal_project_hierarchy.strip("\\") + + deadline_plugin_info.ProjectFile = unreal_project_file_name + + deadline_plugin_info.PerforceStream = version_control_data["stream"] + deadline_plugin_info.PerforceChangelist = collected_version_control[ + "change_info"]["change"] + deadline_plugin_info.PerforceGamePath = unreal_project_hierarchy From 03a1675b459a43d4a03d1ca89e412829af33f2bc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 23 Jul 2024 14:51:13 +0200 Subject: [PATCH 03/11] Moved unreal deadline plugin to subfolder Subfolders were added to limit imports in different DCCs --- .../plugins/publish/{ => unreal}/submit_unreal_deadline.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/ayon_deadline/plugins/publish/{ => unreal}/submit_unreal_deadline.py (100%) diff --git a/client/ayon_deadline/plugins/publish/submit_unreal_deadline.py b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py similarity index 100% rename from client/ayon_deadline/plugins/publish/submit_unreal_deadline.py rename to client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py From 6d2fa50dfab6ea1e6350adc836c0a147ce679cc3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 24 Jul 2024 12:15:15 +0200 Subject: [PATCH 04/11] Fix resolution of PerforceGamePath It should be relative path from workspace dir until folder with project --- .../publish/unreal/submit_unreal_deadline.py | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py index 1f7d511203..d04e81f9de 100644 --- a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py +++ b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py @@ -3,6 +3,7 @@ import getpass import pyblish.api from datetime import datetime +from pathlib import Path from ayon_core.lib import ( env_value_to_bool, @@ -128,7 +129,6 @@ def get_plugin_info(self): deadline_plugin_info.ProjectFile = self.scene_path deadline_plugin_info.Output = render_path.replace("\\", "/") - deadline_plugin_info.ProjectFile = self.scene_path deadline_plugin_info.EditorExecutableName = "UnrealEditor-Cmd.exe" # parse ayon+settings://applications/applications/unreal/variants/3/environmen deadline_plugin_info.EngineVersion = self._instance.data["app_version"] master_level = self._instance.data["master_level"] @@ -148,8 +148,17 @@ def get_plugin_info(self): # if Perforce - triggered by active `publish_commit` instance!! collected_version_control = self._get_version_control() if collected_version_control: - self._update_version_control_data(collected_version_control, - deadline_plugin_info) + version_control_data = self._instance.context.data[ + "version_control"] + workspace_dir = version_control_data["workspace_dir"] + stream = version_control_data["stream"] + self._update_version_control_data( + self.scene_path, + workspace_dir, + stream, + collected_version_control["change_info"]["change"], + deadline_plugin_info + ) return attr.asdict(deadline_plugin_info) @@ -195,8 +204,14 @@ def _get_version_control(self): break return change_list_version - def _update_version_control_data(self, collected_version_control, - deadline_plugin_info): + def _update_version_control_data( + self, + scene_path, + workspace_dir, + stream, + change_list_id, + deadline_plugin_info + ): """Adds Perforce metadata which causes DL pre job to sync to change. It triggers only in presence of activated `publish_commit` instance, @@ -205,20 +220,20 @@ def _update_version_control_data(self, collected_version_control, `publish_commit` replaces `workfile` as there are no versioned Unreal projects (because of size). """ - self.log.info(f"collected_version_control::{collected_version_control}") + # normalize paths, c:/ vs C:/ + scene_path = str(Path(scene_path).resolve()) + workspace_dir = str(Path(workspace_dir).resolve()) + + unreal_project_file_name = os.path.basename(scene_path) - unreal_project_file_name = os.path.basename(self.scene_path) - version_control_data = self._instance.context.data["version_control"] - workspace_dir = version_control_data["workspace_dir"] - unreal_project_hierarchy = self.scene_path.replace(workspace_dir, - "") + unreal_project_hierarchy = self.scene_path.replace(workspace_dir, "") unreal_project_hierarchy = ( unreal_project_hierarchy.replace(unreal_project_file_name, "")) + # relative path from workspace dir to last folder unreal_project_hierarchy = unreal_project_hierarchy.strip("\\") deadline_plugin_info.ProjectFile = unreal_project_file_name - deadline_plugin_info.PerforceStream = version_control_data["stream"] - deadline_plugin_info.PerforceChangelist = collected_version_control[ - "change_info"]["change"] + deadline_plugin_info.PerforceStream = stream + deadline_plugin_info.PerforceChangelist = change_list_id deadline_plugin_info.PerforceGamePath = unreal_project_hierarchy From 0fc1dcdada9c74c80268de6850fb6d9f9f1e5b48 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 24 Jul 2024 12:20:56 +0200 Subject: [PATCH 05/11] Fix querying P4PORT from env 'env' is controlled by AYON, os.getenv might not Stream is required --- .../plugins/UnrealEngine5/JobPreLoad.py | 37 +++++++++---------- .../plugins/UnrealEngine5/UnrealSyncUtil.py | 9 +++-- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py index 40477e028e..93ed0b5e72 100644 --- a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py @@ -15,6 +15,7 @@ def __main__( deadlinePlugin ): stream = deadlinePlugin.GetPluginInfoEntry("PerforceStream") if not stream: print("Perforce info not collected, skipping!") + return changelist = int(deadlinePlugin.GetPluginInfoEntryWithDefault("PerforceChangelist", "0")) gamePath = deadlinePlugin.GetPluginInfoEntry("PerforceGamePath") projectFile = deadlinePlugin.GetPluginInfoEntry("ProjectFile") @@ -26,14 +27,10 @@ def __main__( deadlinePlugin ): bForceFullSync = deadlinePlugin.GetPluginInfoEntryWithDefault("ForceFullSync", "false").lower() == "true" bSyncProject = deadlinePlugin.GetPluginInfoEntryWithDefault("SyncProject", "true" ).lower() == "true" bSyncEntireStream = deadlinePlugin.GetPluginInfoEntryWithDefault("SyncEntireStream", "false").lower() == "true" - bBuildProject = True - - print("bSyncProject::: " + str(bSyncProject)) - print("bSyncEntireStream: " + str(bSyncEntireStream)) - + bBuildProject = True # - # Set up PerforceUtil + # Set up PerforceUtil # try: @@ -62,7 +59,7 @@ def __main__( deadlinePlugin ): deadlinePlugin.FailRender(argError.message) except UnrealSyncUtil.PerforceMultipleWorkspaceError as argError: deadlinePlugin.LogWarning(argError.message) - deadlinePlugin.FailRender(argError.message) + deadlinePlugin.FailRender(argError.message) # Set project root # This resolves gamePath in case it contains "..."" @@ -74,7 +71,7 @@ def __main__( deadlinePlugin ): except UnrealSyncUtil.PerforceError as argError: deadlinePlugin.LogWarning(argError.message) deadlinePlugin.FailRender(argError.message) - + projectRoot = perforceTools.projectRoot.replace('\\','/') deadlinePlugin.LogInfo( "Storing UnrealProjectRoot (\"%s\") in environment variable..." % projectRoot ) deadlinePlugin.SetProcessEnvironmentVariable( "UnrealProjectRoot", projectRoot ) @@ -85,7 +82,7 @@ def __main__( deadlinePlugin ): # Set the option if it's syncing entire stream or just game path - perforceTools.SetSyncEntireStream( bSyncEntireStream ) + perforceTools.SetSyncEntireStream( bSyncEntireStream ) # # Clean workspace @@ -97,9 +94,9 @@ def __main__( deadlinePlugin ): deadlinePlugin.LogInfo("Performing a perforce clean to bring local files in sync with depot.") perforceTools.CleanWorkspace() deadlinePlugin.LogInfo("Finished p4 clean.") - + deadlinePlugin.LogInfo("Perforce Command Prefix: " + " ".join(perforceTools.GetP4CommandPrefix())) - + # Determine the latest changelist to sync to if unspecified. try: if changelist == 0: @@ -116,7 +113,7 @@ def __main__( deadlinePlugin ): # # Sync project - # + # if bSyncProject: # Estimate how much work there is to do for a sync operation. @@ -127,7 +124,7 @@ def __main__( deadlinePlugin ): except UnrealSyncUtil.PerforceResponseError as argError: deadlinePlugin.LogWarning(str(argError)) deadlinePlugin.LogWarning("No sync estimates will be available.") - + # If there's no files to sync, let's skip running the sync. It takes a lot of time as it's a double-estimate. if perforceTools.syncEstimates[0] == 0 and perforceTools.syncEstimates[1] == 0 and perforceTools.syncEstimates[2] == 0: deadlinePlugin.LogInfo("Skipping sync command as estimated says there's no work to sync!") @@ -138,13 +135,13 @@ def __main__( deadlinePlugin ): deadlinePlugin.LogInfo("Syncing to CL %d" % perforceTools.changelist) deadlinePlugin.SetProgress(0) deadlinePlugin.LogInfo("Estimated Files %s (added/updated/deleted)" % ("/".join(map(str, perforceTools.syncEstimates)))) - + logCallback = lambda tools: deadlinePlugin.SetProgress(perforceTools.GetSyncProgress() * 100) - + # Perform the sync. This could take a while. perforceTools.Sync(logCallback, bForceFullSync) - + # The estimates are only estimates, so when the command is complete we'll ensure it looks complete. deadlinePlugin.SetStatusMessage("Synced Workspace to CL " + str(perforceTools.changelist)) deadlinePlugin.LogInfo("Synced Workspace to CL " + str(perforceTools.changelist)) @@ -154,10 +151,10 @@ def __main__( deadlinePlugin ): deadlinePlugin.FailRender("Suspected Out of Disk Error while syncing: \"%s\"" % str(ioError)) else: deadlinePlugin.LogInfo("Skipping Project Sync due to job settings.") - - # + + # # Build project # if bBuildProject: @@ -181,7 +178,7 @@ def __main__( deadlinePlugin ): engine_root = unreal_executable.split('/Engine/Binaries/')[0] uproject_path = perforceTools.uprojectPath - + buildtool = UnrealSyncUtil.BuildUtils( engine_root, uproject_path, editorName ) if not buildtool.IsCppProject(): @@ -189,7 +186,7 @@ def __main__( deadlinePlugin ): else: deadlinePlugin.LogInfo("Starting a local build") - try: + try: deadlinePlugin.LogInfo("Generating project files...") deadlinePlugin.SetStatusMessage("Generating project files") buildtool.GenerateProjectFiles() diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py index 741400a54d..af0cf796af 100644 --- a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/UnrealSyncUtil.py @@ -106,7 +106,7 @@ def run(self): class PerforceUtils(object): def __init__(self, stream, gamePath, env): # The hostname of the perforce server. Defaults to the "P4PORT" Environment Var. - self._serverName = self._FindServerHostName() + self._serverName = self._FindServerHostName(env) if not self._serverName: raise PerforceError('"P4PORT" has not been set in the Slave environment!') @@ -175,10 +175,13 @@ def uprojectPath(self): def setChangelist(self, value): self._changelist = value - def _FindServerHostName(self): + def _FindServerHostName(self, env): # The hostname of the perforce server. Defaults to the "P4PORT" Environment Var. # If it's not set, try to find it from 'p4 set' command - name = os.getenv("P4PORT") + if env: + name = env.get("P4PORT") + else: + name = os.getenv("P4PORT") if name: return name output = subprocess.check_output(["p4", "set"]) From c6211be30d222e3bd322fc72c065245f991c7d7e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 31 Jul 2024 11:14:50 +0200 Subject: [PATCH 06/11] Update imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- .../plugins/publish/unreal/submit_unreal_deadline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py index d04e81f9de..f4f7af92b2 100644 --- a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py +++ b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py @@ -10,8 +10,8 @@ collect_frames, is_in_tests, ) -from openpype_modules.deadline import abstract_submit_deadline -from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo +from ayon_deadline import abstract_submit_deadline +from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo @attr.s From d9a3f6de43e251f3aeb10510636e94e20cd57147 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 31 Jul 2024 11:15:01 +0200 Subject: [PATCH 07/11] Update logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- .../plugins/publish/unreal/submit_unreal_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py index f4f7af92b2..8f1c804a70 100644 --- a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py +++ b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py @@ -142,7 +142,7 @@ def get_plugin_info(self): "-allowStdOutLogVerbosity" "-Unattended" ]) - self.log.info(f"cmd-args::{cmd_args}") + self.log.debug(f"cmd-args::{cmd_args}") deadline_plugin_info.CommandLineArguments = " ".join(cmd_args) # if Perforce - triggered by active `publish_commit` instance!! From b57f535265ea91ea26c1f6681be7bc98cce825a1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 31 Jul 2024 11:19:07 +0200 Subject: [PATCH 08/11] Removed hardcoded unreal engine path --- .../repository/custom/plugins/UnrealEngine5/JobPreLoad.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py index 93ed0b5e72..e53818f936 100644 --- a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py @@ -138,7 +138,6 @@ def __main__( deadlinePlugin ): logCallback = lambda tools: deadlinePlugin.SetProgress(perforceTools.GetSyncProgress() * 100) - # Perform the sync. This could take a while. perforceTools.Sync(logCallback, bForceFullSync) @@ -166,7 +165,6 @@ def __main__( deadlinePlugin ): executable_key = f"UnrealEditorExecutable_{version_string}" unreal_exe_list = (deadlinePlugin.GetEnvironmentVariable(executable_key) or deadlinePlugin.GetEnvironmentVariable("UnrealExecutable")) - unreal_exe_list = r"C:\Program Files\Epic Games\UE_5.3\Engine\Binaries\Win64\UnrealEditor-Cmd.exe" # TODO TEMP! if not unreal_exe_list: deadlinePlugin.FailRender( "Unreal Engine " + str(version) + " entry not found in .param file" ) unreal_executable = FileUtils.SearchFileList( unreal_exe_list ) From bed1687e619dceaac6dd38766fd5abe89ed38fde Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 31 Jul 2024 11:31:19 +0200 Subject: [PATCH 09/11] Renamed family to changelist_metadata --- .../plugins/publish/unreal/submit_unreal_deadline.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py index 8f1c804a70..4d598a26b6 100644 --- a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py +++ b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py @@ -145,7 +145,7 @@ def get_plugin_info(self): self.log.debug(f"cmd-args::{cmd_args}") deadline_plugin_info.CommandLineArguments = " ".join(cmd_args) - # if Perforce - triggered by active `publish_commit` instance!! + # if Perforce - triggered by active `changelist_metadata` instance!! collected_version_control = self._get_version_control() if collected_version_control: version_control_data = self._instance.context.data[ @@ -188,14 +188,14 @@ def _get_batch_name(self): return batch_name def _get_version_control(self): - """Look if publish_commit is published to get change list info. + """Look if changelist_metadata is published to get change list info. Context version_control contains universal connection info, instance version_control contains detail about change list. """ change_list_version = {} for inst in self._instance.context: - # get change info from `publish_commit` instance + # get change info from `changelist_metadata` instance change_list_version = inst.data.get("version_control") if change_list_version: context_version = ( @@ -214,10 +214,10 @@ def _update_version_control_data( ): """Adds Perforce metadata which causes DL pre job to sync to change. - It triggers only in presence of activated `publish_commit` instance, + It triggers only in presence of activated `changelist_metadata` instance, which materialize info about commit. Artists could return to any published commit and re-render if they choose. - `publish_commit` replaces `workfile` as there are no versioned Unreal + `changelist_metadata` replaces `workfile` as there are no versioned Unreal projects (because of size). """ # normalize paths, c:/ vs C:/ From da8f96e4fb22d7120efedd6737d2b446a49d126b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 7 Aug 2024 10:40:55 +0200 Subject: [PATCH 10/11] :dog: fix ruff warnings --- .../plugins/publish/unreal/submit_unreal_deadline.py | 7 ++----- ruff.toml | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py index 4d598a26b6..cabb764437 100644 --- a/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py +++ b/client/ayon_deadline/plugins/publish/unreal/submit_unreal_deadline.py @@ -5,11 +5,8 @@ from datetime import datetime from pathlib import Path -from ayon_core.lib import ( - env_value_to_bool, - collect_frames, - is_in_tests, -) +from ayon_core.lib import is_in_tests + from ayon_deadline import abstract_submit_deadline from ayon_deadline.abstract_submit_deadline import DeadlineJobInfo diff --git a/ruff.toml b/ruff.toml index c6b550b68e..36bd84fa29 100644 --- a/ruff.toml +++ b/ruff.toml @@ -28,6 +28,7 @@ exclude = [ "venv", "client/ayon_deadline/repository/custom/plugins/CelAction/*", "client/ayon_deadline/repository/custom/plugins/HarmonyAYON/*", + "client/ayon_deadline/repository/custom/plugins/UnrealEngine5", ] # Same as Black. From 2feff1dab28a2d067f658c58d042655ad87fb846 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Aug 2024 11:37:31 +0200 Subject: [PATCH 11/11] Change to process environment variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> --- .../repository/custom/plugins/UnrealEngine5/JobPreLoad.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py index e53818f936..4f47a9aae7 100644 --- a/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py +++ b/client/ayon_deadline/repository/custom/plugins/UnrealEngine5/JobPreLoad.py @@ -163,8 +163,8 @@ def __main__( deadlinePlugin ): deadlinePlugin.LogInfo('Version defined: %s' % version ) version_string = str(version).replace(".", "_") executable_key = f"UnrealEditorExecutable_{version_string}" - unreal_exe_list = (deadlinePlugin.GetEnvironmentVariable(executable_key) - or deadlinePlugin.GetEnvironmentVariable("UnrealExecutable")) + unreal_exe_list = (deadlinePlugin.GetProcessEnvironmentVariable(executable_key) + or deadlinePlugin.GetProcessEnvironmentVariable("UnrealExecutable")) if not unreal_exe_list: deadlinePlugin.FailRender( "Unreal Engine " + str(version) + " entry not found in .param file" ) unreal_executable = FileUtils.SearchFileList( unreal_exe_list )