From 450c0270fa335cdf09e6364912b4dcd4c540c574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 26 Oct 2022 18:37:44 +0200 Subject: [PATCH 001/144] :construction: wip on maya royalrender submit plugin --- openpype/modules/royalrender/api.py | 20 ++- .../publish/submit_maya_royalrender.py | 150 ++++++++++++++++++ openpype/modules/royalrender/rr_job.py | 8 +- 3 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index de1dba8724..c47d50b62b 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -15,9 +15,8 @@ class Api: RR_SUBMIT_CONSOLE = 1 RR_SUBMIT_API = 2 - def __init__(self, settings, project=None): + def __init__(self, project=None): self.log = Logger.get_logger("RoyalRender") - self._settings = settings self._initialize_rr(project) def _initialize_rr(self, project=None): @@ -91,21 +90,21 @@ class Api: sys.path.append(os.path.join(self._rr_path, rr_module_path)) - def create_submission(self, jobs, submitter_attributes, file_name=None): - # type: (list[RRJob], list[SubmitterParameter], str) -> SubmitFile + @staticmethod + def create_submission(jobs, submitter_attributes): + # type: (list[RRJob], list[SubmitterParameter]) -> SubmitFile """Create jobs submission file. Args: jobs (list): List of :class:`RRJob` submitter_attributes (list): List of submitter attributes :class:`SubmitterParameter` for whole submission batch. - file_name (str), optional): File path to write data to. Returns: str: XML data of job submission files. """ - raise NotImplementedError + return SubmitFile(SubmitterParameters=submitter_attributes, Jobs=jobs) def submit_file(self, file, mode=RR_SUBMIT_CONSOLE): # type: (SubmitFile, int) -> None @@ -119,15 +118,14 @@ class Api: # self._submit_using_api(file) def _submit_using_console(self, file): - # type: (SubmitFile) -> bool + # type: (SubmitFile) -> None rr_console = os.path.join( self._get_rr_bin_path(), - "rrSubmitterconsole" + "rrSubmitterConsole" ) - if sys.platform.lower() == "darwin": - if "/bin/mac64" in rr_console: - rr_console = rr_console.replace("/bin/mac64", "/bin/mac") + if sys.platform.lower() == "darwin" and "/bin/mac64" in rr_console: + rr_console = rr_console.replace("/bin/mac64", "/bin/mac") if sys.platform.lower() == "win32": if "/bin/win64" in rr_console: diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py new file mode 100644 index 0000000000..c354cc80a0 --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to RoyalRender.""" +import os +import sys +import tempfile + +from maya.OpenMaya import MGlobal # noqa +from pyblish.api import InstancePlugin, IntegratorOrder +from openpype.hosts.maya.api.lib import get_attr_in_layer +from openpype.pipeline.farm.tools import get_published_workfile_instance +from openpype.pipeline.publish import KnownPublishError +from openpype.modules.royalrender.api import Api as rr_api +from openpype.modules.royalrender.rr_job import RRJob, SubmitterParameter + + +class MayaSubmitRoyalRender(InstancePlugin): + label = "Submit to RoyalRender" + order = IntegratorOrder + 0.1 + use_published = True + + def __init__(self, *args, **kwargs): + self._instance = None + self._rrRoot = None + self.scene_path = None + self.job = None + self.submission_parameters = None + self.rr_api = None + + def get_job(self): + """Prepare job payload. + + Returns: + RRJob: RoyalRender job payload. + + """ + def get_rr_platform(): + if sys.platform.lower() in ["win32", "win64"]: + return "win" + elif sys.platform.lower() == "darwin": + return "mac" + else: + return "lx" + + expected_files = self._instance.data["expectedFiles"] + first_file = next(self._iter_expected_files(expected_files)) + output_dir = os.path.dirname(first_file) + self._instance.data["outputDir"] = output_dir + workspace = self._instance.context.data["workspaceDir"] + default_render_file = self._instance.context.data.get('project_settings') \ + .get('maya') \ + .get('RenderSettings') \ + .get('default_render_image_folder') + filename = os.path.basename(self.scene_path) + dirname = os.path.join(workspace, default_render_file) + + job = RRJob( + Software="Maya", + Renderer=self._instance.data["renderer"], + SeqStart=int(self._instance.data["frameStartHandle"]), + SeqEnd=int(self._instance.data["frameEndHandle"]), + SeqStep=int(self._instance.data["byFrameStep"]), + SeqFileOffset=0, + Version="{0:.2f}".format(MGlobal.apiVersion() / 10000), + SceneName=os.path.basename(self.scene_path), + IsActive=True, + ImageDir=dirname, + ImageFilename=filename, + ImageExtension="." + os.path.splitext(filename)[1], + ImagePreNumberLetter=".", + ImageSingleOutputFile=False, + SceneOS=get_rr_platform(), + Camera=self._instance.data["cameras"][0], + Layer=self._instance.data["layer"], + SceneDatabaseDir=workspace, + ImageFramePadding=get_attr_in_layer( + "defaultRenderGlobals.extensionPadding", + self._instance.data["layer"]), + ImageWidth=self._instance.data["resolutionWidth"], + ImageHeight=self._instance.data["resolutionHeight"] + ) + return job + + @staticmethod + def get_submission_parameters(): + return [] + + def create_file(self, name, ext, contents=None): + temp = tempfile.NamedTemporaryFile( + dir=self.tempdir, + suffix=ext, + prefix=name + '.', + delete=False, + ) + + if contents: + with open(temp.name, 'w') as f: + f.write(contents) + + return temp.name + + def process(self, instance): + """Plugin entry point.""" + self._instance = instance + context = instance.context + self.rr_api = rr_api(context.data["project"]) + + # get royalrender module + """ + try: + rr_module = context.data.get( + "openPypeModules")["royalrender"] + except AttributeError: + self.log.error("Cannot get OpenPype RoyalRender module.") + raise AssertionError("OpenPype RoyalRender module not found.") + """ + + self._rrRoot = instance.data["rrPath"] or context.data["defaultRRPath"] # noqa + if not self._rrRoot: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + file_path = None + if self.use_published: + file_path = get_published_workfile_instance() + + # fallback if nothing was set + if not file_path: + self.log.warning("Falling back to workfile") + file_path = context.data["currentFile"] + + self.scene_path = file_path + self.job = self.get_job() + self.log.info(self.job) + self.submission_parameters = self.get_submission_parameters() + + self.process_submission() + + def process_submission(self): + submission = rr_api.create_submission( + [self.job], + self.submission_parameters) + + self.log.debug(submission) + xml = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) + with open(xml.name, "w") as f: + f.write(submission.serialize()) + + self.rr_api.submit_file(file=xml) + + diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index c660eceac7..beb8c17187 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -35,7 +35,7 @@ class RRJob: # Is the job enabled for submission? # enabled by default - IsActive = attr.ib() # type: str + IsActive = attr.ib() # type: bool # Sequence settings of this job SeqStart = attr.ib() # type: int @@ -60,7 +60,7 @@ class RRJob: # If you render a single file, e.g. Quicktime or Avi, then you have to # set this value. Videos have to be rendered at once on one client. - ImageSingleOutputFile = attr.ib(default="false") # type: str + ImageSingleOutputFile = attr.ib(default=False) # type: bool # Semi-Required (required for some render applications) # ----------------------------------------------------- @@ -169,11 +169,11 @@ class SubmitFile: # Delete submission file after processing DeleteXML = attr.ib(default=1) # type: int - # List of submitter options per job + # List of the submitter options per job. # list item must be of `SubmitterParameter` type SubmitterParameters = attr.ib(factory=list) # type: list - # List of job is submission batch. + # List of the jobs in submission batch. # list item must be of type `RRJob` Jobs = attr.ib(factory=list) # type: list From 635fe48edb835b1748daf3023bac7781f44d2f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 26 Oct 2022 18:38:11 +0200 Subject: [PATCH 002/144] :recycle: move functions to common lib --- .../deadline/abstract_submit_deadline.py | 112 +----------------- openpype/pipeline/farm/tools.py | 109 +++++++++++++++++ 2 files changed, 111 insertions(+), 110 deletions(-) create mode 100644 openpype/pipeline/farm/tools.py diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 512ff800ee..0299b3ff18 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -21,6 +21,7 @@ from openpype.pipeline.publish import ( AbstractMetaInstancePlugin, KnownPublishError ) +from openpype.pipeline.farm.tools import get_published_workfile_instance JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) @@ -424,7 +425,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): file_path = None if self.use_published: - file_path = self.from_published_scene() + file_path = get_published_workfile_instance(instance) # fallback if nothing was set if not file_path: @@ -495,96 +496,6 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): """ return [] - def from_published_scene(self, replace_in_path=True): - """Switch work scene for published scene. - - If rendering/exporting from published scenes is enabled, this will - replace paths from working scene to published scene. - - Args: - replace_in_path (bool): if True, it will try to find - old scene name in path of expected files and replace it - with name of published scene. - - Returns: - str: Published scene path. - None: if no published scene is found. - - Note: - Published scene path is actually determined from project Anatomy - as at the time this plugin is running scene can still no be - published. - - """ - - instance = self._instance - workfile_instance = self._get_workfile_instance(instance.context) - if workfile_instance is None: - return - - # determine published path from Anatomy. - template_data = workfile_instance.data.get("anatomyData") - rep = workfile_instance.data.get("representations")[0] - template_data["representation"] = rep.get("name") - template_data["ext"] = rep.get("ext") - template_data["comment"] = None - - anatomy = instance.context.data['anatomy'] - anatomy_filled = anatomy.format(template_data) - template_filled = anatomy_filled["publish"]["path"] - file_path = os.path.normpath(template_filled) - - self.log.info("Using published scene for render {}".format(file_path)) - - if not os.path.exists(file_path): - self.log.error("published scene does not exist!") - raise - - if not replace_in_path: - return file_path - - # now we need to switch scene in expected files - # because token will now point to published - # scene file and that might differ from current one - def _clean_name(path): - return os.path.splitext(os.path.basename(path))[0] - - new_scene = _clean_name(file_path) - orig_scene = _clean_name(instance.context.data["currentFile"]) - expected_files = instance.data.get("expectedFiles") - - if isinstance(expected_files[0], dict): - # we have aovs and we need to iterate over them - new_exp = {} - for aov, files in expected_files[0].items(): - replaced_files = [] - for f in files: - replaced_files.append( - str(f).replace(orig_scene, new_scene) - ) - new_exp[aov] = replaced_files - # [] might be too much here, TODO - instance.data["expectedFiles"] = [new_exp] - else: - new_exp = [] - for f in expected_files: - new_exp.append( - str(f).replace(orig_scene, new_scene) - ) - instance.data["expectedFiles"] = new_exp - - metadata_folder = instance.data.get("publishRenderMetadataFolder") - if metadata_folder: - metadata_folder = metadata_folder.replace(orig_scene, - new_scene) - instance.data["publishRenderMetadataFolder"] = metadata_folder - - self.log.info("Scene name was switched {} -> {}".format( - orig_scene, new_scene - )) - - return file_path - def assemble_payload( self, job_info=None, plugin_info=None, aux_files=None): """Assemble payload data from its various parts. @@ -644,22 +555,3 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): self._instance.data["deadlineSubmissionJob"] = result return result["_id"] - - @staticmethod - def _get_workfile_instance(context): - """Find workfile instance in context""" - for i in context: - - is_workfile = ( - "workfile" in i.data.get("families", []) or - i.data["family"] == "workfile" - ) - if not is_workfile: - continue - - # test if there is instance of workfile waiting - # to be published. - assert i.data["publish"] is True, ( - "Workfile (scene) must be published along") - - return i diff --git a/openpype/pipeline/farm/tools.py b/openpype/pipeline/farm/tools.py new file mode 100644 index 0000000000..8cf1af399e --- /dev/null +++ b/openpype/pipeline/farm/tools.py @@ -0,0 +1,109 @@ +import os + + +def get_published_workfile_instance(context): + """Find workfile instance in context""" + for i in context: + is_workfile = ( + "workfile" in i.data.get("families", []) or + i.data["family"] == "workfile" + ) + if not is_workfile: + continue + + # test if there is instance of workfile waiting + # to be published. + if i.data["publish"] is not True: + continue + + return i + + +def from_published_scene(instance, replace_in_path=True): + """Switch work scene for published scene. + + If rendering/exporting from published scenes is enabled, this will + replace paths from working scene to published scene. + + Args: + instance (pyblish.api.Instance): Instance data to process. + replace_in_path (bool): if True, it will try to find + old scene name in path of expected files and replace it + with name of published scene. + + Returns: + str: Published scene path. + None: if no published scene is found. + + Note: + Published scene path is actually determined from project Anatomy + as at the time this plugin is running the scene can be still + un-published. + + """ + workfile_instance = get_published_workfile_instance(instance.context) + if workfile_instance is None: + return + + # determine published path from Anatomy. + template_data = workfile_instance.data.get("anatomyData") + rep = workfile_instance.data.get("representations")[0] + template_data["representation"] = rep.get("name") + template_data["ext"] = rep.get("ext") + template_data["comment"] = None + + anatomy = instance.context.data['anatomy'] + anatomy_filled = anatomy.format(template_data) + template_filled = anatomy_filled["publish"]["path"] + file_path = os.path.normpath(template_filled) + + self.log.info("Using published scene for render {}".format(file_path)) + + if not os.path.exists(file_path): + self.log.error("published scene does not exist!") + raise + + if not replace_in_path: + return file_path + + # now we need to switch scene in expected files + # because token will now point to published + # scene file and that might differ from current one + def _clean_name(path): + return os.path.splitext(os.path.basename(path))[0] + + new_scene = _clean_name(file_path) + orig_scene = _clean_name(instance.context.data["currentFile"]) + expected_files = instance.data.get("expectedFiles") + + if isinstance(expected_files[0], dict): + # we have aovs and we need to iterate over them + new_exp = {} + for aov, files in expected_files[0].items(): + replaced_files = [] + for f in files: + replaced_files.append( + str(f).replace(orig_scene, new_scene) + ) + new_exp[aov] = replaced_files + # [] might be too much here, TODO + instance.data["expectedFiles"] = [new_exp] + else: + new_exp = [] + for f in expected_files: + new_exp.append( + str(f).replace(orig_scene, new_scene) + ) + instance.data["expectedFiles"] = new_exp + + metadata_folder = instance.data.get("publishRenderMetadataFolder") + if metadata_folder: + metadata_folder = metadata_folder.replace(orig_scene, + new_scene) + instance.data["publishRenderMetadataFolder"] = metadata_folder + + self.log.info("Scene name was switched {} -> {}".format( + orig_scene, new_scene + )) + + return file_path From aaafcd633341e774944b7fb865acc3f790a60f70 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Jan 2023 19:13:28 +0100 Subject: [PATCH 003/144] :construction: redoing publishing flow for multiple rr roots --- .../maya/plugins/create/create_render.py | 89 +++++++++++++++---- .../maya/plugins/publish/collect_render.py | 7 ++ openpype/modules/royalrender/api.py | 30 +------ .../publish/collect_default_rr_path.py | 23 ----- .../publish/collect_rr_path_from_instance.py | 16 ++-- .../publish/collect_sequences_from_job.py | 2 +- .../publish/submit_maya_royalrender.py | 51 +++++++++-- .../project_settings/royalrender.json | 3 + .../defaults/system_settings/modules.json | 6 +- 9 files changed, 138 insertions(+), 89 deletions(-) delete mode 100644 openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 8375149442..dedb057fb7 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -79,31 +79,58 @@ class CreateRender(plugin.Creator): if self._project_settings["maya"]["RenderSettings"]["apply_render_settings"]: # noqa lib_rendersettings.RenderSettings().set_default_renderer_settings() - # Deadline-only + # Handling farms manager = ModulesManager() deadline_settings = get_system_settings()["modules"]["deadline"] - if not deadline_settings["enabled"]: - self.deadline_servers = {} + rr_settings = get_system_settings()["modules"]["royalrender"] + + self.deadline_servers = {} + self.rr_paths = {} + + if deadline_settings["enabled"]: + self.deadline_module = manager.modules_by_name["deadline"] + try: + default_servers = deadline_settings["deadline_urls"] + project_servers = ( + self._project_settings["deadline"]["deadline_servers"] + ) + self.deadline_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + if not self.deadline_servers: + self.deadline_servers = default_servers + + except AttributeError: + # Handle situation were we had only one url for deadline. + # get default deadline webservice url from deadline module + self.deadline_servers = self.deadline_module.deadline_urls + + # RoyalRender only + if not rr_settings["enabled"]: return - self.deadline_module = manager.modules_by_name["deadline"] + + self.rr_module = manager.modules_by_name["royalrender"] try: - default_servers = deadline_settings["deadline_urls"] - project_servers = ( - self._project_settings["deadline"]["deadline_servers"] + default_paths = rr_settings["rr_paths"] + project_paths = ( + self._project_settings["royalrender"]["rr_paths"] ) - self.deadline_servers = { - k: default_servers[k] - for k in project_servers - if k in default_servers + self.rr_paths = { + k: default_paths[k] + for k in project_paths + if k in default_paths } - if not self.deadline_servers: - self.deadline_servers = default_servers + if not self.rr_paths: + self.rr_paths = default_paths except AttributeError: - # Handle situation were we had only one url for deadline. - # get default deadline webservice url from deadline module - self.deadline_servers = self.deadline_module.deadline_urls + # Handle situation were we had only one path for royalrender. + # Get default royalrender root path from the rr module. + self.rr_paths = self.rr_module.rr_paths def process(self): """Entry point.""" @@ -139,6 +166,14 @@ class CreateRender(plugin.Creator): self._deadline_webservice_changed ]) + # add RoyalRender root path selection list + if self.rr_paths: + cmds.scriptJob( + attributeChange=[ + "{}.rrPaths".format(self.instance), + self._rr_path_changed + ]) + cmds.setAttr("{}.machineList".format(self.instance), lock=True) rs = renderSetup.instance() layers = rs.getRenderLayers() @@ -191,6 +226,18 @@ class CreateRender(plugin.Creator): attributeType="enum", enumName=":".join(sorted_pools)) + @staticmethod + def _rr_path_changed(): + """Unused callback to pull information from RR.""" + """ + _ = self.rr_paths[ + self.server_aliases[ + cmds.getAttr("{}.rrPaths".format(self.instance)) + ] + ] + """ + pass + def _create_render_settings(self): """Create instance settings.""" # get pools (slave machines of the render farm) @@ -225,15 +272,21 @@ class CreateRender(plugin.Creator): system_settings = get_system_settings()["modules"] deadline_enabled = system_settings["deadline"]["enabled"] + royalrender_enabled = system_settings["royalrender"]["enabled"] muster_enabled = system_settings["muster"]["enabled"] muster_url = system_settings["muster"]["MUSTER_REST_URL"] - if deadline_enabled and muster_enabled: + if deadline_enabled and muster_enabled and royalrender_enabled: self.log.error( - "Both Deadline and Muster are enabled. " "Cannot support both." + ("Multiple render farm support (Deadline/RoyalRender/Muster) " + "is enabled. We support only one at time.") ) raise RuntimeError("Both Deadline and Muster are enabled") + if royalrender_enabled: + self.server_aliases = list(self.rr_paths.keys()) + self.data["rrPaths"] = self.server_aliases + if deadline_enabled: self.server_aliases = list(self.deadline_servers.keys()) self.data["deadlineServers"] = self.server_aliases diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index b1ad3ca58e..efae830b64 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -328,6 +328,13 @@ class CollectMayaRender(pyblish.api.ContextPlugin): if deadline_settings["enabled"]: data["deadlineUrl"] = render_instance.data.get("deadlineUrl") + rr_settings = ( + context.data["system_settings"]["modules"]["royalrender"] + ) + if rr_settings["enabled"]: + data["rrPathName"] = render_instance.data.get("rrPathName") + self.log.info(data["rrPathName"]) + if self.sync_workfile_version: data["version"] = context.data["version"] diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index c47d50b62b..dcb518deb1 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -15,36 +15,10 @@ class Api: RR_SUBMIT_CONSOLE = 1 RR_SUBMIT_API = 2 - def __init__(self, project=None): + def __init__(self, rr_path=None): self.log = Logger.get_logger("RoyalRender") - self._initialize_rr(project) - - def _initialize_rr(self, project=None): - # type: (str) -> None - """Initialize RR Path. - - Args: - project (str, Optional): Project name to set RR api in - context. - - """ - if project: - project_settings = get_project_settings(project) - rr_path = ( - project_settings - ["royalrender"] - ["rr_paths"] - ) - else: - rr_path = ( - self._settings - ["modules"] - ["royalrender"] - ["rr_path"] - ["default"] - ) - os.environ["RR_ROOT"] = rr_path self._rr_path = rr_path + os.environ["RR_ROOT"] = rr_path def _get_rr_bin_path(self, rr_root=None): # type: (str) -> str diff --git a/openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py b/openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py deleted file mode 100644 index 3ce95e0c50..0000000000 --- a/openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect default Deadline server.""" -import pyblish.api - - -class CollectDefaultRRPath(pyblish.api.ContextPlugin): - """Collect default Royal Render path.""" - - order = pyblish.api.CollectorOrder - label = "Default Royal Render Path" - - def process(self, context): - try: - rr_module = context.data.get( - "openPypeModules")["royalrender"] - except AttributeError: - msg = "Cannot get OpenPype Royal Render module." - self.log.error(msg) - raise AssertionError(msg) - - # get default deadline webservice url from deadline module - self.log.debug(rr_module.rr_paths) - context.data["defaultRRPath"] = rr_module.rr_paths["default"] # noqa: E501 diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index 6a3dc276f3..187e2b9c44 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -5,19 +5,19 @@ import pyblish.api class CollectRRPathFromInstance(pyblish.api.InstancePlugin): """Collect RR Path from instance.""" - order = pyblish.api.CollectorOrder + 0.01 - label = "Royal Render Path from the Instance" + order = pyblish.api.CollectorOrder + label = "Collect Royal Render path name from the Instance" families = ["rendering"] def process(self, instance): - instance.data["rrPath"] = self._collect_rr_path(instance) + instance.data["rrPathName"] = self._collect_rr_path_name(instance) self.log.info( - "Using {} for submission.".format(instance.data["rrPath"])) + "Using '{}' for submission.".format(instance.data["rrPathName"])) @staticmethod - def _collect_rr_path(render_instance): + def _collect_rr_path_name(render_instance): # type: (pyblish.api.Instance) -> str - """Get Royal Render path from render instance.""" + """Get Royal Render pat name from render instance.""" rr_settings = ( render_instance.context.data ["system_settings"] @@ -42,8 +42,6 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): # Handle situation were we had only one url for royal render. return render_instance.context.data["defaultRRPath"] - return rr_servers[ - list(rr_servers.keys())[ + return list(rr_servers.keys())[ int(render_instance.data.get("rrPaths")) ] - ] diff --git a/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py b/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py index 65af90e8a6..4c123e4134 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py +++ b/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py @@ -71,7 +71,7 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin): """Gather file sequences from job directory. When "OPENPYPE_PUBLISH_DATA" environment variable is set these paths - (folders or .json files) are parsed for image sequences. Otherwise the + (folders or .json files) are parsed for image sequences. Otherwise, the current working directory is searched for file sequences. """ diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py index f1ba5e62ef..ef98bcf74c 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py @@ -3,9 +3,10 @@ import os import sys import tempfile +import platform from maya.OpenMaya import MGlobal # noqa -from pyblish.api import InstancePlugin, IntegratorOrder +from pyblish.api import InstancePlugin, IntegratorOrder, Context from openpype.hosts.maya.api.lib import get_attr_in_layer from openpype.pipeline.publish.lib import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError @@ -16,6 +17,8 @@ from openpype.modules.royalrender.rr_job import RRJob, SubmitterParameter class MayaSubmitRoyalRender(InstancePlugin): label = "Submit to RoyalRender" order = IntegratorOrder + 0.1 + families = ["renderlayer"] + targets = ["local"] use_published = True def __init__(self, *args, **kwargs): @@ -102,7 +105,16 @@ class MayaSubmitRoyalRender(InstancePlugin): """Plugin entry point.""" self._instance = instance context = instance.context - self.rr_api = rr_api(context.data["project"]) + from pprint import pformat + + self._rr_root = self._resolve_rr_path(context, instance.data.get("rrPathName")) # noqa + self.log.debug(self._rr_root) + if not self._rr_root: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + + self.rr_api = rr_api(self._rr_root) # get royalrender module """ @@ -114,11 +126,7 @@ class MayaSubmitRoyalRender(InstancePlugin): raise AssertionError("OpenPype RoyalRender module not found.") """ - self._rrRoot = instance.data["rrPath"] or context.data["defaultRRPath"] # noqa - if not self._rrRoot: - raise KnownPublishError( - ("Missing RoyalRender root. " - "You need to configure RoyalRender module.")) + file_path = None if self.use_published: file_path = get_published_workfile_instance() @@ -147,4 +155,33 @@ class MayaSubmitRoyalRender(InstancePlugin): self.rr_api.submit_file(file=xml) + @staticmethod + def _resolve_rr_path(context, rr_path_name): + # type: (Context, str) -> str + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except (AttributeError, KeyError): + # Handle situation were we had only one url for royal render. + return context.data["defaultRRPath"][platform.system().lower()] + + return rr_servers[rr_path_name][platform.system().lower()] + diff --git a/openpype/settings/defaults/project_settings/royalrender.json b/openpype/settings/defaults/project_settings/royalrender.json index be267b11d8..dc7d1574a1 100644 --- a/openpype/settings/defaults/project_settings/royalrender.json +++ b/openpype/settings/defaults/project_settings/royalrender.json @@ -1,4 +1,7 @@ { + "rr_paths": [ + "default" + ], "publish": { "CollectSequencesFromJob": { "review": true diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index c84d23d3fc..b5f7784663 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -185,9 +185,9 @@ "enabled": false, "rr_paths": { "default": { - "windows": "", - "darwin": "", - "linux": "" + "windows": "C:\\RR8", + "darwin": "/Volumes/share/RR8", + "linux": "/mnt/studio/RR8" } } }, From 0eb7da3b93b7e54f9461575751c87672a2fb8982 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Jan 2023 19:13:54 +0100 Subject: [PATCH 004/144] :recycle: optimizing enum classes --- openpype/settings/entities/__init__.py | 4 +- openpype/settings/entities/enum_entity.py | 87 ++++++++++--------- .../schema_project_royalrender.json | 6 ++ 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index b2cb2204f4..dab17c84a3 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -107,7 +107,8 @@ from .enum_entity import ( TaskTypeEnumEntity, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity, - ShotgridUrlEnumEntity + ShotgridUrlEnumEntity, + RoyalRenderRootEnumEntity ) from .list_entity import ListEntity @@ -173,6 +174,7 @@ __all__ = ( "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", "ShotgridUrlEnumEntity", + "RoyalRenderRootEnumEntity", "AnatomyTemplatesEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index c0c103ea10..9f9ae93026 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,3 +1,5 @@ +import abc +import six import copy from .input_entities import InputEntity from .exceptions import EntitySchemaError @@ -476,8 +478,9 @@ class TaskTypeEnumEntity(BaseEnumEntity): self.set(value_on_not_set) -class DeadlineUrlEnumEntity(BaseEnumEntity): - schema_types = ["deadline_url-enum"] +@six.add_metaclass(abc.ABCMeta) +class FarmRootEnumEntity(BaseEnumEntity): + schema_types = [] def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) @@ -495,22 +498,8 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): # GUI attribute self.placeholder = self.schema_data.get("placeholder") - def _get_enum_values(self): - deadline_urls_entity = self.get_entity_from_path( - "system_settings/modules/deadline/deadline_urls" - ) - - valid_keys = set() - enum_items_list = [] - for server_name, url_entity in deadline_urls_entity.items(): - enum_items_list.append( - {server_name: "{}: {}".format(server_name, url_entity.value)} - ) - valid_keys.add(server_name) - return enum_items_list, valid_keys - def set_override_state(self, *args, **kwargs): - super(DeadlineUrlEnumEntity, self).set_override_state(*args, **kwargs) + super(FarmRootEnumEntity, self).set_override_state(*args, **kwargs) self.enum_items, self.valid_keys = self._get_enum_values() if self.multiselection: @@ -527,22 +516,50 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): elif self._current_value not in self.valid_keys: self._current_value = tuple(self.valid_keys)[0] + @abc.abstractmethod + def _get_enum_values(self): + pass -class ShotgridUrlEnumEntity(BaseEnumEntity): + +class DeadlineUrlEnumEntity(FarmRootEnumEntity): + schema_types = ["deadline_url-enum"] + + def _get_enum_values(self): + deadline_urls_entity = self.get_entity_from_path( + "system_settings/modules/deadline/deadline_urls" + ) + + valid_keys = set() + enum_items_list = [] + for server_name, url_entity in deadline_urls_entity.items(): + enum_items_list.append( + {server_name: "{}: {}".format(server_name, url_entity.value)} + ) + valid_keys.add(server_name) + return enum_items_list, valid_keys + + +class RoyalRenderRootEnumEntity(FarmRootEnumEntity): + schema_types = ["rr_root-enum"] + + def _get_enum_values(self): + rr_root_entity = self.get_entity_from_path( + "system_settings/modules/royalrender/rr_paths" + ) + + valid_keys = set() + enum_items_list = [] + for server_name, url_entity in rr_root_entity.items(): + enum_items_list.append( + {server_name: "{}: {}".format(server_name, url_entity.value)} + ) + valid_keys.add(server_name) + return enum_items_list, valid_keys + + +class ShotgridUrlEnumEntity(FarmRootEnumEntity): schema_types = ["shotgrid_url-enum"] - def _item_initialization(self): - self.multiselection = False - - self.enum_items = [] - self.valid_keys = set() - - self.valid_value_types = (STRING_TYPE,) - self.value_on_not_set = "" - - # GUI attribute - self.placeholder = self.schema_data.get("placeholder") - def _get_enum_values(self): shotgrid_settings = self.get_entity_from_path( "system_settings/modules/shotgrid/shotgrid_settings" @@ -561,16 +578,6 @@ class ShotgridUrlEnumEntity(BaseEnumEntity): valid_keys.add(server_name) return enum_items_list, valid_keys - def set_override_state(self, *args, **kwargs): - super(ShotgridUrlEnumEntity, self).set_override_state(*args, **kwargs) - - self.enum_items, self.valid_keys = self._get_enum_values() - if not self.valid_keys: - self._current_value = "" - - elif self._current_value not in self.valid_keys: - self._current_value = tuple(self.valid_keys)[0] - class AnatomyTemplatesEnumEntity(BaseEnumEntity): schema_types = ["anatomy-templates-enum"] diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json index cabb4747d5..f4bf2f51ba 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json @@ -5,6 +5,12 @@ "collapsible": true, "is_file": true, "children": [ + { + "type": "rr_root-enum", + "key": "rr_paths", + "label": "Royal Render Roots", + "multiselect": true + }, { "type": "dict", "collapsible": true, From 2cc688fb72dd9d04bbe76beab2ca3d04c4c9df1d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 16 Jan 2023 10:40:05 +0100 Subject: [PATCH 005/144] :construction: render job submission --- openpype/modules/royalrender/api.py | 76 +++++++++++-------- .../publish/submit_maya_royalrender.py | 45 ++++++----- openpype/modules/royalrender/rr_job.py | 10 ++- 3 files changed, 77 insertions(+), 54 deletions(-) diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index dcb518deb1..8b13b9781f 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -2,11 +2,13 @@ """Wrapper around Royal Render API.""" import sys import os +import platform from openpype.settings import get_project_settings from openpype.lib.local_settings import OpenPypeSettingsRegistry from openpype.lib import Logger, run_subprocess from .rr_job import RRJob, SubmitFile, SubmitterParameter +from openpype.lib.vendor_bin_utils import find_tool_in_custom_paths class Api: @@ -20,31 +22,46 @@ class Api: self._rr_path = rr_path os.environ["RR_ROOT"] = rr_path - def _get_rr_bin_path(self, rr_root=None): - # type: (str) -> str - """Get path to RR bin folder.""" + def _get_rr_bin_path(self, tool_name=None, rr_root=None): + # type: (str, str) -> str + """Get path to RR bin folder. + + Args: + tool_name (str): Name of RR executable you want. + rr_root (str, Optional): Custom RR root if needed. + + Returns: + str: Path to the tool based on current platform. + + """ rr_root = rr_root or self._rr_path is_64bit_python = sys.maxsize > 2 ** 32 - rr_bin_path = "" + rr_bin_parts = [rr_root, "bin"] if sys.platform.lower() == "win32": - rr_bin_path = "/bin/win64" - if not is_64bit_python: - # we are using 64bit python - rr_bin_path = "/bin/win" - rr_bin_path = rr_bin_path.replace( - "/", os.path.sep - ) + rr_bin_parts.append("win") if sys.platform.lower() == "darwin": - rr_bin_path = "/bin/mac64" - if not is_64bit_python: - rr_bin_path = "/bin/mac" + rr_bin_parts.append("mac") - if sys.platform.lower() == "linux": - rr_bin_path = "/bin/lx64" + if sys.platform.lower().startswith("linux"): + rr_bin_parts.append("lx") - return os.path.join(rr_root, rr_bin_path) + rr_bin_path = os.sep.join(rr_bin_parts) + + paths_to_check = [] + # if we use 64bit python, append 64bit specific path first + if is_64bit_python: + if not tool_name: + return rr_bin_path + "64" + paths_to_check.append(rr_bin_path + "64") + + # otherwise use 32bit + if not tool_name: + return rr_bin_path + paths_to_check.append(rr_bin_path) + + return find_tool_in_custom_paths(paths_to_check, tool_name) def _initialize_module_path(self): # type: () -> None @@ -84,30 +101,25 @@ class Api: # type: (SubmitFile, int) -> None if mode == self.RR_SUBMIT_CONSOLE: self._submit_using_console(file) + return - # RR v7 supports only Python 2.7 so we bail out in fear + # RR v7 supports only Python 2.7, so we bail out in fear # until there is support for Python 3 😰 raise NotImplementedError( "Submission via RoyalRender API is not supported yet") # self._submit_using_api(file) - def _submit_using_console(self, file): + def _submit_using_console(self, job_file): # type: (SubmitFile) -> None - rr_console = os.path.join( - self._get_rr_bin_path(), - "rrSubmitterConsole" - ) + rr_start_local = self._get_rr_bin_path("rrStartLocal") - if sys.platform.lower() == "darwin" and "/bin/mac64" in rr_console: - rr_console = rr_console.replace("/bin/mac64", "/bin/mac") + self.log.info("rr_console: {}".format(rr_start_local)) - if sys.platform.lower() == "win32": - if "/bin/win64" in rr_console: - rr_console = rr_console.replace("/bin/win64", "/bin/win") - rr_console += ".exe" - - args = [rr_console, file] - run_subprocess(" ".join(args), logger=self.log) + args = [rr_start_local, "rrSubmitterconsole", job_file] + self.log.info("Executing: {}".format(" ".join(args))) + env = os.environ + env["RR_ROOT"] = self._rr_path + run_subprocess(args, logger=self.log, env=env) def _submit_using_api(self, file): # type: (SubmitFile) -> None diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py index ef98bcf74c..9303bad895 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py @@ -38,11 +38,11 @@ class MayaSubmitRoyalRender(InstancePlugin): """ def get_rr_platform(): if sys.platform.lower() in ["win32", "win64"]: - return "win" + return "windows" elif sys.platform.lower() == "darwin": return "mac" else: - return "lx" + return "linux" expected_files = self._instance.data["expectedFiles"] first_file = next(self._iter_expected_files(expected_files)) @@ -53,8 +53,10 @@ class MayaSubmitRoyalRender(InstancePlugin): .get('maya') \ .get('RenderSettings') \ .get('default_render_image_folder') - filename = os.path.basename(self.scene_path) - dirname = os.path.join(workspace, default_render_file) + file_name = os.path.basename(self.scene_path) + dir_name = os.path.join(workspace, default_render_file) + layer = self._instance.data["setMembers"] # type: str + layer_name = layer.removeprefix("rs_") job = RRJob( Software="Maya", @@ -64,22 +66,22 @@ class MayaSubmitRoyalRender(InstancePlugin): SeqStep=int(self._instance.data["byFrameStep"]), SeqFileOffset=0, Version="{0:.2f}".format(MGlobal.apiVersion() / 10000), - SceneName=os.path.basename(self.scene_path), + SceneName=self.scene_path, IsActive=True, - ImageDir=dirname, - ImageFilename=filename, - ImageExtension="." + os.path.splitext(filename)[1], + ImageDir=dir_name, + ImageFilename="{}.".format(layer_name), + ImageExtension=os.path.splitext(first_file)[1], ImagePreNumberLetter=".", ImageSingleOutputFile=False, SceneOS=get_rr_platform(), Camera=self._instance.data["cameras"][0], - Layer=self._instance.data["layer"], + Layer=layer_name, SceneDatabaseDir=workspace, - ImageFramePadding=get_attr_in_layer( - "defaultRenderGlobals.extensionPadding", - self._instance.data["layer"]), + CustomSHotName=self._instance.context.data["asset"], + CompanyProjectName=self._instance.context.data["projectName"], ImageWidth=self._instance.data["resolutionWidth"], - ImageHeight=self._instance.data["resolutionHeight"] + ImageHeight=self._instance.data["resolutionHeight"], + PreID=1 ) return job @@ -125,11 +127,9 @@ class MayaSubmitRoyalRender(InstancePlugin): self.log.error("Cannot get OpenPype RoyalRender module.") raise AssertionError("OpenPype RoyalRender module not found.") """ - - file_path = None if self.use_published: - file_path = get_published_workfile_instance() + file_path = get_published_workfile_instance(context) # fallback if nothing was set if not file_path: @@ -153,7 +153,8 @@ class MayaSubmitRoyalRender(InstancePlugin): with open(xml.name, "w") as f: f.write(submission.serialize()) - self.rr_api.submit_file(file=xml) + self.log.info("submitting job file: {}".format(xml.name)) + self.rr_api.submit_file(file=xml.name) @staticmethod def _resolve_rr_path(context, rr_path_name): @@ -184,4 +185,12 @@ class MayaSubmitRoyalRender(InstancePlugin): return rr_servers[rr_path_name][platform.system().lower()] - + @staticmethod + def _iter_expected_files(exp): + if isinstance(exp[0], dict): + for _aov, files in exp[0].items(): + for file in files: + yield file + else: + for file in exp: + yield file diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index beb8c17187..f5c7033b62 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -87,7 +87,7 @@ class RRJob: # Frame Padding of the frame number in the rendered filename. # Some render config files are setting the padding at render time. - ImageFramePadding = attr.ib(default=None) # type: str + ImageFramePadding = attr.ib(default=None) # type: int # Some render applications support overriding the image format at # the render commandline. @@ -129,6 +129,7 @@ class RRJob: CustomUserInfo = attr.ib(default=None) # type: str SubmitMachine = attr.ib(default=None) # type: str Color_ID = attr.ib(default=2) # type: int + CompanyProjectName = attr.ib(default=None) # type: str RequiredLicenses = attr.ib(default=None) # type: str @@ -225,7 +226,7 @@ class SubmitFile: # foo=bar~baz~goo self._process_submitter_parameters( self.SubmitterParameters, root, job_file) - + root.appendChild(job_file) for job in self.Jobs: # type: RRJob if not isinstance(job, RRJob): raise AttributeError( @@ -247,10 +248,11 @@ class SubmitFile: custom_attr.name)] = custom_attr.value for item, value in serialized_job.items(): - xml_attr = root.create(item) + xml_attr = root.createElement(item) xml_attr.appendChild( - root.createTextNode(value) + root.createTextNode(str(value)) ) xml_job.appendChild(xml_attr) + job_file.appendChild(xml_job) return root.toprettyxml(indent="\t") From 1e386c7ce4a30def013780ca05896de8365a2d7a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 16 Jan 2023 13:14:41 +0000 Subject: [PATCH 006/144] Implemented Blender Scene file type --- .../plugins/create/create_blender_scene.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 openpype/hosts/blender/plugins/create/create_blender_scene.py diff --git a/openpype/hosts/blender/plugins/create/create_blender_scene.py b/openpype/hosts/blender/plugins/create/create_blender_scene.py new file mode 100644 index 0000000000..b63ed4fd3f --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_blender_scene.py @@ -0,0 +1,37 @@ +import bpy + +from openpype.pipeline import legacy_io +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES + + +class CreateBlenderScene(plugin.Creator): + """Raw Blender Scene file export""" + + name = "blenderScene" + label = "Blender Scene" + family = "blenderScene" + icon = "file-archive-o" + + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + + def _process(self): + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + asset = self.data["asset"] + subset = self.data["subset"] + name = plugin.asset_name(asset, subset) + + asset_group = bpy.data.collections.new(name=name) + instances.children.link(asset_group) + self.data['task'] = legacy_io.Session.get('AVALON_TASK') + lib.imprint(asset_group, self.data) + + return asset_group From 44b45cdee00cd0769466b5195fa72aef8335a324 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 16 Jan 2023 13:15:35 +0000 Subject: [PATCH 007/144] Implemented Blender Scene extraction --- .../publish/extract_blender_scene_raw.py | 93 +++++++++++++++++++ openpype/plugins/publish/integrate_legacy.py | 3 +- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py diff --git a/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py b/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py new file mode 100644 index 0000000000..d6c858aaed --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py @@ -0,0 +1,93 @@ +import os + +import bpy + +from openpype.pipeline import AVALON_CONTAINER_ID, publish +from openpype.hosts.blender.api.workio import save_file + + +class ExtractBlenderSceneRaw(publish.Extractor): + """Extract as Blender Scene (raw). + + This will preserve all references, construction history, etc. + """ + + label = "Blender Scene (Raw)" + hosts = ["blender"] + families = ["blenderScene", "layout"] + scene_type = "blend" + + def process(self, instance): + # Define extract output file path + dir_path = self.staging_dir(instance) + filename = "{0}.{1}".format(instance.name, self.scene_type) + path = os.path.join(dir_path, filename) + + # We need to get all the data blocks for all the blender objects. + # The following set will contain all the data blocks from version + # 2.93 of Blender. + data_blocks = { + *bpy.data.actions, + *bpy.data.armatures, + *bpy.data.brushes, + *bpy.data.cache_files, + *bpy.data.cameras, + *bpy.data.collections, + *bpy.data.curves, + *bpy.data.fonts, + *bpy.data.grease_pencils, + *bpy.data.images, + *bpy.data.lattices, + *bpy.data.libraries, + *bpy.data.lightprobes, + *bpy.data.lights, + *bpy.data.linestyles, + *bpy.data.masks, + *bpy.data.materials, + *bpy.data.metaballs, + *bpy.data.meshes, + *bpy.data.movieclips, + *bpy.data.node_groups, + *bpy.data.objects, + *bpy.data.paint_curves, + *bpy.data.palettes, + *bpy.data.particles, + *bpy.data.scenes, + *bpy.data.screens, + *bpy.data.shape_keys, + *bpy.data.speakers, + *bpy.data.sounds, + *bpy.data.texts, + *bpy.data.textures, + *bpy.data.volumes, + *bpy.data.worlds + } + + # Some data blocks are only available in certain versions of Blender. + version = tuple(map(int, (bpy.app.version_string.split(".")))) + + if version >= (3, 0, 0): + data_blocks |= { + *bpy.data.pointclouds + } + + if version >= (3, 3, 0): + data_blocks |= { + *bpy.data.hair_curves + } + + # Write the datablocks in a new blend file. + bpy.data.libraries.write(path, data_blocks) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': self.scene_type, + 'ext': self.scene_type, + 'files': filename, + "stagingDir": dir_path + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) diff --git a/openpype/plugins/publish/integrate_legacy.py b/openpype/plugins/publish/integrate_legacy.py index b93abab1d8..c2abd66446 100644 --- a/openpype/plugins/publish/integrate_legacy.py +++ b/openpype/plugins/publish/integrate_legacy.py @@ -125,7 +125,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "mvLook", "mvUsdComposition", "mvUsdOverride", - "simpleUnrealTexture" + "simpleUnrealTexture", + "blenderScene" ] exclude_families = ["render.farm"] db_representation_context_keys = [ From 7f31e88acf9383abce4594a736089a4ecd9a5f79 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 16 Jan 2023 15:30:10 +0000 Subject: [PATCH 008/144] Improved selection of data blocks to extract --- .../publish/extract_blender_scene_raw.py | 56 ++----------------- 1 file changed, 6 insertions(+), 50 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py b/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py index d6c858aaed..b3f6f6460c 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py +++ b/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py @@ -2,8 +2,7 @@ import os import bpy -from openpype.pipeline import AVALON_CONTAINER_ID, publish -from openpype.hosts.blender.api.workio import save_file +from openpype.pipeline import publish class ExtractBlenderSceneRaw(publish.Extractor): @@ -26,55 +25,12 @@ class ExtractBlenderSceneRaw(publish.Extractor): # We need to get all the data blocks for all the blender objects. # The following set will contain all the data blocks from version # 2.93 of Blender. - data_blocks = { - *bpy.data.actions, - *bpy.data.armatures, - *bpy.data.brushes, - *bpy.data.cache_files, - *bpy.data.cameras, - *bpy.data.collections, - *bpy.data.curves, - *bpy.data.fonts, - *bpy.data.grease_pencils, - *bpy.data.images, - *bpy.data.lattices, - *bpy.data.libraries, - *bpy.data.lightprobes, - *bpy.data.lights, - *bpy.data.linestyles, - *bpy.data.masks, - *bpy.data.materials, - *bpy.data.metaballs, - *bpy.data.meshes, - *bpy.data.movieclips, - *bpy.data.node_groups, - *bpy.data.objects, - *bpy.data.paint_curves, - *bpy.data.palettes, - *bpy.data.particles, - *bpy.data.scenes, - *bpy.data.screens, - *bpy.data.shape_keys, - *bpy.data.speakers, - *bpy.data.sounds, - *bpy.data.texts, - *bpy.data.textures, - *bpy.data.volumes, - *bpy.data.worlds - } + data_blocks = set() - # Some data blocks are only available in certain versions of Blender. - version = tuple(map(int, (bpy.app.version_string.split(".")))) - - if version >= (3, 0, 0): - data_blocks |= { - *bpy.data.pointclouds - } - - if version >= (3, 3, 0): - data_blocks |= { - *bpy.data.hair_curves - } + for attr in dir(bpy.data): + data_block = getattr(bpy.data, attr) + if isinstance(data_block, bpy.types.bpy_prop_collection): + data_blocks |= {*data_block} # Write the datablocks in a new blend file. bpy.data.libraries.write(path, data_blocks) From b3b266752bc2396fa7d68637f25e5725283e54d6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:06:05 +0100 Subject: [PATCH 009/144] :construction: refactor RR job flow --- .../publish/collect_rr_path_from_instance.py | 12 +- ...nder.py => create_maya_royalrender_job.py} | 76 ++------ .../publish/create_publish_royalrender_job.py | 178 ++++++++++++++++++ .../publish/submit_jobs_to_royalrender.py | 115 +++++++++++ openpype/pipeline/farm/pyblish.py | 49 +++++ 5 files changed, 364 insertions(+), 66 deletions(-) rename openpype/modules/royalrender/plugins/publish/{submit_maya_royalrender.py => create_maya_royalrender_job.py} (74%) create mode 100644 openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py create mode 100644 openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py create mode 100644 openpype/pipeline/farm/pyblish.py diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index 187e2b9c44..40f34561fa 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -15,19 +15,21 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): "Using '{}' for submission.".format(instance.data["rrPathName"])) @staticmethod - def _collect_rr_path_name(render_instance): + def _collect_rr_path_name(instance): # type: (pyblish.api.Instance) -> str """Get Royal Render pat name from render instance.""" rr_settings = ( - render_instance.context.data + instance.context.data ["system_settings"] ["modules"] ["royalrender"] ) + if not instance.data.get("rrPaths"): + return "default" try: default_servers = rr_settings["rr_paths"] project_servers = ( - render_instance.context.data + instance.context.data ["project_settings"] ["royalrender"] ["rr_paths"] @@ -40,8 +42,8 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): except (AttributeError, KeyError): # Handle situation were we had only one url for royal render. - return render_instance.context.data["defaultRRPath"] + return rr_settings["rr_paths"]["default"] return list(rr_servers.keys())[ - int(render_instance.data.get("rrPaths")) + int(instance.data.get("rrPaths")) ] diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py similarity index 74% rename from openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py rename to openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 9303bad895..e194a7edc6 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -2,20 +2,18 @@ """Submitting render job to RoyalRender.""" import os import sys -import tempfile import platform from maya.OpenMaya import MGlobal # noqa from pyblish.api import InstancePlugin, IntegratorOrder, Context -from openpype.hosts.maya.api.lib import get_attr_in_layer from openpype.pipeline.publish.lib import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError -from openpype.modules.royalrender.api import Api as rr_api -from openpype.modules.royalrender.rr_job import RRJob, SubmitterParameter +from openpype.modules.royalrender.api import Api as rrApi +from openpype.modules.royalrender.rr_job import RRJob -class MayaSubmitRoyalRender(InstancePlugin): - label = "Submit to RoyalRender" +class CreateMayaRoyalRenderJob(InstancePlugin): + label = "Create Maya Render job in RR" order = IntegratorOrder + 0.1 families = ["renderlayer"] targets = ["local"] @@ -81,28 +79,9 @@ class MayaSubmitRoyalRender(InstancePlugin): CompanyProjectName=self._instance.context.data["projectName"], ImageWidth=self._instance.data["resolutionWidth"], ImageHeight=self._instance.data["resolutionHeight"], - PreID=1 ) return job - @staticmethod - def get_submission_parameters(): - return [] - - def create_file(self, name, ext, contents=None): - temp = tempfile.NamedTemporaryFile( - dir=self.tempdir, - suffix=ext, - prefix=name + '.', - delete=False, - ) - - if contents: - with open(temp.name, 'w') as f: - f.write(contents) - - return temp.name - def process(self, instance): """Plugin entry point.""" self._instance = instance @@ -116,17 +95,8 @@ class MayaSubmitRoyalRender(InstancePlugin): ("Missing RoyalRender root. " "You need to configure RoyalRender module.")) - self.rr_api = rr_api(self._rr_root) + self.rr_api = rrApi(self._rr_root) - # get royalrender module - """ - try: - rr_module = context.data.get( - "openPypeModules")["royalrender"] - except AttributeError: - self.log.error("Cannot get OpenPype RoyalRender module.") - raise AssertionError("OpenPype RoyalRender module not found.") - """ file_path = None if self.use_published: file_path = get_published_workfile_instance(context) @@ -137,24 +107,18 @@ class MayaSubmitRoyalRender(InstancePlugin): file_path = context.data["currentFile"] self.scene_path = file_path - self.job = self.get_job() - self.log.info(self.job) - self.submission_parameters = self.get_submission_parameters() - self.process_submission() + self._instance.data["rrJobs"] = [self.get_job()] - def process_submission(self): - submission = rr_api.create_submission( - [self.job], - self.submission_parameters) - - self.log.debug(submission) - xml = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - with open(xml.name, "w") as f: - f.write(submission.serialize()) - - self.log.info("submitting job file: {}".format(xml.name)) - self.rr_api.submit_file(file=xml.name) + @staticmethod + def _iter_expected_files(exp): + if isinstance(exp[0], dict): + for _aov, files in exp[0].items(): + for file in files: + yield file + else: + for file in exp: + yield file @staticmethod def _resolve_rr_path(context, rr_path_name): @@ -184,13 +148,3 @@ class MayaSubmitRoyalRender(InstancePlugin): return context.data["defaultRRPath"][platform.system().lower()] return rr_servers[rr_path_name][platform.system().lower()] - - @staticmethod - def _iter_expected_files(exp): - if isinstance(exp[0], dict): - for _aov, files in exp[0].items(): - for file in files: - yield file - else: - for file in exp: - yield file diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py new file mode 100644 index 0000000000..9d8cc602c5 --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""Create publishing job on RoyalRender.""" +from pyblish.api import InstancePlugin, IntegratorOrder +from copy import deepcopy +from openpype.pipeline import legacy_io +import requests +import os + + +class CreatePublishRoyalRenderJob(InstancePlugin): + label = "Create publish job in RR" + order = IntegratorOrder + 0.2 + icon = "tractor" + targets = ["local"] + hosts = ["fusion", "maya", "nuke", "celaction", "aftereffects", "harmony"] + families = ["render.farm", "prerender.farm", + "renderlayer", "imagesequence", "vrayscene"] + aov_filter = {"maya": [r".*([Bb]eauty).*"], + "aftereffects": [r".*"], # for everything from AE + "harmony": [r".*"], # for everything from AE + "celaction": [r".*"]} + + def process(self, instance): + data = instance.data.copy() + context = instance.context + self.context = context + self.anatomy = instance.context.data["anatomy"] + + asset = data.get("asset") + subset = data.get("subset") + source = self._remap_source( + data.get("source") or context.data["source"]) + + + + def _remap_source(self, source): + success, rootless_path = ( + self.anatomy.find_root_template_from_path(source) + ) + if success: + source = rootless_path + else: + # `rootless_path` is not set to `source` if none of roots match + self.log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues." + ).format(source)) + return source + + def _submit_post_job(self, instance, job, instances): + """Submit publish job to RoyalRender.""" + data = instance.data.copy() + subset = data["subset"] + job_name = "Publish - {subset}".format(subset=subset) + + # instance.data.get("subset") != instances[0]["subset"] + # 'Main' vs 'renderMain' + override_version = None + instance_version = instance.data.get("version") # take this if exists + if instance_version != 1: + override_version = instance_version + output_dir = self._get_publish_folder( + instance.context.data['anatomy'], + deepcopy(instance.data["anatomyData"]), + instance.data.get("asset"), + instances[0]["subset"], + 'render', + override_version + ) + + # Transfer the environment from the original job to this dependent + # job, so they use the same environment + metadata_path, roothless_metadata_path = \ + self._create_metadata_path(instance) + + environment = { + "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], + "AVALON_ASSET": legacy_io.Session["AVALON_ASSET"], + "AVALON_TASK": legacy_io.Session["AVALON_TASK"], + "OPENPYPE_USERNAME": instance.context.data["user"], + "OPENPYPE_PUBLISH_JOB": "1", + "OPENPYPE_RENDER_JOB": "0", + "OPENPYPE_REMOTE_JOB": "0", + "OPENPYPE_LOG_NO_COLORS": "1" + } + + # add environments from self.environ_keys + for env_key in self.environ_keys: + if os.getenv(env_key): + environment[env_key] = os.environ[env_key] + + # pass environment keys from self.environ_job_filter + job_environ = job["Props"].get("Env", {}) + for env_j_key in self.environ_job_filter: + if job_environ.get(env_j_key): + environment[env_j_key] = job_environ[env_j_key] + + # Add mongo url if it's enabled + if instance.context.data.get("deadlinePassMongoUrl"): + mongo_url = os.environ.get("OPENPYPE_MONGO") + if mongo_url: + environment["OPENPYPE_MONGO"] = mongo_url + + priority = self.deadline_priority or instance.data.get("priority", 50) + + args = [ + "--headless", + 'publish', + roothless_metadata_path, + "--targets", "deadline", + "--targets", "farm" + ] + + # Generate the payload for Deadline submission + payload = { + "JobInfo": { + "Plugin": self.deadline_plugin, + "BatchName": job["Props"]["Batch"], + "Name": job_name, + "UserName": job["Props"]["User"], + "Comment": instance.context.data.get("comment", ""), + + "Department": self.deadline_department, + "ChunkSize": self.deadline_chunk_size, + "Priority": priority, + + "Group": self.deadline_group, + "Pool": instance.data.get("primaryPool"), + "SecondaryPool": instance.data.get("secondaryPool"), + + "OutputDirectory0": output_dir + }, + "PluginInfo": { + "Version": self.plugin_pype_version, + "Arguments": " ".join(args), + "SingleFrameOnly": "True", + }, + # Mandatory for Deadline, may be empty + "AuxFiles": [], + } + + # add assembly jobs as dependencies + if instance.data.get("tileRendering"): + self.log.info("Adding tile assembly jobs as dependencies...") + job_index = 0 + for assembly_id in instance.data.get("assemblySubmissionJobs"): + payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 + job_index += 1 + elif instance.data.get("bakingSubmissionJobs"): + self.log.info("Adding baking submission jobs as dependencies...") + job_index = 0 + for assembly_id in instance.data["bakingSubmissionJobs"]: + payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 + job_index += 1 + else: + payload["JobInfo"]["JobDependency0"] = job["_id"] + + if instance.data.get("suspend_publish"): + payload["JobInfo"]["InitialStatus"] = "Suspended" + + for index, (key_, value_) in enumerate(environment.items()): + payload["JobInfo"].update( + { + "EnvironmentKeyValue%d" + % index: "{key}={value}".format( + key=key_, value=value_ + ) + } + ) + # remove secondary pool + payload["JobInfo"].pop("SecondaryPool", None) + + self.log.info("Submitting Deadline job ...") + + url = "{}/api/jobs".format(self.deadline_url) + response = requests.post(url, json=payload, timeout=10) + if not response.ok: + raise Exception(response.text) \ No newline at end of file diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py new file mode 100644 index 0000000000..4fcf3a08bd --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Submit jobs to RoyalRender.""" +import tempfile +import platform + +from pyblish.api import IntegratorOrder, ContextPlugin, Context +from openpype.modules.royalrender.api import RRJob, Api as rrApi +from openpype.pipeline.publish import KnownPublishError + + +class SubmitJobsToRoyalRender(ContextPlugin): + """Find all jobs, create submission XML and submit it to RoyalRender.""" + label = "Submit jobs to RoyalRender" + order = IntegratorOrder + 0.1 + targets = ["local"] + + def __init__(self): + super(SubmitJobsToRoyalRender, self).__init__() + self._rr_root = None + self._rr_api = None + self._submission_parameters = [] + + def process(self, context): + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + + if rr_settings["enabled"] is not True: + self.log.warning("RoyalRender modules is disabled.") + return + + # iterate over all instances and try to find RRJobs + jobs = [] + for instance in context: + if isinstance(instance.data.get("rrJob"), RRJob): + jobs.append(instance.data.get("rrJob")) + if instance.data.get("rrJobs"): + if all(isinstance(job, RRJob) for job in instance.data.get("rrJobs")): + jobs += instance.data.get("rrJobs") + + if jobs: + self._rr_root = self._resolve_rr_path( + context, instance.data.get("rrPathName")) # noqa + if not self._rr_root: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + self._rr_api = rrApi(self._rr_root) + self._submission_parameters = self.get_submission_parameters() + self.process_submission(jobs) + return + + self.log.info("No RoyalRender jobs found") + + def process_submission(self, jobs): + # type: ([RRJob]) -> None + submission = rrApi.create_submission( + jobs, + self._submission_parameters) + + xml = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) + with open(xml.name, "w") as f: + f.write(submission.serialize()) + + self.log.info("submitting job(s) file: {}".format(xml.name)) + self._rr_api.submit_file(file=xml.name) + + def create_file(self, name, ext, contents=None): + temp = tempfile.NamedTemporaryFile( + dir=self.tempdir, + suffix=ext, + prefix=name + '.', + delete=False, + ) + + if contents: + with open(temp.name, 'w') as f: + f.write(contents) + + return temp.name + + def get_submission_parameters(self): + return [] + + @staticmethod + def _resolve_rr_path(context, rr_path_name): + # type: (Context, str) -> str + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except (AttributeError, KeyError): + # Handle situation were we had only one url for royal render. + return context.data["defaultRRPath"][platform.system().lower()] + + return rr_servers[rr_path_name][platform.system().lower()] diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py new file mode 100644 index 0000000000..02535f4090 --- /dev/null +++ b/openpype/pipeline/farm/pyblish.py @@ -0,0 +1,49 @@ +from openpype.lib import Logger +import attr + + +@attr.s +class InstanceSkeleton(object): + family = attr.ib(factory=) + +def remap_source(source, anatomy): + success, rootless_path = ( + anatomy.find_root_template_from_path(source) + ) + if success: + source = rootless_path + else: + # `rootless_path` is not set to `source` if none of roots match + log = Logger.get_logger("farm_publishing") + log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues." + ).format(source)) + return source + +def get_skeleton_instance() + instance_skeleton_data = { + "family": family, + "subset": subset, + "families": families, + "asset": asset, + "frameStart": start, + "frameEnd": end, + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStartHandle": start - handle_start, + "frameEndHandle": end + handle_end, + "comment": instance.data["comment"], + "fps": fps, + "source": source, + "extendFrames": data.get("extendFrames"), + "overrideExistingFrame": data.get("overrideExistingFrame"), + "pixelAspect": data.get("pixelAspect", 1), + "resolutionWidth": data.get("resolutionWidth", 1920), + "resolutionHeight": data.get("resolutionHeight", 1080), + "multipartExr": data.get("multipartExr", False), + "jobBatchName": data.get("jobBatchName", ""), + "useSequenceForReview": data.get("useSequenceForReview", True), + # map inputVersions `ObjectId` -> `str` so json supports it + "inputVersions": list(map(str, data.get("inputVersions", []))) + } \ No newline at end of file From 60eaf283ed0621aab5c289e5bd6537a80a9af0f2 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:06:53 +0100 Subject: [PATCH 010/144] :bug: fix default for ShotGrid this is fixing default value for ShotGrid server enumerator after code refactor done in this branch --- openpype/settings/defaults/project_settings/shotgrid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/shotgrid.json b/openpype/settings/defaults/project_settings/shotgrid.json index 774bce714b..143a474b80 100644 --- a/openpype/settings/defaults/project_settings/shotgrid.json +++ b/openpype/settings/defaults/project_settings/shotgrid.json @@ -1,6 +1,6 @@ { "shotgrid_project_id": 0, - "shotgrid_server": "", + "shotgrid_server": [], "event": { "enabled": false }, From b4f1574d443f0abc963a946dcaa287faf8c3deac Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:19:18 +0100 Subject: [PATCH 011/144] :rotating_light: hound fixes --- .../hosts/maya/plugins/create/create_render.py | 2 +- openpype/modules/royalrender/api.py | 2 -- .../publish/collect_rr_path_from_instance.py | 4 +--- .../plugins/publish/create_maya_royalrender_job.py | 14 ++++++++------ .../publish/create_publish_royalrender_job.py | 12 +++++------- .../plugins/publish/submit_jobs_to_royalrender.py | 4 +++- openpype/pipeline/farm/pyblish.py | 12 +++++++++--- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index dedb057fb7..4189351238 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -278,7 +278,7 @@ class CreateRender(plugin.Creator): if deadline_enabled and muster_enabled and royalrender_enabled: self.log.error( - ("Multiple render farm support (Deadline/RoyalRender/Muster) " + ("Multiple render farm support (Deadline/RoyalRender/Muster) " "is enabled. We support only one at time.") ) raise RuntimeError("Both Deadline and Muster are enabled") diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index 8b13b9781f..86d27ccc6c 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -2,9 +2,7 @@ """Wrapper around Royal Render API.""" import sys import os -import platform -from openpype.settings import get_project_settings from openpype.lib.local_settings import OpenPypeSettingsRegistry from openpype.lib import Logger, run_subprocess from .rr_job import RRJob, SubmitFile, SubmitterParameter diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index 40f34561fa..cfb5b78077 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -44,6 +44,4 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): # Handle situation were we had only one url for royal render. return rr_settings["rr_paths"]["default"] - return list(rr_servers.keys())[ - int(instance.data.get("rrPaths")) - ] + return list(rr_servers.keys())[int(instance.data.get("rrPaths"))] diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index e194a7edc6..5f427353ac 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -47,11 +47,14 @@ class CreateMayaRoyalRenderJob(InstancePlugin): output_dir = os.path.dirname(first_file) self._instance.data["outputDir"] = output_dir workspace = self._instance.context.data["workspaceDir"] - default_render_file = self._instance.context.data.get('project_settings') \ - .get('maya') \ - .get('RenderSettings') \ - .get('default_render_image_folder') - file_name = os.path.basename(self.scene_path) + default_render_file = ( + self._instance.context.data + ['project_settings'] + ['maya'] + ['RenderSettings'] + ['default_render_image_folder'] + ) + # file_name = os.path.basename(self.scene_path) dir_name = os.path.join(workspace, default_render_file) layer = self._instance.data["setMembers"] # type: str layer_name = layer.removeprefix("rs_") @@ -86,7 +89,6 @@ class CreateMayaRoyalRenderJob(InstancePlugin): """Plugin entry point.""" self._instance = instance context = instance.context - from pprint import pformat self._rr_root = self._resolve_rr_path(context, instance.data.get("rrPathName")) # noqa self.log.debug(self._rr_root) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 9d8cc602c5..e62289641b 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -26,12 +26,10 @@ class CreatePublishRoyalRenderJob(InstancePlugin): self.context = context self.anatomy = instance.context.data["anatomy"] - asset = data.get("asset") - subset = data.get("subset") - source = self._remap_source( - data.get("source") or context.data["source"]) - - + # asset = data.get("asset") + # subset = data.get("subset") + # source = self._remap_source( + # data.get("source") or context.data["source"]) def _remap_source(self, source): success, rootless_path = ( @@ -175,4 +173,4 @@ class CreatePublishRoyalRenderJob(InstancePlugin): url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) if not response.ok: - raise Exception(response.text) \ No newline at end of file + raise Exception(response.text) diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py index 4fcf3a08bd..325fb36993 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -38,7 +38,9 @@ class SubmitJobsToRoyalRender(ContextPlugin): if isinstance(instance.data.get("rrJob"), RRJob): jobs.append(instance.data.get("rrJob")) if instance.data.get("rrJobs"): - if all(isinstance(job, RRJob) for job in instance.data.get("rrJobs")): + if all( + isinstance(job, RRJob) + for job in instance.data.get("rrJobs")): jobs += instance.data.get("rrJobs") if jobs: diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py index 02535f4090..15f4356b86 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish.py @@ -4,7 +4,9 @@ import attr @attr.s class InstanceSkeleton(object): - family = attr.ib(factory=) + # family = attr.ib(factory=) + pass + def remap_source(source, anatomy): success, rootless_path = ( @@ -21,7 +23,9 @@ def remap_source(source, anatomy): ).format(source)) return source -def get_skeleton_instance() + +def get_skeleton_instance(): + """ instance_skeleton_data = { "family": family, "subset": subset, @@ -46,4 +50,6 @@ def get_skeleton_instance() "useSequenceForReview": data.get("useSequenceForReview", True), # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))) - } \ No newline at end of file + } + """ + pass From 56f404e8f9a841d67c9af478ca3aa1ea8df98337 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:21:28 +0100 Subject: [PATCH 012/144] :rotating_light: hound fixes 2 --- .../plugins/publish/create_publish_royalrender_job.py | 2 +- openpype/pipeline/farm/pyblish.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index e62289641b..65de600bfa 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -21,7 +21,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "celaction": [r".*"]} def process(self, instance): - data = instance.data.copy() + # data = instance.data.copy() context = instance.context self.context = context self.anatomy = instance.context.data["anatomy"] diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py index 15f4356b86..436ad7f195 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish.py @@ -17,10 +17,9 @@ def remap_source(source, anatomy): else: # `rootless_path` is not set to `source` if none of roots match log = Logger.get_logger("farm_publishing") - log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues." - ).format(source)) + log.warning( + ("Could not find root path for remapping \"{}\"." + " This may cause issues.").format(source)) return source From 61fe2ac36b2b8167554239c41d6e23eac051a638 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 7 Feb 2023 19:22:54 +0100 Subject: [PATCH 013/144] :construction: work on publish job --- openpype/modules/royalrender/api.py | 11 +- .../publish/create_maya_royalrender_job.py | 1 - .../publish/create_publish_royalrender_job.py | 102 ++++++++---------- openpype/modules/royalrender/rr_job.py | 46 +++++++- 4 files changed, 94 insertions(+), 66 deletions(-) diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index 86d27ccc6c..e610a0c8a8 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -20,19 +20,19 @@ class Api: self._rr_path = rr_path os.environ["RR_ROOT"] = rr_path - def _get_rr_bin_path(self, tool_name=None, rr_root=None): + @staticmethod + def get_rr_bin_path(rr_root, tool_name=None): # type: (str, str) -> str """Get path to RR bin folder. Args: tool_name (str): Name of RR executable you want. - rr_root (str, Optional): Custom RR root if needed. + rr_root (str): Custom RR root if needed. Returns: str: Path to the tool based on current platform. """ - rr_root = rr_root or self._rr_path is_64bit_python = sys.maxsize > 2 ** 32 rr_bin_parts = [rr_root, "bin"] @@ -65,7 +65,7 @@ class Api: # type: () -> None """Set RR modules for Python.""" # default for linux - rr_bin = self._get_rr_bin_path() + rr_bin = self.get_rr_bin_path(self._rr_path) rr_module_path = os.path.join(rr_bin, "lx64/lib") if sys.platform.lower() == "win32": @@ -109,7 +109,8 @@ class Api: def _submit_using_console(self, job_file): # type: (SubmitFile) -> None - rr_start_local = self._get_rr_bin_path("rrStartLocal") + rr_start_local = self.get_rr_bin_path( + self._rr_path, "rrStartLocal") self.log.info("rr_console: {}".format(rr_start_local)) diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 5f427353ac..0b257d8b7a 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -32,7 +32,6 @@ class CreateMayaRoyalRenderJob(InstancePlugin): Returns: RRJob: RoyalRender job payload. - """ def get_rr_platform(): if sys.platform.lower() in ["win32", "win64"]: diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 65de600bfa..f87ee589b6 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -6,6 +6,10 @@ from openpype.pipeline import legacy_io import requests import os +from openpype.modules.royalrender.rr_job import RRJob, RREnvList +from openpype.pipeline.publish import KnownPublishError +from openpype.modules.royalrender.api import Api as rrApi + class CreatePublishRoyalRenderJob(InstancePlugin): label = "Create publish job in RR" @@ -45,14 +49,12 @@ class CreatePublishRoyalRenderJob(InstancePlugin): ).format(source)) return source - def _submit_post_job(self, instance, job, instances): + def get_job(self, instance, job, instances): """Submit publish job to RoyalRender.""" data = instance.data.copy() subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) - # instance.data.get("subset") != instances[0]["subset"] - # 'Main' vs 'renderMain' override_version = None instance_version = instance.data.get("version") # take this if exists if instance_version != 1: @@ -62,6 +64,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): deepcopy(instance.data["anatomyData"]), instance.data.get("asset"), instances[0]["subset"], + # TODO: this shouldn't be hardcoded and is in fact settable by + # Settings. 'render', override_version ) @@ -71,7 +75,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): metadata_path, roothless_metadata_path = \ self._create_metadata_path(instance) - environment = { + environment = RREnvList({ "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], "AVALON_ASSET": legacy_io.Session["AVALON_ASSET"], "AVALON_TASK": legacy_io.Session["AVALON_TASK"], @@ -80,7 +84,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "OPENPYPE_RENDER_JOB": "0", "OPENPYPE_REMOTE_JOB": "0", "OPENPYPE_LOG_NO_COLORS": "1" - } + }) # add environments from self.environ_keys for env_key in self.environ_keys: @@ -88,7 +92,16 @@ class CreatePublishRoyalRenderJob(InstancePlugin): environment[env_key] = os.environ[env_key] # pass environment keys from self.environ_job_filter - job_environ = job["Props"].get("Env", {}) + # and collect all pre_ids to wait for + job_environ = {} + jobs_pre_ids = [] + for job in instance["rrJobs"]: # type: RRJob + if job.rrEnvList: + job_environ.update( + dict(RREnvList.parse(job.rrEnvList)) + ) + jobs_pre_ids.append(job.PreID) + for env_j_key in self.environ_job_filter: if job_environ.get(env_j_key): environment[env_j_key] = job_environ[env_j_key] @@ -99,7 +112,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): if mongo_url: environment["OPENPYPE_MONGO"] = mongo_url - priority = self.deadline_priority or instance.data.get("priority", 50) + priority = self.priority or instance.data.get("priority", 50) args = [ "--headless", @@ -109,66 +122,37 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "--targets", "farm" ] - # Generate the payload for Deadline submission - payload = { - "JobInfo": { - "Plugin": self.deadline_plugin, - "BatchName": job["Props"]["Batch"], - "Name": job_name, - "UserName": job["Props"]["User"], - "Comment": instance.context.data.get("comment", ""), - - "Department": self.deadline_department, - "ChunkSize": self.deadline_chunk_size, - "Priority": priority, - - "Group": self.deadline_group, - "Pool": instance.data.get("primaryPool"), - "SecondaryPool": instance.data.get("secondaryPool"), - - "OutputDirectory0": output_dir - }, - "PluginInfo": { - "Version": self.plugin_pype_version, - "Arguments": " ".join(args), - "SingleFrameOnly": "True", - }, - # Mandatory for Deadline, may be empty - "AuxFiles": [], - } + job = RRJob( + Software="Execute", + Renderer="Once", + # path to OpenPype + SeqStart=1, + SeqEnd=1, + SeqStep=1, + SeqFileOffset=0, + Version="1.0", + SceneName="", + IsActive=True, + ImageFilename="execOnce.file", + ImageDir="", + ImageExtension="", + ImagePreNumberLetter="", + SceneOS=RRJob.get_rr_platform(), + rrEnvList=environment.serialize(), + Priority=priority + ) # add assembly jobs as dependencies if instance.data.get("tileRendering"): self.log.info("Adding tile assembly jobs as dependencies...") - job_index = 0 - for assembly_id in instance.data.get("assemblySubmissionJobs"): - payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 - job_index += 1 + job.WaitForPreIDs += instance.data.get("assemblySubmissionJobs") elif instance.data.get("bakingSubmissionJobs"): self.log.info("Adding baking submission jobs as dependencies...") - job_index = 0 - for assembly_id in instance.data["bakingSubmissionJobs"]: - payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 - job_index += 1 + job.WaitForPreIDs += instance.data["bakingSubmissionJobs"] else: - payload["JobInfo"]["JobDependency0"] = job["_id"] + job.WaitForPreIDs += jobs_pre_ids - if instance.data.get("suspend_publish"): - payload["JobInfo"]["InitialStatus"] = "Suspended" - - for index, (key_, value_) in enumerate(environment.items()): - payload["JobInfo"].update( - { - "EnvironmentKeyValue%d" - % index: "{key}={value}".format( - key=key_, value=value_ - ) - } - ) - # remove secondary pool - payload["JobInfo"].pop("SecondaryPool", None) - - self.log.info("Submitting Deadline job ...") + self.log.info("Creating RoyalRender Publish job ...") url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index f5c7033b62..21e5291bc3 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Python wrapper for RoyalRender XML job file.""" +import sys from xml.dom import minidom as md import attr from collections import namedtuple, OrderedDict @@ -8,6 +9,23 @@ from collections import namedtuple, OrderedDict CustomAttribute = namedtuple("CustomAttribute", ["name", "value"]) +class RREnvList(dict): + def serialize(self): + # VariableA=ValueA~~~VariableB=ValueB + return "~~~".join( + ["{}={}".format(k, v) for k, v in sorted(self.items())]) + + @staticmethod + def parse(data): + # type: (str) -> RREnvList + """Parse rrEnvList string and return it as RREnvList object.""" + out = RREnvList() + for var in data.split("~~~"): + k, v = data.split("=") + out[k] = v + return out + + @attr.s class RRJob: """Mapping of Royal Render job file to a data class.""" @@ -108,7 +126,7 @@ class RRJob: # jobs send from this machine. If a job with the PreID was found, then # this jobs waits for the other job. Note: This flag can be used multiple # times to wait for multiple jobs. - WaitForPreID = attr.ib(default=None) # type: int + WaitForPreIDs = attr.ib(factory=list) # type: list # List of submitter options per job # list item must be of `SubmitterParameter` type @@ -138,6 +156,21 @@ class RRJob: TotalFrames = attr.ib(default=None) # type: int Tiled = attr.ib(default=None) # type: str + # Environment + # only used in RR 8.3 and newer + rrEnvList = attr.ib(default=None) # type: str + + @staticmethod + def get_rr_platform(): + # type: () -> str + """Returns name of platform used in rr jobs.""" + if sys.platform.lower() in ["win32", "win64"]: + return "windows" + elif sys.platform.lower() == "darwin": + return "mac" + else: + return "linux" + class SubmitterParameter: """Wrapper for Submitter Parameters.""" @@ -242,6 +275,8 @@ class SubmitFile: job, dict_factory=OrderedDict, filter=filter_data) serialized_job.pop("CustomAttributes") serialized_job.pop("SubmitterParameters") + # we are handling `WaitForPreIDs` separately. + wait_pre_ids = serialized_job.pop("WaitForPreIDs", []) for custom_attr in job_custom_attributes: # type: CustomAttribute serialized_job["Custom{}".format( @@ -253,6 +288,15 @@ class SubmitFile: root.createTextNode(str(value)) ) xml_job.appendChild(xml_attr) + + # WaitForPreID - can be used multiple times + for pre_id in wait_pre_ids: + xml_attr = root.createElement("WaitForPreID") + xml_attr.appendChild( + root.createTextNode(str(pre_id)) + ) + xml_job.appendChild(xml_attr) + job_file.appendChild(xml_job) return root.toprettyxml(indent="\t") From fbf1b9e6592dac486730253cd9b82ea8ea61be86 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 7 Mar 2023 17:24:58 +0100 Subject: [PATCH 014/144] :art: simple bgeo publishing in houdini --- .../houdini/plugins/create/create_bgeo.py | 65 +++++++++++++++++++ .../houdini/plugins/publish/collect_frames.py | 7 +- .../houdini/plugins/publish/extract_bgeo.py | 50 ++++++++++++++ openpype/plugins/publish/integrate.py | 3 +- 4 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/create/create_bgeo.py create mode 100644 openpype/hosts/houdini/plugins/publish/extract_bgeo.py diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py new file mode 100644 index 0000000000..38e63dbc9f --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating pointcache bgeo files.""" +from openpype.hosts.houdini.api import plugin +from openpype.pipeline import CreatedInstance +from openpype.lib import EnumDef + + +class CreateBGEO(plugin.HoudiniCreator): + """BGEO pointcache creator.""" + identifier = "io.openpype.creators.houdini.bgeo" + label = "BGEO PointCache" + family = "bgeo" + icon = "gears" + + def create(self, subset_name, instance_data, pre_create_data): + import hou + + instance_data.pop("active", None) + + instance_data.update({"node_type": "geometry"}) + instance_data["bgeo_type"] = pre_create_data.get("bgeo_type") + + instance = super(CreateBGEO, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + + instance_node = hou.node(instance.get("instance_node")) + + file_path = "{}{}".format( + hou.text.expandString("$HIP/pyblish/"), + "{}.$F4.{}".format( + subset_name, + pre_create_data.get("bgeo_type") or "bgeo.sc") + ) + parms = { + "sopoutput": file_path + } + + if self.selected_nodes: + parms["soppath"] = self.selected_nodes[0].path() + + # try to find output node + for child in self.selected_nodes[0].children(): + if child.type().name() == "output": + parms["soppath"] = child.path() + break + + instance_node.setParms(parms) + instance_node.parm("trange").set(1) + + def get_pre_create_attr_defs(self): + attrs = super().get_pre_create_attr_defs() + bgeo_enum = [ + {"option": "bgeo", "value": "bgeo", "label": "uncompressed bgeo (.bgeo)"}, + {"option": "bgeosc", "value": "bgeosc", "label": "BLOSC compressed bgeo (.bgeosc)"}, + {"option": "bgeo.sc", "value": "bgeo.sc", "label": "BLOSC compressed bgeo (.bgeo.sc)"}, + {"option": "bgeo.gz", "value": "bgeo.gz", "label": "GZ compressed bgeo (.bgeo.gz)"}, + {"option": "bgeo.lzma", "value": "bgeo.lzma", "label": "LZMA compressed bgeo (.bgeo.lzma)"}, + {"option": "bgeo.bz2", "value": "bgeo.bz2", "label": "BZip2 compressed bgeo (.bgeo.bz2)"} + ] + + return attrs + [ + EnumDef("bgeo_type", bgeo_enum, label="BGEO Options"), + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 531cdf1249..f0264b10a6 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -8,13 +8,12 @@ import pyblish.api from openpype.hosts.houdini.api import lib - class CollectFrames(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" order = pyblish.api.CollectorOrder label = "Collect Frames" - families = ["vdbcache", "imagesequence", "ass", "redshiftproxy"] + families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "bgeo"] def process(self, instance): @@ -35,7 +34,9 @@ class CollectFrames(pyblish.api.InstancePlugin): output = output_parm.eval() _, ext = lib.splitext(output, - allowed_multidot_extensions=[".ass.gz"]) + allowed_multidot_extensions=[ + ".ass.gz", ".bgeo.sc", ".bgeo.gz", + ".bgeo.lzma", ".bgeo.bz2"]) file_name = os.path.basename(output) result = file_name diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py new file mode 100644 index 0000000000..8b14ca7418 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -0,0 +1,50 @@ +import os + +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.houdini.api.lib import render_rop + +import hou + + +class ExtractBGEO(publish.Extractor): + + order = pyblish.api.ExtractorOrder + label = "Extract BGEO" + hosts = ["houdini"] + families = ["bgeo"] + + def process(self, instance): + + ropnode = hou.node(instance.data["instance_node"]) + + # Get the filename from the filename parameter + output = ropnode.evalParm("sopoutput") + staging_dir = os.path.dirname(output) + instance.data["stagingDir"] = staging_dir + + file_name = os.path.basename(output) + + # We run the render + self.log.info("Writing bgeo files '%s' to '%s'" % (file_name, + staging_dir)) + + # write files + ropnode.parm("execute").pressButton() + + output = instance.data["frames"] + self.log.debug(f"output: {output}") + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'bgeo', + 'ext': instance.data["bgeo_type"], + 'files': output, + "stagingDir": staging_dir, + "frameStart": instance.data["frameStart"], + "frameEnd": instance.data["frameEnd"] + } + instance.data["representations"].append(representation) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index b117006871..0971459c0c 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -132,7 +132,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "mvUsdOverride", "simpleUnrealTexture", "online", - "uasset" + "uasset", + "bgeo" ] default_template_name = "publish" From b2a936a7902be9392cb81ee01715145226e3596a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 8 Mar 2023 16:48:14 +0100 Subject: [PATCH 015/144] :rotating_light: few fixes --- openpype/hosts/houdini/plugins/create/create_bgeo.py | 12 ++++++------ .../hosts/houdini/plugins/publish/collect_frames.py | 6 +++--- .../hosts/houdini/plugins/publish/extract_bgeo.py | 7 +++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index 38e63dbc9f..1ca2ce36a0 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -52,12 +52,12 @@ class CreateBGEO(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() bgeo_enum = [ - {"option": "bgeo", "value": "bgeo", "label": "uncompressed bgeo (.bgeo)"}, - {"option": "bgeosc", "value": "bgeosc", "label": "BLOSC compressed bgeo (.bgeosc)"}, - {"option": "bgeo.sc", "value": "bgeo.sc", "label": "BLOSC compressed bgeo (.bgeo.sc)"}, - {"option": "bgeo.gz", "value": "bgeo.gz", "label": "GZ compressed bgeo (.bgeo.gz)"}, - {"option": "bgeo.lzma", "value": "bgeo.lzma", "label": "LZMA compressed bgeo (.bgeo.lzma)"}, - {"option": "bgeo.bz2", "value": "bgeo.bz2", "label": "BZip2 compressed bgeo (.bgeo.bz2)"} + {"value": "bgeo", "label": "uncompressed bgeo (.bgeo)"}, + {"value": "bgeosc", "label": "BLOSC compressed bgeo (.bgeosc)"}, + {"value": "bgeo.sc", "label": "BLOSC compressed bgeo (.bgeo.sc)"}, + {"value": "bgeo.gz", "label": "GZ compressed bgeo (.bgeo.gz)"}, + {"value": "bgeo.lzma", "label": "LZMA compressed bgeo (.bgeo.lzma)"}, + {"value": "bgeo.bz2", "label": "BZip2 compressed bgeo (.bgeo.bz2)"} ] return attrs + [ diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index f0264b10a6..0c2915552a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -33,8 +33,8 @@ class CollectFrames(pyblish.api.InstancePlugin): self.log.warning("Using current frame: {}".format(hou.frame())) output = output_parm.eval() - _, ext = lib.splitext(output, - allowed_multidot_extensions=[ + _, ext = lib.splitext( + output, allowed_multidot_extensions=[ ".ass.gz", ".bgeo.sc", ".bgeo.gz", ".bgeo.lzma", ".bgeo.bz2"]) file_name = os.path.basename(output) @@ -78,7 +78,7 @@ class CollectFrames(pyblish.api.InstancePlugin): frame = match.group(1) padding = len(frame) - # Get the parts of the filename surrounding the frame number + # Get the parts of the filename surrounding the frame number, # so we can put our own frame numbers in. span = match.span(1) prefix = match.string[: span[0]] diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index 8b14ca7418..5e022bc9c0 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -27,14 +27,13 @@ class ExtractBGEO(publish.Extractor): file_name = os.path.basename(output) # We run the render - self.log.info("Writing bgeo files '%s' to '%s'" % (file_name, - staging_dir)) + self.log.info("Writing bgeo files '{}' to '{}'.".format( + file_name, staging_dir)) # write files - ropnode.parm("execute").pressButton() + render_rop(ropnode) output = instance.data["frames"] - self.log.debug(f"output: {output}") if "representations" not in instance.data: instance.data["representations"] = [] From 6a3692b832b717fd90cc256497fa09169f6e87f4 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 8 Mar 2023 16:55:01 +0100 Subject: [PATCH 016/144] :rotating_light: hound fixes :dog: --- .../houdini/plugins/create/create_bgeo.py | 30 +++++++++++++++---- .../houdini/plugins/publish/collect_frames.py | 4 +-- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index 1ca2ce36a0..46fa47df92 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -52,12 +52,30 @@ class CreateBGEO(plugin.HoudiniCreator): def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() bgeo_enum = [ - {"value": "bgeo", "label": "uncompressed bgeo (.bgeo)"}, - {"value": "bgeosc", "label": "BLOSC compressed bgeo (.bgeosc)"}, - {"value": "bgeo.sc", "label": "BLOSC compressed bgeo (.bgeo.sc)"}, - {"value": "bgeo.gz", "label": "GZ compressed bgeo (.bgeo.gz)"}, - {"value": "bgeo.lzma", "label": "LZMA compressed bgeo (.bgeo.lzma)"}, - {"value": "bgeo.bz2", "label": "BZip2 compressed bgeo (.bgeo.bz2)"} + { + "value": "bgeo", + "label": "uncompressed bgeo (.bgeo)" + }, + { + "value": "bgeosc", + "label": "BLOSC compressed bgeo (.bgeosc)" + }, + { + "value": "bgeo.sc", + "label": "BLOSC compressed bgeo (.bgeo.sc)" + }, + { + "value": "bgeo.gz", + "label": "GZ compressed bgeo (.bgeo.gz)" + }, + { + "value": "bgeo.lzma", + "label": "LZMA compressed bgeo (.bgeo.lzma)" + }, + { + "value": "bgeo.bz2", + "label": "BZip2 compressed bgeo (.bgeo.bz2)" + } ] return attrs + [ diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 0c2915552a..6769915453 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -35,8 +35,8 @@ class CollectFrames(pyblish.api.InstancePlugin): _, ext = lib.splitext( output, allowed_multidot_extensions=[ - ".ass.gz", ".bgeo.sc", ".bgeo.gz", - ".bgeo.lzma", ".bgeo.bz2"]) + ".ass.gz", ".bgeo.sc", ".bgeo.gz", + ".bgeo.lzma", ".bgeo.bz2"]) file_name = os.path.basename(output) result = file_name From be39ab147c329b4bf2f27c33c4272733cba1ec66 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 8 Mar 2023 17:29:05 +0100 Subject: [PATCH 017/144] :memo: add docs --- website/docs/artist_hosts_houdini.md | 24 ++++++++++++++++++ website/docs/assets/houdini_bgeo-loading.png | Bin 0 -> 123822 bytes .../docs/assets/houdini_bgeo-publisher.png | Bin 0 -> 94696 bytes .../docs/assets/houdini_bgeo_output_node.png | Bin 0 -> 11699 bytes 4 files changed, 24 insertions(+) create mode 100644 website/docs/assets/houdini_bgeo-loading.png create mode 100644 website/docs/assets/houdini_bgeo-publisher.png create mode 100644 website/docs/assets/houdini_bgeo_output_node.png diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md index f2b128ffc6..30648974e9 100644 --- a/website/docs/artist_hosts_houdini.md +++ b/website/docs/artist_hosts_houdini.md @@ -101,3 +101,27 @@ switch versions between different hda types. When you load hda, it will install its type in your hip file and add published version as its definition file. When you switch version via Scene Manager, it will add its definition and set it as preferred. + +## Publishing and loading BGEO caches + +There is a simple support for publishing and loading **BGEO** files in all supported compression variants. + +### Creating BGEO instances + +Just select your object to be exported as BGEO. If there is `output` node inside, first found will be used as entry +point: + +![BGEO output node](assets/houdini_bgeo_output_node.png) + +Then you can open Publisher, in Create you select **BGEO PointCache**: + +![BGEO Publisher](assets/houdini_bgeo-publisher.png) + +You can select compression type and if the current selection should be connected to ROPs SOP path parameter. Publishing +will produce sequence of files based on your timeline settings. + +### Loading BGEO + +Select your published BGEO subsets in Loader, right click and load them in: + +![BGEO Publisher](assets/houdini_bgeo-loading.png) diff --git a/website/docs/assets/houdini_bgeo-loading.png b/website/docs/assets/houdini_bgeo-loading.png new file mode 100644 index 0000000000000000000000000000000000000000..e8aad66f43980da0135fa4371e3eaa00f50dd0ad GIT binary patch literal 123822 zcmc$F1yGi4*DWQT(wz#@-Q9we@DS4R5YpWsjijg#NQ3l4cZYPRba#Uwo#)2)eZTKJ z|Cu>+{xfst%%IHejCWku-fOSD_PRc*D$8M@lAyxCz+k+VmwpQa^9&OP25uG^4)_<( zStc*w7p(JJIZ2q3VX|G|0>MH;Ndg9@JQnTF_!)4G;vlc%3Q|k!}BBPvvjUbD9!;0zn&5X0Wg_c<>Rh z@DblsIWjT=i8+kK@i?-cBlJ;8$k-mTFLF&7zs*bB-AzmLYfY~9)+tGaHMYN+{O#$X zdH;SA3aZwcG@#T39i}s*-T5O2QvLI@@`(`_K?3`)pHA!l+bagOO}2@paTH_Mf8Uzj zQXYcxugf)_|M}``;b~ZbSYgV^WqqcYKKUJ=j)Z@u*qXmV(#a*8`h))aUb6qVnTg&> z(A?|ZJK5_MtJ_5VK1pnvR�NgQny~xJ@tsFssx^N*jQdySS_ltf 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 Y``j!d+-hon^cr}bR#sPfs$g>KUwMz%8~^|S literal 0 HcmV?d00001 diff --git a/website/docs/assets/houdini_bgeo-publisher.png b/website/docs/assets/houdini_bgeo-publisher.png new file mode 100644 index 0000000000000000000000000000000000000000..5c3534077f37a4025096beed579bbfcc9b444023 GIT binary patch literal 94696 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%0D=#<7{jwP*ke{Ow&PKHPUfotBM<@I9^+u6q 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 literal 0 HcmV?d00001 diff --git a/website/docs/assets/houdini_bgeo_output_node.png b/website/docs/assets/houdini_bgeo_output_node.png new file mode 100644 index 0000000000000000000000000000000000000000..160f0a259b98e830b98997cba281f903a571a552 GIT binary patch literal 11699 zcmdU#XEm7z{=UqLH?`;&&6!jnf-kFcqlS<9HX z{9ks3r{^U33FJY_y@FK@zQQPZSDaW@aubQmf@7c!kCo z-oqlX6ry?_bb57j;2>b$_7vis=>?{VZ+#6~aTlL#P=AWWV9Rx-_o%m0a#B}U7hG!X z>|9<{q+RSGuy7sNio^^H4cOJlj{t9tv%5X7c}7ULz*vf`skdCA`E*u;-N4B}yif>F zl8WZnci`;9@H%|90R^|{C(c;hZuUzAhxVYiaPdsgOr6ihPvk|wWTy9VG!W3XkD zw?9Qac(3&uWO-;EjbTxW5IQ|QC4s22y-`yew7`wXVULJt=~N8w%>-*de6K|Ubvaz? z8MZ_e0f(B=%2S9CEN$Gix#!-Vg?b4Fiy;A9Rs$~V`qfS#g^Nsuf%xNyN!<4yN>QjL z4lZu_9v|h0*i2pIy_YWvi#AVup9c*L4i5J92~ZFS!K%3I1yb@)h;Tw;>2(u50Nchp zfuNKGIB5eS9@*r*%Kzr?lB6R-AH{-m+54=jCLL-g#jpIOBfCgGiR2E0Ykox&=h#kJ zm|flkMs{^9udHw{FW3U=R&3egnb zZySJUezj#3PE+iMwu(OuwkO2;X59nE8WIF1T&ENu#5yLR#KrOri+X@1^{V^c`%Fd* z)=Tskm;cW%b0~U_b*ZJVudk^Yo0TO42LBxy8JV5U*rb$A&Dq@AdLxB9lr~nZvp3i5 zXNS7Ixk6`3r4?Pg6z{Bf(O-3VcxZc10dKO|?`$Mb<&6NnqFWC**}A5tMzD6`&!3rv z6f9y3n*OGlnHj{?Ju;90;p${hk0N9T>+vH>+@tNo6%>BW11@$pUS8fYH!LHU2E#ZO zSX0Nl^`Y$U?rtp@{s>^&RTF3Dy|%XZT&PMeFL(D}XLEjdV8v&DkQ-SkYpwNx0TrVh z;hlw5UsL_^Cw8bYq8xzCG7PZp4q-upnq|mg6UP=`uj9Y;2*B?8zm0 z_?QMe3*HCz2q1KUu#@b$Iv0ET`UZLRRqoP?^(okb3vS zkaqU)zko9-$Xr<1d(@0sL|DrWv09{Org%#~A2-SwHfMq)t^l*geZOsKdD-TS>fuX^ zN|{U%t`X)!O$i9#GX}rpBPNcLce8}o%G_ijmBWA?tIQb(ncDqv@`aBl7Lq|hbQdY{XXQ+y& z34)Fa!A3hGa-M!>`r7_hOE^K0f_q#wzH4s8b9l#v{24cpHms_|`b=FVN#j{KfLt-I zqZ$m{V5wn)>)K$3Q{9!{{FQN0LHzg^mkplGWMb&>hWp_8Q`jg%BaTI>vY#(1A9kVo z2A?su=S`<61js~X#YTlicIsYh=jZ2r37ok!Y>_!ORg*dDs)?4xV^q!R%Ga~=H%2Vq zW}Uo>*5Y>mJMl+XyC_qN2n}=09Da~}ps8utWXEu|{6{K6dxZ9%pE(?<@{ow{p>Q~y zRrB~F1hLdiSH~Vf7RmZa_IY6ul_0M9NE!a>#li8YIw_>(q|}*`Ak1f8j0KxAX?N+< zCw3QeA*yG3vCI4C=kGMMQI8&PlW}$qeIRC%i+`qv7kdBpt>UAL2lwixF=rQ@MRs+` zkYI`l@$0}KNpd&LuLbJRUW+Lph@=YJ3&AAZR{N=)e=*`gmYKslE3DC(jg8_V?L*_^ zwLy2cz2t8sFP7t>Q0V;4uGgzp_;mhp9Oe6m2a}r&0h?9d1u}YI0ugY{p-C|S|@$*n>5xl zZQyjf3OznCK`-XIJRmyXKR6h;nqtkCRd9H@RX*)d+qBylN!;NwGwU^fgOs`DCwceo zO}jWx>4kqwasmy145i)!g{KLox{!}(`t=Rko`HTFCtEL=YL9Yqjtzu8)doQvy}}Oj zSX~`WwGftXN|rz_)g!JiCt9TgE`=DEJBegK*o%V_dt98HTie?+&3<*Ai{6W& z_>2W=8NPsl=>4`U)-wH~i3Rxh_UHWecK%jin$PA8nmkK;EyUj~w9Pz!a74IOU%h&E zWlk3TyJV3sZ9qP9EEZ!ykxC3Be&rP7<=}6q4T9ihvqRp1V46Z*N8L2mbk=-Be+)ws ze(Ri&!~jvQH!Fr-Mh%aHgCm>WZXi_{27|@Ek0dy|z1kxuBO`nE4608^C&{if5<(>H zeKzZLhYY&LeWc-})>B_iMu20U35t15qkxyq5wTdW!T!4vXU@VBY@BPO2k%7uq(M@F zqs_$<7YWkadyd%Rd+kiYJS?I8R&HNqG5z()U96v|sHvF|7M7NJrP@`KGxPH@C!;;( z@iB%3I3at#jt05wW^#%)W0@6Jz1a9nzPQ-Jh!N|0q*X-FQB(M1Y;0jw@7x3oOvrl3 zCd^cho>H&`hV(3}*`&_Z*eB(%lk*sU{1f|Ud|WLvKrn5fZa(m8 zVd3ra5(iWhSZR(QHwfpad2GR zp0%puUEJ&iwE|yBfzD?+T;`gQ@%xV->~T?)l$2a3r@udgiGkWifS;cqISC;F;vy#r zDgTum?`vj6^1y1xhieV582kG9xLD|DHCf`=PgVATw|uT#3ZtMdJ`jwQn5(R= zLZ7>eRqH#L=knWuR`YLjclYC!B$L4N#qc`EX%T7Z+bR38;1+g!A*Y<$O|FHLYY}{L@rg6~CiL*b3np(sG>~pxhIaa&} zEX!WTmcxW{Vo2a&d=>-d^?k~umeo8kPlr<-FDI{r-zpb>8jO z(x?zzlOxB+(ERq`Y5b(_ulgaYssZhGYV?1;to!-?VrMuJDJ1YXmww={slUJfd`rMY zT{tAWAk){*4%Ohc#-t~YCk7NE?IF1Tq9|`q{jo4_mrJ}V+i2fqf;AP|R*RnzLHt4j z*-OnG$I>K^+ibr3&AFq8|Kj0A%6|?n)F3NnsnGWxLr=ZC$k|eTp-pP7e(8!zqWjqi zWvNJ_i*(#>AuvLfGg@er*CvK z;vDF<+!ISH;b}N;Z5)9qLmv6d$_4^I5Cc|moBpDtT!k2%2f?LdWA`*AT`pWl;)Br= z7U@p3;%=+Ku(Zs-%~zAwS+lJ{cfezp@X585Gchsws-PwB?(RNwK59%$OFK;NTgi2H zb|yM_DBqr$nF&0{yAAcA%Ttq^XN=23DT0p$RL;}C{kLY~ZqSVh~OB?c$K}r2~iTp@P_FPi7 zdt);0*Du;)?a~*#>L+rqF}iL?!`=2pIOVhBL9cLQtA!|hRYA1#a zrHlUmt7YE*bEVpy?|c6nZm17Rz(FLQ@uj`2{*xj7kK+&ou?%BcVikfoVF@0gV;iZQ z^00U5;NmiT>a~=;;^Q8$;z-=mrm8{m*kcI9w%Y_v(#Q>tbLLi#ZuluVy43859%OXr4=At zf;X8Zq#Xa;G|b)cttJ!bEoEcwE$x3~#|qP-TJynDH17d_EcNVq(KIFZCqI<#;}gG~ z@3y0^<0=zO8*hynFbt_<@G-W$6`b$WLY_6i8Ic}sArZ{_vsG+LTtS1^h8gyB772-< z!S<5s5|dbAl76I*xFXdi<}J<%+~Z+4nf6J(FB1aZ{IEOmvZzr@9b3BmXLN}D*Jl&p zfd?fc>6@PnJ9C7}fuS!z?x%*TCx#1dI9uTJ{=>tBi*8QQ&chwwlFfm&axPwzl+90V zYXky>(*N!%zF|_`Pb@{HhEqGwmu%nXL(~HZUSWAr)%<>BL2pGd3@n9v08j|qAI44% zy=rYxYt2;ZqND=g+Kc!0gd1foMT6{U>oyM6M0o>z$M3eJj9P!IU&9~fM10+}?!+7k zM`-Srlov@%_?xzP;`u_yCMF7e3BL$r&9(7a3mClR)3!*_S=(1BsLkTYrz*31W6%xR z!L-XO{p#R&;6_hg_v)ppE;0`&Ryfxv-U;Z~@RxJ`oaqo82aEPDCds?9jZ;Z8nD?CZl9$%KG3<^>y1FQJmSE)*!dES}-+X-d~-`gzOM2G~tzmHPkUB%AAB^N0z z>GNUAh7pjlW}i-Ktw_e&^ryVOtd^)31B9+(j#m}x`cUpcG%?dds}@}u13wU4Or6Dd zC_>`Yl*+=8O6kXvj%g){xLgK^Mv(0foe^v|{!#q1pgS}w{^3HHYksy*YMzIGOx;`~t6G_t1AtQB!O-TD+3?rc5Q;{e(D zcdd5%x^*p9y@v`Hwa*j?l+IA{3(CvB4;-po%9A3Ti>49nECUKKUC-t=B6^`k-HJZiqD6 zaToDAw>7mav~?A%bx15>eh&?lFe9=csAl@bZs^061MiY?8})pEZt6fc&AHD~$N4V2 zcrW5w&x+4x!=oOg$Bb6N(nOv@a&Xg;nCu@E3g2?rsXkX}*8xxq?fNro7sJ;!)8}$sBY~(HfvSlVMM&E%c)2 zkZVW$s!_P~|A9JI4h~VKl$s!^Ky??TijtnamaE;u_yP;PvhwmeJ;&IL$eeZ8hO*O> zN9_xZHCtR{D4GyqCJKV+s;aV=1|b%mVNa0Jw2>{O|EjZ0rPtdE0oWn!>()znjFnvI zR2Ne79G1)G+r{BnAhG++WTtPYGy2q56$lD)j>azHahkRQ`W#nF8HGY8S-P0{cf94J zAP8Fzk;fk|jHkDXw7zneGyUvx{qW`dKMF!YkgCf6$o--0&*>e(KoCFy49oJ}$is2M zGt}1Tqg%4#olK0i3J~z$q?5#u&FH|D;5!g(;O`K=8x02>&3XsL3(NP33NVtJfMWt_Ek?rS#K^$mBL2+GEvq5Zv zV1~7_vNC615|2^Eud=esj!+d+x;lo4U3{o#7u+RbEM1N zvHBgT@I)|0waIHw+H+eseZuiQ1Lh@sY@OU#=}&!yZV4#6iiU8m$ACMrGS9LL8cCUW zGj@0571UO4t8cHewF*lUoogI+;15S=cv&Y6m>!+~lFoA8r7U>LG{hh6lj{bi@jKlv zEG`zLAaHbaygJ+4+ue=!KVHv`0Yu_SvB}MLZ7skw2KUZd&zB-4(XGcLDzV)GSEtb1 zyXD?^-saC#vS2dsDE_fKfK4p;8Ic`K&w|t zV`uMtvmQcPNy5X<-qYXrLjScxh5psmmHn0-_`$@;xWJIi!in$1e5L6{<%(0xRcDO! zvA2|7il7X&Q*@V$3xMbT&pe6eGOVZwXrH+PsK>ME2C(`YnbhQD?}IM#hq4Tabg;`3 zuFQEmfWBzmCWSGC8EVfNe{bDvPaaEWsl&%6%kJ8ufFM7xcZTGm`Q)?IZy}&x>KO)L zkpz|%01=VNnflMv1R!biR#*u-OLSY7yjrhjFCy10m9$wrMjJnKb92w`%t9YR-FoZS zyIlA+K{ZwbsrmW&ifvHObAUPlBD~JVGlgYr_aODpP{(cCR_H<+APsqv#;BS!tmI=acM3nrYl4t22VbQ+SV}O*s&I3|fXLV9WI^<)`K4sVybJca z^BTt5JW3-CM{AWddiFY^KWV1t<^q?_KlS(bfB5yGD7C0&Ya;M?m|g)XFP6Bovz3AR zy~Gh6c|o4cW(8HrVG*?U_x$sW;hq($-%9aVB6-_VeY@q;?)B z{c!yP0<-e%?5W4gjH<@p^8F~%*U|#*MV*~G;JEIb-{Eu z5Qrm}z^(K21UQc$J<1mWARj=l!o%?y<5+;EX61HuZkN&liw@9cLajuU^pMPEvnT;3 zVr6BG=g!N^LwqUJVPs$fl6K8Iuw+t4tP%}Uub+$*oh21e+mzen3bkCui_G5uZuM%n zX>X2qj(Q)rtlG#{a@u=kB%!pxp!)<+=kU&_ z7al+u1=1s+6_tXvgb>ylTa%Y3TeO4(@-F-qWGUK~d3W9PQ|2jWF(xdzP{V!IMjCjZyfA5_+PuG@KR>rMe ze5MwaU*e@9%mTYGRW@{)=~Y>M9^I&(-r+(;$HdILp{0PB@a)+$tEz^^#>-&^`V-$~ zAhblL%c_`#2LuEd)H?hdl5bl#>p9)%u@!i;Wnxa7L=0qPC>pE@!o|VKr+YN;*V@9u z;$W$ZA2}lQIfp%SjU)9D=OTe$H-O^VBPu5ye0+RDLqpXHM(t`Uxu{nfm?Lrso;-ol zyPKIU#VLrHe%AIS6U0EWhn{Eo$7N(R`y8!KO-%&{2M66u1nsP!AFqIeU$*Ud@*32Z zl$8O&{O)>92L0V>%p2G^p!aVt02*DV%M~9h#K-rxLKnas#YIIxr@5)AX|G`|(;ON7 zgn+;SpjllJ&wxdJ*~J^U=-CuM2e=skZU6%SEU|dN>T1V1DDwFd>m&5N@ha!Qqcp(x zs-*5*-QBBeYJ@LI$7h_pbaZqi)v>hUaLunR!N&0^^wiYlML+Uw#aADEwOT&Anx2}< zA(|Q+JJ|(a1-UxKjI4=uCM?CO$lPqG$0xsXHTK=eBYm#+>vYOq)IsheK9K$=eu0p% zh;-Q66Zy0OCxL_9vf<>IvNYICmkUP|wM@ja87+V?cmf7gTxPU@Mtc-n7CI{-UQkk; zh5#o%O;?igIbH|fmZH<$7i|EFkx6x6%YM`Ylc;AEmS`6j*`>;N*y%Ri(&`_4vewW` zG%v%Xm&6$!aIfPFsG-#|B`H^Iga()DCo^e8nM2y&F2ST@&}~%SukKT`iQ9l_qElR+ zSqbb|jf9?_ln336>6kRfGDB2*z)}*P+d!=Yv>LnB*NVYTsU`g-BVBpI3Bvf?SfsRC zqe41=mg;$jnED7IV(YWsdsWtHW010#n87=f{in`1e0aU6dm}p5lP~Tv#rwt!t zCylpEsj{3?{@j1Q@ml;RYKa#uv@;*%-M)&KMg4Z-hfr9kOf>pT@Cx)?@}SZ7_`w(+e-Lo$PMOf;Kgviq4E<#0M}A3LE12=_B33FB9; z{*8~>0|D>Js4&|le5aAL^|_?dp&o{fcyTsod1|?rgnldS-;sT~G`d<6rYQ0#$~bNl z;A3nB_{;I!ufuPfDhr#k8Y&BEA9ev51|3cCo{)XooO{RF5ZphRUE(@l-Cg3EB_>e@ zU!Jj!TNiy8@TxoSDX$TAkhO?ppvy8+Davf{DXCq|?ov<8{yF^50p0hD&)~`- z6=`gRIKp=%QQe;g6l!-~Qq@2bNW7xXO`zifL8$+LIbUyz^apDT_z)xaKrMUc^FR{G z6!yqHJB5+xc0((3BYejWgi&WOsl53!^zXo+4@?4!#O8wytB*Q$Rpj3al|>!(h3s_7 zEG#2ToW)tbHcI7wa)N5Q_fGL@&)6}e0xxc63ryH^L$8`7b2^%tJLGFQ0hxGhZ%TjtRn5eJ!% zO~q0KGaJJ_45v04eL32+SsR~G)6vuT0?&;hbFf8r^sRYf!32{kg=BMLyPIB1dkVSN zXmtY-0b%o(A!yh~EQ2u*;S{LhY=Am%)6T(g?VHcef#o-y6P3z3WkJ`_H}X!B(`0{U zcDYRAe}5ZI-X1(0AR65+iP7hmIK)09X8fI=!mCOErK(CQDQdY&yIG4d1nyhE9lEl-&G&*1{pRSeX4T)MeOD_Zu8`VG#W#B zM%o-QoXPB~fX~RLU_5kV+nq3bIJCbKBN5Z8gom$2;B>jElF}tis>3@NAT1tZJ|@(a zhH~82C7~85QYXlydQ>x9GTLu75Wr@heL8qxqC7Xxzj<8L2!0Z&!X_yw6T;HiN2`vw zE*n3)b*yCN3KxQN7$0>7NA7W&eOpA`F_&+SXRpREWhFY#H66A%du2(trEGgi--K~6 zi!ul4U92`YphS;_>58UK>LOo=kU+F|1M`;#TRnLU*624P5O3vWyT;2~1*UF$E!cL? z2d7`R?a7ecsN!GnHwg!s7L~^HKciLNWa#i#u5f0k#y#I?&?vTF z?qVz(GBehB+Yo9^5?BLa-=@c-A;MO7a}A10QkD~@D%;&w+TN7Ih7MCcMETyjztg{x za~5P|srsS(jtE^_O7bW@aHn-*_R2&mWJfAUoFXw)@haz8nnQzS26CPO@nZUTWz58P zH#aMQ_0qO-chp%$Rvj<~;9=^74T#-op}BjBDj92-*aTw9kX=XWM~s~+NDBDjurO_i__2$cYEhrPh8+Ten=D;=ZWW2>~*h!9b=G8qN8OGmILoXhWqUrE5pXN$E z2hZ*8+FSWlfsW9W(c$Hq&|UQ~?iDFo8;yU*T=j){e$ zU$AvXM9tvVlinG{h5(7qklIe(&IkH!2L%~Dy+II<+oWvw9*~KBy$&IA2X=%l_velK z=nc&B-K({W1|$bi3(p31HMl!&UA3lP(rB!Wcpz*@0|pSpFu1Mw#YYiK@a&Ba9-|zv z=R<3$N&IT{v5J#VkLb+Krd_{L?Vyd9vD8u70wp8D#_n%Ae4Tdl<8ieG`4gjC@J8V# z#U|!GF=SBKd@-oOcrV3^dlGsvX251Zuo~4>yhmj##p;X1V|+a%*f5mK zE?%{s8bJvsusVKBgby3|e@32+NsA1pZmX} z-Jx$$#=}=>|1jm40)ZIh^Q&-M`n1+qKl89-RHVLAXlI?D{HYDv#uj#Y+ zvgB{J|M~mSsi~=wpVy&Iu(i;5pgx)ZL*cq-!}$ufB2Lt@MbeT%)%00>Ph&&X}V)_awY}8r->|!)2LAsM=eo+C}Pc?I`)( zyiE9=KYoYk%!XlO?KdlG=rB3qEjqmtXa1c63kJkbocQ5kjn}e{^GS@+$!B+1#^T5T zkr!RitI{z`&b8RPZsvUI$ z9JD1`om_3KtaYHr#B*srQ}sk{feK8ID+mVK053}vkMUU_%VzI9FzXtif0vcBUzVOm zSXSK8-&kh6$z%bC*XY?QjnQD&E;$^rLRJ?uH53hi4h!3%T(7i}0q!UTsA5~r|7&>L(Xg}&RrDMb}@T)`=3p!GjANONwbP|?cIrJi8 zly@oLVQB(Q8KwW6H06=LZ|64+BH_P={G=l^Y& dZ}JZJQK{q0l(^h_U>7!)5>!pD4D$BF{{VnF&w2m= literal 0 HcmV?d00001 From 912c34aca03f98b826d75ed73e5052f7d363c448 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 8 Mar 2023 17:44:39 +0100 Subject: [PATCH 018/144] :memo: :bug: fix file name bug preventing building of documentation --- website/docs/artist_hosts_aftereffects.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/website/docs/artist_hosts_aftereffects.md b/website/docs/artist_hosts_aftereffects.md index a9c9ca49fa..32cc8af232 100644 --- a/website/docs/artist_hosts_aftereffects.md +++ b/website/docs/artist_hosts_aftereffects.md @@ -15,18 +15,18 @@ sidebar_label: AfterEffects ## Setup -To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}hosts/aftereffects/api/extension.zxp`. +To install the extension, download, install [Anastasyi's Extension Manager](https://install.anastasiy.com/). Open Anastasyi's Extension Manager and select AfterEffects in menu. Then go to `{path to pype}hosts/aftereffects/api/extension.zxp`. -Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. +Drag extension.zxp and drop it to Anastasyi's Extension Manager. The extension will install itself. ## Implemented functionality AfterEffects implementation currently allows you to import and add various media to composition (image plates, renders, audio files, video files etc.) -and send prepared composition for rendering to Deadline or render locally. +and send prepared composition for rendering to Deadline or render locally. ## Usage -When you launch AfterEffects you will be met with the Workfiles app. If don't +When you launch AfterEffects you will be met with the Workfiles app. If don't have any previous workfiles, you can just close this window. Workfiles tools takes care of saving your .AEP files in the correct location and under @@ -34,7 +34,7 @@ a correct name. You should use it instead of standard file saving dialog. In AfterEffects you'll find the tools in the `OpenPype` extension: -![Extension](assets/photoshop_extension.PNG) +![Extension](assets/photoshop_extension.png) You can show the extension panel by going to `Window` > `Extensions` > `OpenPype`. @@ -67,7 +67,7 @@ Publisher allows publishing into different context, just click on any instance, #### RenderQueue -AE's Render Queue is required for publishing locally or on a farm. Artist needs to configure expected result format (extension, resolution) in the Render Queue in an Output module. +AE's Render Queue is required for publishing locally or on a farm. Artist needs to configure expected result format (extension, resolution) in the Render Queue in an Output module. Currently its expected to have only single render item per composition in the Render Queue. From 68edd86cd68be165ba0a0cc83367d8fe6e2a02a8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 15 Mar 2023 14:47:44 +0100 Subject: [PATCH 019/144] :rotating_light: fix quotes style --- .../hosts/houdini/plugins/publish/extract_bgeo.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index 5e022bc9c0..daa35adfff 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -5,6 +5,14 @@ import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop +import hou +import os + +import pyblish.api + +from openpype.pipeline import publish +from openpype.hosts.houdini.api.lib import render_rop + import hou @@ -39,9 +47,9 @@ class ExtractBGEO(publish.Extractor): instance.data["representations"] = [] representation = { - 'name': 'bgeo', - 'ext': instance.data["bgeo_type"], - 'files': output, + "name": "bgeo", + "ext": instance.data["bgeo_type"], + "files": output, "stagingDir": staging_dir, "frameStart": instance.data["frameStart"], "frameEnd": instance.data["frameEnd"] From af471f6bb3ed236e0b80cde34abe2d00493baed0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 15 Mar 2023 14:48:53 +0100 Subject: [PATCH 020/144] :bug: fix messed up imports --- openpype/hosts/houdini/plugins/publish/extract_bgeo.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index daa35adfff..23c3b78813 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -5,14 +5,6 @@ import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop -import hou -import os - -import pyblish.api - -from openpype.pipeline import publish -from openpype.hosts.houdini.api.lib import render_rop - import hou From 085d803558f894261b5cf24c19b95a2f8a4557d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 26 Oct 2022 18:37:44 +0200 Subject: [PATCH 021/144] :construction: wip on maya royalrender submit plugin --- openpype/modules/royalrender/api.py | 20 ++- .../publish/submit_maya_royalrender.py | 150 ++++++++++++++++++ openpype/modules/royalrender/rr_job.py | 8 +- 3 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index de1dba8724..c47d50b62b 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -15,9 +15,8 @@ class Api: RR_SUBMIT_CONSOLE = 1 RR_SUBMIT_API = 2 - def __init__(self, settings, project=None): + def __init__(self, project=None): self.log = Logger.get_logger("RoyalRender") - self._settings = settings self._initialize_rr(project) def _initialize_rr(self, project=None): @@ -91,21 +90,21 @@ class Api: sys.path.append(os.path.join(self._rr_path, rr_module_path)) - def create_submission(self, jobs, submitter_attributes, file_name=None): - # type: (list[RRJob], list[SubmitterParameter], str) -> SubmitFile + @staticmethod + def create_submission(jobs, submitter_attributes): + # type: (list[RRJob], list[SubmitterParameter]) -> SubmitFile """Create jobs submission file. Args: jobs (list): List of :class:`RRJob` submitter_attributes (list): List of submitter attributes :class:`SubmitterParameter` for whole submission batch. - file_name (str), optional): File path to write data to. Returns: str: XML data of job submission files. """ - raise NotImplementedError + return SubmitFile(SubmitterParameters=submitter_attributes, Jobs=jobs) def submit_file(self, file, mode=RR_SUBMIT_CONSOLE): # type: (SubmitFile, int) -> None @@ -119,15 +118,14 @@ class Api: # self._submit_using_api(file) def _submit_using_console(self, file): - # type: (SubmitFile) -> bool + # type: (SubmitFile) -> None rr_console = os.path.join( self._get_rr_bin_path(), - "rrSubmitterconsole" + "rrSubmitterConsole" ) - if sys.platform.lower() == "darwin": - if "/bin/mac64" in rr_console: - rr_console = rr_console.replace("/bin/mac64", "/bin/mac") + if sys.platform.lower() == "darwin" and "/bin/mac64" in rr_console: + rr_console = rr_console.replace("/bin/mac64", "/bin/mac") if sys.platform.lower() == "win32": if "/bin/win64" in rr_console: diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py new file mode 100644 index 0000000000..c354cc80a0 --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to RoyalRender.""" +import os +import sys +import tempfile + +from maya.OpenMaya import MGlobal # noqa +from pyblish.api import InstancePlugin, IntegratorOrder +from openpype.hosts.maya.api.lib import get_attr_in_layer +from openpype.pipeline.farm.tools import get_published_workfile_instance +from openpype.pipeline.publish import KnownPublishError +from openpype.modules.royalrender.api import Api as rr_api +from openpype.modules.royalrender.rr_job import RRJob, SubmitterParameter + + +class MayaSubmitRoyalRender(InstancePlugin): + label = "Submit to RoyalRender" + order = IntegratorOrder + 0.1 + use_published = True + + def __init__(self, *args, **kwargs): + self._instance = None + self._rrRoot = None + self.scene_path = None + self.job = None + self.submission_parameters = None + self.rr_api = None + + def get_job(self): + """Prepare job payload. + + Returns: + RRJob: RoyalRender job payload. + + """ + def get_rr_platform(): + if sys.platform.lower() in ["win32", "win64"]: + return "win" + elif sys.platform.lower() == "darwin": + return "mac" + else: + return "lx" + + expected_files = self._instance.data["expectedFiles"] + first_file = next(self._iter_expected_files(expected_files)) + output_dir = os.path.dirname(first_file) + self._instance.data["outputDir"] = output_dir + workspace = self._instance.context.data["workspaceDir"] + default_render_file = self._instance.context.data.get('project_settings') \ + .get('maya') \ + .get('RenderSettings') \ + .get('default_render_image_folder') + filename = os.path.basename(self.scene_path) + dirname = os.path.join(workspace, default_render_file) + + job = RRJob( + Software="Maya", + Renderer=self._instance.data["renderer"], + SeqStart=int(self._instance.data["frameStartHandle"]), + SeqEnd=int(self._instance.data["frameEndHandle"]), + SeqStep=int(self._instance.data["byFrameStep"]), + SeqFileOffset=0, + Version="{0:.2f}".format(MGlobal.apiVersion() / 10000), + SceneName=os.path.basename(self.scene_path), + IsActive=True, + ImageDir=dirname, + ImageFilename=filename, + ImageExtension="." + os.path.splitext(filename)[1], + ImagePreNumberLetter=".", + ImageSingleOutputFile=False, + SceneOS=get_rr_platform(), + Camera=self._instance.data["cameras"][0], + Layer=self._instance.data["layer"], + SceneDatabaseDir=workspace, + ImageFramePadding=get_attr_in_layer( + "defaultRenderGlobals.extensionPadding", + self._instance.data["layer"]), + ImageWidth=self._instance.data["resolutionWidth"], + ImageHeight=self._instance.data["resolutionHeight"] + ) + return job + + @staticmethod + def get_submission_parameters(): + return [] + + def create_file(self, name, ext, contents=None): + temp = tempfile.NamedTemporaryFile( + dir=self.tempdir, + suffix=ext, + prefix=name + '.', + delete=False, + ) + + if contents: + with open(temp.name, 'w') as f: + f.write(contents) + + return temp.name + + def process(self, instance): + """Plugin entry point.""" + self._instance = instance + context = instance.context + self.rr_api = rr_api(context.data["project"]) + + # get royalrender module + """ + try: + rr_module = context.data.get( + "openPypeModules")["royalrender"] + except AttributeError: + self.log.error("Cannot get OpenPype RoyalRender module.") + raise AssertionError("OpenPype RoyalRender module not found.") + """ + + self._rrRoot = instance.data["rrPath"] or context.data["defaultRRPath"] # noqa + if not self._rrRoot: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + file_path = None + if self.use_published: + file_path = get_published_workfile_instance() + + # fallback if nothing was set + if not file_path: + self.log.warning("Falling back to workfile") + file_path = context.data["currentFile"] + + self.scene_path = file_path + self.job = self.get_job() + self.log.info(self.job) + self.submission_parameters = self.get_submission_parameters() + + self.process_submission() + + def process_submission(self): + submission = rr_api.create_submission( + [self.job], + self.submission_parameters) + + self.log.debug(submission) + xml = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) + with open(xml.name, "w") as f: + f.write(submission.serialize()) + + self.rr_api.submit_file(file=xml) + + diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index c660eceac7..beb8c17187 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -35,7 +35,7 @@ class RRJob: # Is the job enabled for submission? # enabled by default - IsActive = attr.ib() # type: str + IsActive = attr.ib() # type: bool # Sequence settings of this job SeqStart = attr.ib() # type: int @@ -60,7 +60,7 @@ class RRJob: # If you render a single file, e.g. Quicktime or Avi, then you have to # set this value. Videos have to be rendered at once on one client. - ImageSingleOutputFile = attr.ib(default="false") # type: str + ImageSingleOutputFile = attr.ib(default=False) # type: bool # Semi-Required (required for some render applications) # ----------------------------------------------------- @@ -169,11 +169,11 @@ class SubmitFile: # Delete submission file after processing DeleteXML = attr.ib(default=1) # type: int - # List of submitter options per job + # List of the submitter options per job. # list item must be of `SubmitterParameter` type SubmitterParameters = attr.ib(factory=list) # type: list - # List of job is submission batch. + # List of the jobs in submission batch. # list item must be of type `RRJob` Jobs = attr.ib(factory=list) # type: list From 77315d301c79fe0125bcaab624a8e0ef405f312c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 26 Oct 2022 18:38:11 +0200 Subject: [PATCH 022/144] :recycle: move functions to common lib --- .../deadline/abstract_submit_deadline.py | 111 +----------------- openpype/pipeline/farm/tools.py | 109 +++++++++++++++++ 2 files changed, 111 insertions(+), 109 deletions(-) create mode 100644 openpype/pipeline/farm/tools.py diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 648eb77007..5d1b703b77 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -21,6 +21,7 @@ from openpype.pipeline.publish import ( AbstractMetaInstancePlugin, KnownPublishError ) +from openpype.pipeline.farm.tools import get_published_workfile_instance JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) @@ -426,7 +427,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): file_path = None if self.use_published: if not self.import_reference: - file_path = self.from_published_scene() + file_path = get_published_workfile_instance(instance) else: self.log.info("use the scene with imported reference for rendering") # noqa file_path = context.data["currentFile"] @@ -500,95 +501,6 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): """ return [] - def from_published_scene(self, replace_in_path=True): - """Switch work scene for published scene. - - If rendering/exporting from published scenes is enabled, this will - replace paths from working scene to published scene. - - Args: - replace_in_path (bool): if True, it will try to find - old scene name in path of expected files and replace it - with name of published scene. - - Returns: - str: Published scene path. - None: if no published scene is found. - - Note: - Published scene path is actually determined from project Anatomy - as at the time this plugin is running scene can still no be - published. - - """ - instance = self._instance - workfile_instance = self._get_workfile_instance(instance.context) - if workfile_instance is None: - return - - # determine published path from Anatomy. - template_data = workfile_instance.data.get("anatomyData") - rep = workfile_instance.data["representations"][0] - template_data["representation"] = rep.get("name") - template_data["ext"] = rep.get("ext") - template_data["comment"] = None - - anatomy = instance.context.data['anatomy'] - anatomy_filled = anatomy.format(template_data) - template_filled = anatomy_filled["publish"]["path"] - file_path = os.path.normpath(template_filled) - - self.log.info("Using published scene for render {}".format(file_path)) - - if not os.path.exists(file_path): - self.log.error("published scene does not exist!") - raise - - if not replace_in_path: - return file_path - - # now we need to switch scene in expected files - # because token will now point to published - # scene file and that might differ from current one - def _clean_name(path): - return os.path.splitext(os.path.basename(path))[0] - - new_scene = _clean_name(file_path) - orig_scene = _clean_name(instance.context.data["currentFile"]) - expected_files = instance.data.get("expectedFiles") - - if isinstance(expected_files[0], dict): - # we have aovs and we need to iterate over them - new_exp = {} - for aov, files in expected_files[0].items(): - replaced_files = [] - for f in files: - replaced_files.append( - str(f).replace(orig_scene, new_scene) - ) - new_exp[aov] = replaced_files - # [] might be too much here, TODO - instance.data["expectedFiles"] = [new_exp] - else: - new_exp = [] - for f in expected_files: - new_exp.append( - str(f).replace(orig_scene, new_scene) - ) - instance.data["expectedFiles"] = new_exp - - metadata_folder = instance.data.get("publishRenderMetadataFolder") - if metadata_folder: - metadata_folder = metadata_folder.replace(orig_scene, - new_scene) - instance.data["publishRenderMetadataFolder"] = metadata_folder - - self.log.info("Scene name was switched {} -> {}".format( - orig_scene, new_scene - )) - - return file_path - def assemble_payload( self, job_info=None, plugin_info=None, aux_files=None): """Assemble payload data from its various parts. @@ -648,22 +560,3 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): self._instance.data["deadlineSubmissionJob"] = result return result["_id"] - - @staticmethod - def _get_workfile_instance(context): - """Find workfile instance in context""" - for i in context: - - is_workfile = ( - "workfile" in i.data.get("families", []) or - i.data["family"] == "workfile" - ) - if not is_workfile: - continue - - # test if there is instance of workfile waiting - # to be published. - assert i.data["publish"] is True, ( - "Workfile (scene) must be published along") - - return i diff --git a/openpype/pipeline/farm/tools.py b/openpype/pipeline/farm/tools.py new file mode 100644 index 0000000000..8cf1af399e --- /dev/null +++ b/openpype/pipeline/farm/tools.py @@ -0,0 +1,109 @@ +import os + + +def get_published_workfile_instance(context): + """Find workfile instance in context""" + for i in context: + is_workfile = ( + "workfile" in i.data.get("families", []) or + i.data["family"] == "workfile" + ) + if not is_workfile: + continue + + # test if there is instance of workfile waiting + # to be published. + if i.data["publish"] is not True: + continue + + return i + + +def from_published_scene(instance, replace_in_path=True): + """Switch work scene for published scene. + + If rendering/exporting from published scenes is enabled, this will + replace paths from working scene to published scene. + + Args: + instance (pyblish.api.Instance): Instance data to process. + replace_in_path (bool): if True, it will try to find + old scene name in path of expected files and replace it + with name of published scene. + + Returns: + str: Published scene path. + None: if no published scene is found. + + Note: + Published scene path is actually determined from project Anatomy + as at the time this plugin is running the scene can be still + un-published. + + """ + workfile_instance = get_published_workfile_instance(instance.context) + if workfile_instance is None: + return + + # determine published path from Anatomy. + template_data = workfile_instance.data.get("anatomyData") + rep = workfile_instance.data.get("representations")[0] + template_data["representation"] = rep.get("name") + template_data["ext"] = rep.get("ext") + template_data["comment"] = None + + anatomy = instance.context.data['anatomy'] + anatomy_filled = anatomy.format(template_data) + template_filled = anatomy_filled["publish"]["path"] + file_path = os.path.normpath(template_filled) + + self.log.info("Using published scene for render {}".format(file_path)) + + if not os.path.exists(file_path): + self.log.error("published scene does not exist!") + raise + + if not replace_in_path: + return file_path + + # now we need to switch scene in expected files + # because token will now point to published + # scene file and that might differ from current one + def _clean_name(path): + return os.path.splitext(os.path.basename(path))[0] + + new_scene = _clean_name(file_path) + orig_scene = _clean_name(instance.context.data["currentFile"]) + expected_files = instance.data.get("expectedFiles") + + if isinstance(expected_files[0], dict): + # we have aovs and we need to iterate over them + new_exp = {} + for aov, files in expected_files[0].items(): + replaced_files = [] + for f in files: + replaced_files.append( + str(f).replace(orig_scene, new_scene) + ) + new_exp[aov] = replaced_files + # [] might be too much here, TODO + instance.data["expectedFiles"] = [new_exp] + else: + new_exp = [] + for f in expected_files: + new_exp.append( + str(f).replace(orig_scene, new_scene) + ) + instance.data["expectedFiles"] = new_exp + + metadata_folder = instance.data.get("publishRenderMetadataFolder") + if metadata_folder: + metadata_folder = metadata_folder.replace(orig_scene, + new_scene) + instance.data["publishRenderMetadataFolder"] = metadata_folder + + self.log.info("Scene name was switched {} -> {}".format( + orig_scene, new_scene + )) + + return file_path From ca552a701816b2d0fec6c1ee282fbb89f79b5db9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Jan 2023 19:13:28 +0100 Subject: [PATCH 023/144] :construction: redoing publishing flow for multiple rr roots --- .../maya/plugins/create/create_render.py | 89 +++++++++++++++---- .../maya/plugins/publish/collect_render.py | 7 ++ openpype/modules/royalrender/api.py | 30 +------ .../publish/collect_default_rr_path.py | 23 ----- .../publish/collect_rr_path_from_instance.py | 16 ++-- .../publish/collect_sequences_from_job.py | 2 +- .../publish/submit_maya_royalrender.py | 51 +++++++++-- .../project_settings/royalrender.json | 3 + .../defaults/system_settings/modules.json | 6 +- 9 files changed, 138 insertions(+), 89 deletions(-) delete mode 100644 openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 387b7321b9..337868d47d 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -80,31 +80,58 @@ class CreateRender(plugin.Creator): if self._project_settings["maya"]["RenderSettings"]["apply_render_settings"]: # noqa lib_rendersettings.RenderSettings().set_default_renderer_settings() - # Deadline-only + # Handling farms manager = ModulesManager() deadline_settings = get_system_settings()["modules"]["deadline"] - if not deadline_settings["enabled"]: - self.deadline_servers = {} + rr_settings = get_system_settings()["modules"]["royalrender"] + + self.deadline_servers = {} + self.rr_paths = {} + + if deadline_settings["enabled"]: + self.deadline_module = manager.modules_by_name["deadline"] + try: + default_servers = deadline_settings["deadline_urls"] + project_servers = ( + self._project_settings["deadline"]["deadline_servers"] + ) + self.deadline_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + if not self.deadline_servers: + self.deadline_servers = default_servers + + except AttributeError: + # Handle situation were we had only one url for deadline. + # get default deadline webservice url from deadline module + self.deadline_servers = self.deadline_module.deadline_urls + + # RoyalRender only + if not rr_settings["enabled"]: return - self.deadline_module = manager.modules_by_name["deadline"] + + self.rr_module = manager.modules_by_name["royalrender"] try: - default_servers = deadline_settings["deadline_urls"] - project_servers = ( - self._project_settings["deadline"]["deadline_servers"] + default_paths = rr_settings["rr_paths"] + project_paths = ( + self._project_settings["royalrender"]["rr_paths"] ) - self.deadline_servers = { - k: default_servers[k] - for k in project_servers - if k in default_servers + self.rr_paths = { + k: default_paths[k] + for k in project_paths + if k in default_paths } - if not self.deadline_servers: - self.deadline_servers = default_servers + if not self.rr_paths: + self.rr_paths = default_paths except AttributeError: - # Handle situation were we had only one url for deadline. - # get default deadline webservice url from deadline module - self.deadline_servers = self.deadline_module.deadline_urls + # Handle situation were we had only one path for royalrender. + # Get default royalrender root path from the rr module. + self.rr_paths = self.rr_module.rr_paths def process(self): """Entry point.""" @@ -140,6 +167,14 @@ class CreateRender(plugin.Creator): self._deadline_webservice_changed ]) + # add RoyalRender root path selection list + if self.rr_paths: + cmds.scriptJob( + attributeChange=[ + "{}.rrPaths".format(self.instance), + self._rr_path_changed + ]) + cmds.setAttr("{}.machineList".format(self.instance), lock=True) rs = renderSetup.instance() layers = rs.getRenderLayers() @@ -192,6 +227,18 @@ class CreateRender(plugin.Creator): attributeType="enum", enumName=":".join(sorted_pools)) + @staticmethod + def _rr_path_changed(): + """Unused callback to pull information from RR.""" + """ + _ = self.rr_paths[ + self.server_aliases[ + cmds.getAttr("{}.rrPaths".format(self.instance)) + ] + ] + """ + pass + def _create_render_settings(self): """Create instance settings.""" # get pools (slave machines of the render farm) @@ -226,15 +273,21 @@ class CreateRender(plugin.Creator): system_settings = get_system_settings()["modules"] deadline_enabled = system_settings["deadline"]["enabled"] + royalrender_enabled = system_settings["royalrender"]["enabled"] muster_enabled = system_settings["muster"]["enabled"] muster_url = system_settings["muster"]["MUSTER_REST_URL"] - if deadline_enabled and muster_enabled: + if deadline_enabled and muster_enabled and royalrender_enabled: self.log.error( - "Both Deadline and Muster are enabled. " "Cannot support both." + ("Multiple render farm support (Deadline/RoyalRender/Muster) " + "is enabled. We support only one at time.") ) raise RuntimeError("Both Deadline and Muster are enabled") + if royalrender_enabled: + self.server_aliases = list(self.rr_paths.keys()) + self.data["rrPaths"] = self.server_aliases + if deadline_enabled: self.server_aliases = list(self.deadline_servers.keys()) self.data["deadlineServers"] = self.server_aliases diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index 7c47f17acb..2fb55782d2 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -338,6 +338,13 @@ class CollectMayaRender(pyblish.api.ContextPlugin): if deadline_settings["enabled"]: data["deadlineUrl"] = render_instance.data.get("deadlineUrl") + rr_settings = ( + context.data["system_settings"]["modules"]["royalrender"] + ) + if rr_settings["enabled"]: + data["rrPathName"] = render_instance.data.get("rrPathName") + self.log.info(data["rrPathName"]) + if self.sync_workfile_version: data["version"] = context.data["version"] diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index c47d50b62b..dcb518deb1 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -15,36 +15,10 @@ class Api: RR_SUBMIT_CONSOLE = 1 RR_SUBMIT_API = 2 - def __init__(self, project=None): + def __init__(self, rr_path=None): self.log = Logger.get_logger("RoyalRender") - self._initialize_rr(project) - - def _initialize_rr(self, project=None): - # type: (str) -> None - """Initialize RR Path. - - Args: - project (str, Optional): Project name to set RR api in - context. - - """ - if project: - project_settings = get_project_settings(project) - rr_path = ( - project_settings - ["royalrender"] - ["rr_paths"] - ) - else: - rr_path = ( - self._settings - ["modules"] - ["royalrender"] - ["rr_path"] - ["default"] - ) - os.environ["RR_ROOT"] = rr_path self._rr_path = rr_path + os.environ["RR_ROOT"] = rr_path def _get_rr_bin_path(self, rr_root=None): # type: (str) -> str diff --git a/openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py b/openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py deleted file mode 100644 index 3ce95e0c50..0000000000 --- a/openpype/modules/royalrender/plugins/publish/collect_default_rr_path.py +++ /dev/null @@ -1,23 +0,0 @@ -# -*- coding: utf-8 -*- -"""Collect default Deadline server.""" -import pyblish.api - - -class CollectDefaultRRPath(pyblish.api.ContextPlugin): - """Collect default Royal Render path.""" - - order = pyblish.api.CollectorOrder - label = "Default Royal Render Path" - - def process(self, context): - try: - rr_module = context.data.get( - "openPypeModules")["royalrender"] - except AttributeError: - msg = "Cannot get OpenPype Royal Render module." - self.log.error(msg) - raise AssertionError(msg) - - # get default deadline webservice url from deadline module - self.log.debug(rr_module.rr_paths) - context.data["defaultRRPath"] = rr_module.rr_paths["default"] # noqa: E501 diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index 6a3dc276f3..187e2b9c44 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -5,19 +5,19 @@ import pyblish.api class CollectRRPathFromInstance(pyblish.api.InstancePlugin): """Collect RR Path from instance.""" - order = pyblish.api.CollectorOrder + 0.01 - label = "Royal Render Path from the Instance" + order = pyblish.api.CollectorOrder + label = "Collect Royal Render path name from the Instance" families = ["rendering"] def process(self, instance): - instance.data["rrPath"] = self._collect_rr_path(instance) + instance.data["rrPathName"] = self._collect_rr_path_name(instance) self.log.info( - "Using {} for submission.".format(instance.data["rrPath"])) + "Using '{}' for submission.".format(instance.data["rrPathName"])) @staticmethod - def _collect_rr_path(render_instance): + def _collect_rr_path_name(render_instance): # type: (pyblish.api.Instance) -> str - """Get Royal Render path from render instance.""" + """Get Royal Render pat name from render instance.""" rr_settings = ( render_instance.context.data ["system_settings"] @@ -42,8 +42,6 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): # Handle situation were we had only one url for royal render. return render_instance.context.data["defaultRRPath"] - return rr_servers[ - list(rr_servers.keys())[ + return list(rr_servers.keys())[ int(render_instance.data.get("rrPaths")) ] - ] diff --git a/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py b/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py index 65af90e8a6..4c123e4134 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py +++ b/openpype/modules/royalrender/plugins/publish/collect_sequences_from_job.py @@ -71,7 +71,7 @@ class CollectSequencesFromJob(pyblish.api.ContextPlugin): """Gather file sequences from job directory. When "OPENPYPE_PUBLISH_DATA" environment variable is set these paths - (folders or .json files) are parsed for image sequences. Otherwise the + (folders or .json files) are parsed for image sequences. Otherwise, the current working directory is searched for file sequences. """ diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py index c354cc80a0..784e4c5ff9 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py @@ -3,9 +3,10 @@ import os import sys import tempfile +import platform from maya.OpenMaya import MGlobal # noqa -from pyblish.api import InstancePlugin, IntegratorOrder +from pyblish.api import InstancePlugin, IntegratorOrder, Context from openpype.hosts.maya.api.lib import get_attr_in_layer from openpype.pipeline.farm.tools import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError @@ -16,6 +17,8 @@ from openpype.modules.royalrender.rr_job import RRJob, SubmitterParameter class MayaSubmitRoyalRender(InstancePlugin): label = "Submit to RoyalRender" order = IntegratorOrder + 0.1 + families = ["renderlayer"] + targets = ["local"] use_published = True def __init__(self, *args, **kwargs): @@ -102,7 +105,16 @@ class MayaSubmitRoyalRender(InstancePlugin): """Plugin entry point.""" self._instance = instance context = instance.context - self.rr_api = rr_api(context.data["project"]) + from pprint import pformat + + self._rr_root = self._resolve_rr_path(context, instance.data.get("rrPathName")) # noqa + self.log.debug(self._rr_root) + if not self._rr_root: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + + self.rr_api = rr_api(self._rr_root) # get royalrender module """ @@ -114,11 +126,7 @@ class MayaSubmitRoyalRender(InstancePlugin): raise AssertionError("OpenPype RoyalRender module not found.") """ - self._rrRoot = instance.data["rrPath"] or context.data["defaultRRPath"] # noqa - if not self._rrRoot: - raise KnownPublishError( - ("Missing RoyalRender root. " - "You need to configure RoyalRender module.")) + file_path = None if self.use_published: file_path = get_published_workfile_instance() @@ -147,4 +155,33 @@ class MayaSubmitRoyalRender(InstancePlugin): self.rr_api.submit_file(file=xml) + @staticmethod + def _resolve_rr_path(context, rr_path_name): + # type: (Context, str) -> str + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except (AttributeError, KeyError): + # Handle situation were we had only one url for royal render. + return context.data["defaultRRPath"][platform.system().lower()] + + return rr_servers[rr_path_name][platform.system().lower()] + diff --git a/openpype/settings/defaults/project_settings/royalrender.json b/openpype/settings/defaults/project_settings/royalrender.json index b72fed8474..14e36058aa 100644 --- a/openpype/settings/defaults/project_settings/royalrender.json +++ b/openpype/settings/defaults/project_settings/royalrender.json @@ -1,4 +1,7 @@ { + "rr_paths": [ + "default" + ], "publish": { "CollectSequencesFromJob": { "review": true diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 1ddbfd2726..f524f01d45 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -185,9 +185,9 @@ "enabled": false, "rr_paths": { "default": { - "windows": "", - "darwin": "", - "linux": "" + "windows": "C:\\RR8", + "darwin": "/Volumes/share/RR8", + "linux": "/mnt/studio/RR8" } } }, From 7e4b3cb3af9041c6e43d329410469fc4dbae1632 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 11 Jan 2023 19:13:54 +0100 Subject: [PATCH 024/144] :recycle: optimizing enum classes --- openpype/settings/entities/__init__.py | 4 +- openpype/settings/entities/enum_entity.py | 87 ++++++++++--------- .../schema_project_royalrender.json | 6 ++ 3 files changed, 56 insertions(+), 41 deletions(-) diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 5e3a76094e..00db2b33a7 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -107,7 +107,8 @@ from .enum_entity import ( TaskTypeEnumEntity, DeadlineUrlEnumEntity, AnatomyTemplatesEnumEntity, - ShotgridUrlEnumEntity + ShotgridUrlEnumEntity, + RoyalRenderRootEnumEntity ) from .list_entity import ListEntity @@ -170,6 +171,7 @@ __all__ = ( "TaskTypeEnumEntity", "DeadlineUrlEnumEntity", "ShotgridUrlEnumEntity", + "RoyalRenderRootEnumEntity", "AnatomyTemplatesEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index c0c103ea10..9f9ae93026 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -1,3 +1,5 @@ +import abc +import six import copy from .input_entities import InputEntity from .exceptions import EntitySchemaError @@ -476,8 +478,9 @@ class TaskTypeEnumEntity(BaseEnumEntity): self.set(value_on_not_set) -class DeadlineUrlEnumEntity(BaseEnumEntity): - schema_types = ["deadline_url-enum"] +@six.add_metaclass(abc.ABCMeta) +class FarmRootEnumEntity(BaseEnumEntity): + schema_types = [] def _item_initialization(self): self.multiselection = self.schema_data.get("multiselection", True) @@ -495,22 +498,8 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): # GUI attribute self.placeholder = self.schema_data.get("placeholder") - def _get_enum_values(self): - deadline_urls_entity = self.get_entity_from_path( - "system_settings/modules/deadline/deadline_urls" - ) - - valid_keys = set() - enum_items_list = [] - for server_name, url_entity in deadline_urls_entity.items(): - enum_items_list.append( - {server_name: "{}: {}".format(server_name, url_entity.value)} - ) - valid_keys.add(server_name) - return enum_items_list, valid_keys - def set_override_state(self, *args, **kwargs): - super(DeadlineUrlEnumEntity, self).set_override_state(*args, **kwargs) + super(FarmRootEnumEntity, self).set_override_state(*args, **kwargs) self.enum_items, self.valid_keys = self._get_enum_values() if self.multiselection: @@ -527,22 +516,50 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): elif self._current_value not in self.valid_keys: self._current_value = tuple(self.valid_keys)[0] + @abc.abstractmethod + def _get_enum_values(self): + pass -class ShotgridUrlEnumEntity(BaseEnumEntity): + +class DeadlineUrlEnumEntity(FarmRootEnumEntity): + schema_types = ["deadline_url-enum"] + + def _get_enum_values(self): + deadline_urls_entity = self.get_entity_from_path( + "system_settings/modules/deadline/deadline_urls" + ) + + valid_keys = set() + enum_items_list = [] + for server_name, url_entity in deadline_urls_entity.items(): + enum_items_list.append( + {server_name: "{}: {}".format(server_name, url_entity.value)} + ) + valid_keys.add(server_name) + return enum_items_list, valid_keys + + +class RoyalRenderRootEnumEntity(FarmRootEnumEntity): + schema_types = ["rr_root-enum"] + + def _get_enum_values(self): + rr_root_entity = self.get_entity_from_path( + "system_settings/modules/royalrender/rr_paths" + ) + + valid_keys = set() + enum_items_list = [] + for server_name, url_entity in rr_root_entity.items(): + enum_items_list.append( + {server_name: "{}: {}".format(server_name, url_entity.value)} + ) + valid_keys.add(server_name) + return enum_items_list, valid_keys + + +class ShotgridUrlEnumEntity(FarmRootEnumEntity): schema_types = ["shotgrid_url-enum"] - def _item_initialization(self): - self.multiselection = False - - self.enum_items = [] - self.valid_keys = set() - - self.valid_value_types = (STRING_TYPE,) - self.value_on_not_set = "" - - # GUI attribute - self.placeholder = self.schema_data.get("placeholder") - def _get_enum_values(self): shotgrid_settings = self.get_entity_from_path( "system_settings/modules/shotgrid/shotgrid_settings" @@ -561,16 +578,6 @@ class ShotgridUrlEnumEntity(BaseEnumEntity): valid_keys.add(server_name) return enum_items_list, valid_keys - def set_override_state(self, *args, **kwargs): - super(ShotgridUrlEnumEntity, self).set_override_state(*args, **kwargs) - - self.enum_items, self.valid_keys = self._get_enum_values() - if not self.valid_keys: - self._current_value = "" - - elif self._current_value not in self.valid_keys: - self._current_value = tuple(self.valid_keys)[0] - class AnatomyTemplatesEnumEntity(BaseEnumEntity): schema_types = ["anatomy-templates-enum"] diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json index cabb4747d5..f4bf2f51ba 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_royalrender.json @@ -5,6 +5,12 @@ "collapsible": true, "is_file": true, "children": [ + { + "type": "rr_root-enum", + "key": "rr_paths", + "label": "Royal Render Roots", + "multiselect": true + }, { "type": "dict", "collapsible": true, From 7d212c051b34ad505dae4132903bc4b2f94998fd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 16 Jan 2023 10:40:05 +0100 Subject: [PATCH 025/144] :construction: render job submission --- openpype/modules/royalrender/api.py | 76 +++++++++++-------- .../publish/submit_maya_royalrender.py | 45 ++++++----- openpype/modules/royalrender/rr_job.py | 10 ++- 3 files changed, 77 insertions(+), 54 deletions(-) diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index dcb518deb1..8b13b9781f 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -2,11 +2,13 @@ """Wrapper around Royal Render API.""" import sys import os +import platform from openpype.settings import get_project_settings from openpype.lib.local_settings import OpenPypeSettingsRegistry from openpype.lib import Logger, run_subprocess from .rr_job import RRJob, SubmitFile, SubmitterParameter +from openpype.lib.vendor_bin_utils import find_tool_in_custom_paths class Api: @@ -20,31 +22,46 @@ class Api: self._rr_path = rr_path os.environ["RR_ROOT"] = rr_path - def _get_rr_bin_path(self, rr_root=None): - # type: (str) -> str - """Get path to RR bin folder.""" + def _get_rr_bin_path(self, tool_name=None, rr_root=None): + # type: (str, str) -> str + """Get path to RR bin folder. + + Args: + tool_name (str): Name of RR executable you want. + rr_root (str, Optional): Custom RR root if needed. + + Returns: + str: Path to the tool based on current platform. + + """ rr_root = rr_root or self._rr_path is_64bit_python = sys.maxsize > 2 ** 32 - rr_bin_path = "" + rr_bin_parts = [rr_root, "bin"] if sys.platform.lower() == "win32": - rr_bin_path = "/bin/win64" - if not is_64bit_python: - # we are using 64bit python - rr_bin_path = "/bin/win" - rr_bin_path = rr_bin_path.replace( - "/", os.path.sep - ) + rr_bin_parts.append("win") if sys.platform.lower() == "darwin": - rr_bin_path = "/bin/mac64" - if not is_64bit_python: - rr_bin_path = "/bin/mac" + rr_bin_parts.append("mac") - if sys.platform.lower() == "linux": - rr_bin_path = "/bin/lx64" + if sys.platform.lower().startswith("linux"): + rr_bin_parts.append("lx") - return os.path.join(rr_root, rr_bin_path) + rr_bin_path = os.sep.join(rr_bin_parts) + + paths_to_check = [] + # if we use 64bit python, append 64bit specific path first + if is_64bit_python: + if not tool_name: + return rr_bin_path + "64" + paths_to_check.append(rr_bin_path + "64") + + # otherwise use 32bit + if not tool_name: + return rr_bin_path + paths_to_check.append(rr_bin_path) + + return find_tool_in_custom_paths(paths_to_check, tool_name) def _initialize_module_path(self): # type: () -> None @@ -84,30 +101,25 @@ class Api: # type: (SubmitFile, int) -> None if mode == self.RR_SUBMIT_CONSOLE: self._submit_using_console(file) + return - # RR v7 supports only Python 2.7 so we bail out in fear + # RR v7 supports only Python 2.7, so we bail out in fear # until there is support for Python 3 😰 raise NotImplementedError( "Submission via RoyalRender API is not supported yet") # self._submit_using_api(file) - def _submit_using_console(self, file): + def _submit_using_console(self, job_file): # type: (SubmitFile) -> None - rr_console = os.path.join( - self._get_rr_bin_path(), - "rrSubmitterConsole" - ) + rr_start_local = self._get_rr_bin_path("rrStartLocal") - if sys.platform.lower() == "darwin" and "/bin/mac64" in rr_console: - rr_console = rr_console.replace("/bin/mac64", "/bin/mac") + self.log.info("rr_console: {}".format(rr_start_local)) - if sys.platform.lower() == "win32": - if "/bin/win64" in rr_console: - rr_console = rr_console.replace("/bin/win64", "/bin/win") - rr_console += ".exe" - - args = [rr_console, file] - run_subprocess(" ".join(args), logger=self.log) + args = [rr_start_local, "rrSubmitterconsole", job_file] + self.log.info("Executing: {}".format(" ".join(args))) + env = os.environ + env["RR_ROOT"] = self._rr_path + run_subprocess(args, logger=self.log, env=env) def _submit_using_api(self, file): # type: (SubmitFile) -> None diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py index 784e4c5ff9..82236386b7 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py @@ -38,11 +38,11 @@ class MayaSubmitRoyalRender(InstancePlugin): """ def get_rr_platform(): if sys.platform.lower() in ["win32", "win64"]: - return "win" + return "windows" elif sys.platform.lower() == "darwin": return "mac" else: - return "lx" + return "linux" expected_files = self._instance.data["expectedFiles"] first_file = next(self._iter_expected_files(expected_files)) @@ -53,8 +53,10 @@ class MayaSubmitRoyalRender(InstancePlugin): .get('maya') \ .get('RenderSettings') \ .get('default_render_image_folder') - filename = os.path.basename(self.scene_path) - dirname = os.path.join(workspace, default_render_file) + file_name = os.path.basename(self.scene_path) + dir_name = os.path.join(workspace, default_render_file) + layer = self._instance.data["setMembers"] # type: str + layer_name = layer.removeprefix("rs_") job = RRJob( Software="Maya", @@ -64,22 +66,22 @@ class MayaSubmitRoyalRender(InstancePlugin): SeqStep=int(self._instance.data["byFrameStep"]), SeqFileOffset=0, Version="{0:.2f}".format(MGlobal.apiVersion() / 10000), - SceneName=os.path.basename(self.scene_path), + SceneName=self.scene_path, IsActive=True, - ImageDir=dirname, - ImageFilename=filename, - ImageExtension="." + os.path.splitext(filename)[1], + ImageDir=dir_name, + ImageFilename="{}.".format(layer_name), + ImageExtension=os.path.splitext(first_file)[1], ImagePreNumberLetter=".", ImageSingleOutputFile=False, SceneOS=get_rr_platform(), Camera=self._instance.data["cameras"][0], - Layer=self._instance.data["layer"], + Layer=layer_name, SceneDatabaseDir=workspace, - ImageFramePadding=get_attr_in_layer( - "defaultRenderGlobals.extensionPadding", - self._instance.data["layer"]), + CustomSHotName=self._instance.context.data["asset"], + CompanyProjectName=self._instance.context.data["projectName"], ImageWidth=self._instance.data["resolutionWidth"], - ImageHeight=self._instance.data["resolutionHeight"] + ImageHeight=self._instance.data["resolutionHeight"], + PreID=1 ) return job @@ -125,11 +127,9 @@ class MayaSubmitRoyalRender(InstancePlugin): self.log.error("Cannot get OpenPype RoyalRender module.") raise AssertionError("OpenPype RoyalRender module not found.") """ - - file_path = None if self.use_published: - file_path = get_published_workfile_instance() + file_path = get_published_workfile_instance(context) # fallback if nothing was set if not file_path: @@ -153,7 +153,8 @@ class MayaSubmitRoyalRender(InstancePlugin): with open(xml.name, "w") as f: f.write(submission.serialize()) - self.rr_api.submit_file(file=xml) + self.log.info("submitting job file: {}".format(xml.name)) + self.rr_api.submit_file(file=xml.name) @staticmethod def _resolve_rr_path(context, rr_path_name): @@ -184,4 +185,12 @@ class MayaSubmitRoyalRender(InstancePlugin): return rr_servers[rr_path_name][platform.system().lower()] - + @staticmethod + def _iter_expected_files(exp): + if isinstance(exp[0], dict): + for _aov, files in exp[0].items(): + for file in files: + yield file + else: + for file in exp: + yield file diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index beb8c17187..f5c7033b62 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -87,7 +87,7 @@ class RRJob: # Frame Padding of the frame number in the rendered filename. # Some render config files are setting the padding at render time. - ImageFramePadding = attr.ib(default=None) # type: str + ImageFramePadding = attr.ib(default=None) # type: int # Some render applications support overriding the image format at # the render commandline. @@ -129,6 +129,7 @@ class RRJob: CustomUserInfo = attr.ib(default=None) # type: str SubmitMachine = attr.ib(default=None) # type: str Color_ID = attr.ib(default=2) # type: int + CompanyProjectName = attr.ib(default=None) # type: str RequiredLicenses = attr.ib(default=None) # type: str @@ -225,7 +226,7 @@ class SubmitFile: # foo=bar~baz~goo self._process_submitter_parameters( self.SubmitterParameters, root, job_file) - + root.appendChild(job_file) for job in self.Jobs: # type: RRJob if not isinstance(job, RRJob): raise AttributeError( @@ -247,10 +248,11 @@ class SubmitFile: custom_attr.name)] = custom_attr.value for item, value in serialized_job.items(): - xml_attr = root.create(item) + xml_attr = root.createElement(item) xml_attr.appendChild( - root.createTextNode(value) + root.createTextNode(str(value)) ) xml_job.appendChild(xml_attr) + job_file.appendChild(xml_job) return root.toprettyxml(indent="\t") From 7ab4e0f77362e36226acd6e340c1c924c4c4773c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:06:05 +0100 Subject: [PATCH 026/144] :construction: refactor RR job flow --- .../publish/collect_rr_path_from_instance.py | 12 +- .../publish/create_maya_royalrender_job.py | 150 ++++++++++++++ .../publish/create_publish_royalrender_job.py | 178 ++++++++++++++++ .../publish/submit_jobs_to_royalrender.py | 115 ++++++++++ .../publish/submit_maya_royalrender.py | 196 ------------------ openpype/pipeline/farm/pyblish.py | 49 +++++ 6 files changed, 499 insertions(+), 201 deletions(-) create mode 100644 openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py create mode 100644 openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py create mode 100644 openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py create mode 100644 openpype/pipeline/farm/pyblish.py diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index 187e2b9c44..40f34561fa 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -15,19 +15,21 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): "Using '{}' for submission.".format(instance.data["rrPathName"])) @staticmethod - def _collect_rr_path_name(render_instance): + def _collect_rr_path_name(instance): # type: (pyblish.api.Instance) -> str """Get Royal Render pat name from render instance.""" rr_settings = ( - render_instance.context.data + instance.context.data ["system_settings"] ["modules"] ["royalrender"] ) + if not instance.data.get("rrPaths"): + return "default" try: default_servers = rr_settings["rr_paths"] project_servers = ( - render_instance.context.data + instance.context.data ["project_settings"] ["royalrender"] ["rr_paths"] @@ -40,8 +42,8 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): except (AttributeError, KeyError): # Handle situation were we had only one url for royal render. - return render_instance.context.data["defaultRRPath"] + return rr_settings["rr_paths"]["default"] return list(rr_servers.keys())[ - int(render_instance.data.get("rrPaths")) + int(instance.data.get("rrPaths")) ] diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py new file mode 100644 index 0000000000..e194a7edc6 --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to RoyalRender.""" +import os +import sys +import platform + +from maya.OpenMaya import MGlobal # noqa +from pyblish.api import InstancePlugin, IntegratorOrder, Context +from openpype.pipeline.publish.lib import get_published_workfile_instance +from openpype.pipeline.publish import KnownPublishError +from openpype.modules.royalrender.api import Api as rrApi +from openpype.modules.royalrender.rr_job import RRJob + + +class CreateMayaRoyalRenderJob(InstancePlugin): + label = "Create Maya Render job in RR" + order = IntegratorOrder + 0.1 + families = ["renderlayer"] + targets = ["local"] + use_published = True + + def __init__(self, *args, **kwargs): + self._instance = None + self._rrRoot = None + self.scene_path = None + self.job = None + self.submission_parameters = None + self.rr_api = None + + def get_job(self): + """Prepare job payload. + + Returns: + RRJob: RoyalRender job payload. + + """ + def get_rr_platform(): + if sys.platform.lower() in ["win32", "win64"]: + return "windows" + elif sys.platform.lower() == "darwin": + return "mac" + else: + return "linux" + + expected_files = self._instance.data["expectedFiles"] + first_file = next(self._iter_expected_files(expected_files)) + output_dir = os.path.dirname(first_file) + self._instance.data["outputDir"] = output_dir + workspace = self._instance.context.data["workspaceDir"] + default_render_file = self._instance.context.data.get('project_settings') \ + .get('maya') \ + .get('RenderSettings') \ + .get('default_render_image_folder') + file_name = os.path.basename(self.scene_path) + dir_name = os.path.join(workspace, default_render_file) + layer = self._instance.data["setMembers"] # type: str + layer_name = layer.removeprefix("rs_") + + job = RRJob( + Software="Maya", + Renderer=self._instance.data["renderer"], + SeqStart=int(self._instance.data["frameStartHandle"]), + SeqEnd=int(self._instance.data["frameEndHandle"]), + SeqStep=int(self._instance.data["byFrameStep"]), + SeqFileOffset=0, + Version="{0:.2f}".format(MGlobal.apiVersion() / 10000), + SceneName=self.scene_path, + IsActive=True, + ImageDir=dir_name, + ImageFilename="{}.".format(layer_name), + ImageExtension=os.path.splitext(first_file)[1], + ImagePreNumberLetter=".", + ImageSingleOutputFile=False, + SceneOS=get_rr_platform(), + Camera=self._instance.data["cameras"][0], + Layer=layer_name, + SceneDatabaseDir=workspace, + CustomSHotName=self._instance.context.data["asset"], + CompanyProjectName=self._instance.context.data["projectName"], + ImageWidth=self._instance.data["resolutionWidth"], + ImageHeight=self._instance.data["resolutionHeight"], + ) + return job + + def process(self, instance): + """Plugin entry point.""" + self._instance = instance + context = instance.context + from pprint import pformat + + self._rr_root = self._resolve_rr_path(context, instance.data.get("rrPathName")) # noqa + self.log.debug(self._rr_root) + if not self._rr_root: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + + self.rr_api = rrApi(self._rr_root) + + file_path = None + if self.use_published: + file_path = get_published_workfile_instance(context) + + # fallback if nothing was set + if not file_path: + self.log.warning("Falling back to workfile") + file_path = context.data["currentFile"] + + self.scene_path = file_path + + self._instance.data["rrJobs"] = [self.get_job()] + + @staticmethod + def _iter_expected_files(exp): + if isinstance(exp[0], dict): + for _aov, files in exp[0].items(): + for file in files: + yield file + else: + for file in exp: + yield file + + @staticmethod + def _resolve_rr_path(context, rr_path_name): + # type: (Context, str) -> str + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except (AttributeError, KeyError): + # Handle situation were we had only one url for royal render. + return context.data["defaultRRPath"][platform.system().lower()] + + return rr_servers[rr_path_name][platform.system().lower()] diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py new file mode 100644 index 0000000000..9d8cc602c5 --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- +"""Create publishing job on RoyalRender.""" +from pyblish.api import InstancePlugin, IntegratorOrder +from copy import deepcopy +from openpype.pipeline import legacy_io +import requests +import os + + +class CreatePublishRoyalRenderJob(InstancePlugin): + label = "Create publish job in RR" + order = IntegratorOrder + 0.2 + icon = "tractor" + targets = ["local"] + hosts = ["fusion", "maya", "nuke", "celaction", "aftereffects", "harmony"] + families = ["render.farm", "prerender.farm", + "renderlayer", "imagesequence", "vrayscene"] + aov_filter = {"maya": [r".*([Bb]eauty).*"], + "aftereffects": [r".*"], # for everything from AE + "harmony": [r".*"], # for everything from AE + "celaction": [r".*"]} + + def process(self, instance): + data = instance.data.copy() + context = instance.context + self.context = context + self.anatomy = instance.context.data["anatomy"] + + asset = data.get("asset") + subset = data.get("subset") + source = self._remap_source( + data.get("source") or context.data["source"]) + + + + def _remap_source(self, source): + success, rootless_path = ( + self.anatomy.find_root_template_from_path(source) + ) + if success: + source = rootless_path + else: + # `rootless_path` is not set to `source` if none of roots match + self.log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues." + ).format(source)) + return source + + def _submit_post_job(self, instance, job, instances): + """Submit publish job to RoyalRender.""" + data = instance.data.copy() + subset = data["subset"] + job_name = "Publish - {subset}".format(subset=subset) + + # instance.data.get("subset") != instances[0]["subset"] + # 'Main' vs 'renderMain' + override_version = None + instance_version = instance.data.get("version") # take this if exists + if instance_version != 1: + override_version = instance_version + output_dir = self._get_publish_folder( + instance.context.data['anatomy'], + deepcopy(instance.data["anatomyData"]), + instance.data.get("asset"), + instances[0]["subset"], + 'render', + override_version + ) + + # Transfer the environment from the original job to this dependent + # job, so they use the same environment + metadata_path, roothless_metadata_path = \ + self._create_metadata_path(instance) + + environment = { + "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], + "AVALON_ASSET": legacy_io.Session["AVALON_ASSET"], + "AVALON_TASK": legacy_io.Session["AVALON_TASK"], + "OPENPYPE_USERNAME": instance.context.data["user"], + "OPENPYPE_PUBLISH_JOB": "1", + "OPENPYPE_RENDER_JOB": "0", + "OPENPYPE_REMOTE_JOB": "0", + "OPENPYPE_LOG_NO_COLORS": "1" + } + + # add environments from self.environ_keys + for env_key in self.environ_keys: + if os.getenv(env_key): + environment[env_key] = os.environ[env_key] + + # pass environment keys from self.environ_job_filter + job_environ = job["Props"].get("Env", {}) + for env_j_key in self.environ_job_filter: + if job_environ.get(env_j_key): + environment[env_j_key] = job_environ[env_j_key] + + # Add mongo url if it's enabled + if instance.context.data.get("deadlinePassMongoUrl"): + mongo_url = os.environ.get("OPENPYPE_MONGO") + if mongo_url: + environment["OPENPYPE_MONGO"] = mongo_url + + priority = self.deadline_priority or instance.data.get("priority", 50) + + args = [ + "--headless", + 'publish', + roothless_metadata_path, + "--targets", "deadline", + "--targets", "farm" + ] + + # Generate the payload for Deadline submission + payload = { + "JobInfo": { + "Plugin": self.deadline_plugin, + "BatchName": job["Props"]["Batch"], + "Name": job_name, + "UserName": job["Props"]["User"], + "Comment": instance.context.data.get("comment", ""), + + "Department": self.deadline_department, + "ChunkSize": self.deadline_chunk_size, + "Priority": priority, + + "Group": self.deadline_group, + "Pool": instance.data.get("primaryPool"), + "SecondaryPool": instance.data.get("secondaryPool"), + + "OutputDirectory0": output_dir + }, + "PluginInfo": { + "Version": self.plugin_pype_version, + "Arguments": " ".join(args), + "SingleFrameOnly": "True", + }, + # Mandatory for Deadline, may be empty + "AuxFiles": [], + } + + # add assembly jobs as dependencies + if instance.data.get("tileRendering"): + self.log.info("Adding tile assembly jobs as dependencies...") + job_index = 0 + for assembly_id in instance.data.get("assemblySubmissionJobs"): + payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 + job_index += 1 + elif instance.data.get("bakingSubmissionJobs"): + self.log.info("Adding baking submission jobs as dependencies...") + job_index = 0 + for assembly_id in instance.data["bakingSubmissionJobs"]: + payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 + job_index += 1 + else: + payload["JobInfo"]["JobDependency0"] = job["_id"] + + if instance.data.get("suspend_publish"): + payload["JobInfo"]["InitialStatus"] = "Suspended" + + for index, (key_, value_) in enumerate(environment.items()): + payload["JobInfo"].update( + { + "EnvironmentKeyValue%d" + % index: "{key}={value}".format( + key=key_, value=value_ + ) + } + ) + # remove secondary pool + payload["JobInfo"].pop("SecondaryPool", None) + + self.log.info("Submitting Deadline job ...") + + url = "{}/api/jobs".format(self.deadline_url) + response = requests.post(url, json=payload, timeout=10) + if not response.ok: + raise Exception(response.text) \ No newline at end of file diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py new file mode 100644 index 0000000000..4fcf3a08bd --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +"""Submit jobs to RoyalRender.""" +import tempfile +import platform + +from pyblish.api import IntegratorOrder, ContextPlugin, Context +from openpype.modules.royalrender.api import RRJob, Api as rrApi +from openpype.pipeline.publish import KnownPublishError + + +class SubmitJobsToRoyalRender(ContextPlugin): + """Find all jobs, create submission XML and submit it to RoyalRender.""" + label = "Submit jobs to RoyalRender" + order = IntegratorOrder + 0.1 + targets = ["local"] + + def __init__(self): + super(SubmitJobsToRoyalRender, self).__init__() + self._rr_root = None + self._rr_api = None + self._submission_parameters = [] + + def process(self, context): + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + + if rr_settings["enabled"] is not True: + self.log.warning("RoyalRender modules is disabled.") + return + + # iterate over all instances and try to find RRJobs + jobs = [] + for instance in context: + if isinstance(instance.data.get("rrJob"), RRJob): + jobs.append(instance.data.get("rrJob")) + if instance.data.get("rrJobs"): + if all(isinstance(job, RRJob) for job in instance.data.get("rrJobs")): + jobs += instance.data.get("rrJobs") + + if jobs: + self._rr_root = self._resolve_rr_path( + context, instance.data.get("rrPathName")) # noqa + if not self._rr_root: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + self._rr_api = rrApi(self._rr_root) + self._submission_parameters = self.get_submission_parameters() + self.process_submission(jobs) + return + + self.log.info("No RoyalRender jobs found") + + def process_submission(self, jobs): + # type: ([RRJob]) -> None + submission = rrApi.create_submission( + jobs, + self._submission_parameters) + + xml = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) + with open(xml.name, "w") as f: + f.write(submission.serialize()) + + self.log.info("submitting job(s) file: {}".format(xml.name)) + self._rr_api.submit_file(file=xml.name) + + def create_file(self, name, ext, contents=None): + temp = tempfile.NamedTemporaryFile( + dir=self.tempdir, + suffix=ext, + prefix=name + '.', + delete=False, + ) + + if contents: + with open(temp.name, 'w') as f: + f.write(contents) + + return temp.name + + def get_submission_parameters(self): + return [] + + @staticmethod + def _resolve_rr_path(context, rr_path_name): + # type: (Context, str) -> str + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except (AttributeError, KeyError): + # Handle situation were we had only one url for royal render. + return context.data["defaultRRPath"][platform.system().lower()] + + return rr_servers[rr_path_name][platform.system().lower()] diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py index 82236386b7..e69de29bb2 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py @@ -1,196 +0,0 @@ -# -*- coding: utf-8 -*- -"""Submitting render job to RoyalRender.""" -import os -import sys -import tempfile -import platform - -from maya.OpenMaya import MGlobal # noqa -from pyblish.api import InstancePlugin, IntegratorOrder, Context -from openpype.hosts.maya.api.lib import get_attr_in_layer -from openpype.pipeline.farm.tools import get_published_workfile_instance -from openpype.pipeline.publish import KnownPublishError -from openpype.modules.royalrender.api import Api as rr_api -from openpype.modules.royalrender.rr_job import RRJob, SubmitterParameter - - -class MayaSubmitRoyalRender(InstancePlugin): - label = "Submit to RoyalRender" - order = IntegratorOrder + 0.1 - families = ["renderlayer"] - targets = ["local"] - use_published = True - - def __init__(self, *args, **kwargs): - self._instance = None - self._rrRoot = None - self.scene_path = None - self.job = None - self.submission_parameters = None - self.rr_api = None - - def get_job(self): - """Prepare job payload. - - Returns: - RRJob: RoyalRender job payload. - - """ - def get_rr_platform(): - if sys.platform.lower() in ["win32", "win64"]: - return "windows" - elif sys.platform.lower() == "darwin": - return "mac" - else: - return "linux" - - expected_files = self._instance.data["expectedFiles"] - first_file = next(self._iter_expected_files(expected_files)) - output_dir = os.path.dirname(first_file) - self._instance.data["outputDir"] = output_dir - workspace = self._instance.context.data["workspaceDir"] - default_render_file = self._instance.context.data.get('project_settings') \ - .get('maya') \ - .get('RenderSettings') \ - .get('default_render_image_folder') - file_name = os.path.basename(self.scene_path) - dir_name = os.path.join(workspace, default_render_file) - layer = self._instance.data["setMembers"] # type: str - layer_name = layer.removeprefix("rs_") - - job = RRJob( - Software="Maya", - Renderer=self._instance.data["renderer"], - SeqStart=int(self._instance.data["frameStartHandle"]), - SeqEnd=int(self._instance.data["frameEndHandle"]), - SeqStep=int(self._instance.data["byFrameStep"]), - SeqFileOffset=0, - Version="{0:.2f}".format(MGlobal.apiVersion() / 10000), - SceneName=self.scene_path, - IsActive=True, - ImageDir=dir_name, - ImageFilename="{}.".format(layer_name), - ImageExtension=os.path.splitext(first_file)[1], - ImagePreNumberLetter=".", - ImageSingleOutputFile=False, - SceneOS=get_rr_platform(), - Camera=self._instance.data["cameras"][0], - Layer=layer_name, - SceneDatabaseDir=workspace, - CustomSHotName=self._instance.context.data["asset"], - CompanyProjectName=self._instance.context.data["projectName"], - ImageWidth=self._instance.data["resolutionWidth"], - ImageHeight=self._instance.data["resolutionHeight"], - PreID=1 - ) - return job - - @staticmethod - def get_submission_parameters(): - return [] - - def create_file(self, name, ext, contents=None): - temp = tempfile.NamedTemporaryFile( - dir=self.tempdir, - suffix=ext, - prefix=name + '.', - delete=False, - ) - - if contents: - with open(temp.name, 'w') as f: - f.write(contents) - - return temp.name - - def process(self, instance): - """Plugin entry point.""" - self._instance = instance - context = instance.context - from pprint import pformat - - self._rr_root = self._resolve_rr_path(context, instance.data.get("rrPathName")) # noqa - self.log.debug(self._rr_root) - if not self._rr_root: - raise KnownPublishError( - ("Missing RoyalRender root. " - "You need to configure RoyalRender module.")) - - self.rr_api = rr_api(self._rr_root) - - # get royalrender module - """ - try: - rr_module = context.data.get( - "openPypeModules")["royalrender"] - except AttributeError: - self.log.error("Cannot get OpenPype RoyalRender module.") - raise AssertionError("OpenPype RoyalRender module not found.") - """ - file_path = None - if self.use_published: - file_path = get_published_workfile_instance(context) - - # fallback if nothing was set - if not file_path: - self.log.warning("Falling back to workfile") - file_path = context.data["currentFile"] - - self.scene_path = file_path - self.job = self.get_job() - self.log.info(self.job) - self.submission_parameters = self.get_submission_parameters() - - self.process_submission() - - def process_submission(self): - submission = rr_api.create_submission( - [self.job], - self.submission_parameters) - - self.log.debug(submission) - xml = tempfile.NamedTemporaryFile(suffix=".xml", delete=False) - with open(xml.name, "w") as f: - f.write(submission.serialize()) - - self.log.info("submitting job file: {}".format(xml.name)) - self.rr_api.submit_file(file=xml.name) - - @staticmethod - def _resolve_rr_path(context, rr_path_name): - # type: (Context, str) -> str - rr_settings = ( - context.data - ["system_settings"] - ["modules"] - ["royalrender"] - ) - try: - default_servers = rr_settings["rr_paths"] - project_servers = ( - context.data - ["project_settings"] - ["royalrender"] - ["rr_paths"] - ) - rr_servers = { - k: default_servers[k] - for k in project_servers - if k in default_servers - } - - except (AttributeError, KeyError): - # Handle situation were we had only one url for royal render. - return context.data["defaultRRPath"][platform.system().lower()] - - return rr_servers[rr_path_name][platform.system().lower()] - - @staticmethod - def _iter_expected_files(exp): - if isinstance(exp[0], dict): - for _aov, files in exp[0].items(): - for file in files: - yield file - else: - for file in exp: - yield file diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py new file mode 100644 index 0000000000..02535f4090 --- /dev/null +++ b/openpype/pipeline/farm/pyblish.py @@ -0,0 +1,49 @@ +from openpype.lib import Logger +import attr + + +@attr.s +class InstanceSkeleton(object): + family = attr.ib(factory=) + +def remap_source(source, anatomy): + success, rootless_path = ( + anatomy.find_root_template_from_path(source) + ) + if success: + source = rootless_path + else: + # `rootless_path` is not set to `source` if none of roots match + log = Logger.get_logger("farm_publishing") + log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues." + ).format(source)) + return source + +def get_skeleton_instance() + instance_skeleton_data = { + "family": family, + "subset": subset, + "families": families, + "asset": asset, + "frameStart": start, + "frameEnd": end, + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStartHandle": start - handle_start, + "frameEndHandle": end + handle_end, + "comment": instance.data["comment"], + "fps": fps, + "source": source, + "extendFrames": data.get("extendFrames"), + "overrideExistingFrame": data.get("overrideExistingFrame"), + "pixelAspect": data.get("pixelAspect", 1), + "resolutionWidth": data.get("resolutionWidth", 1920), + "resolutionHeight": data.get("resolutionHeight", 1080), + "multipartExr": data.get("multipartExr", False), + "jobBatchName": data.get("jobBatchName", ""), + "useSequenceForReview": data.get("useSequenceForReview", True), + # map inputVersions `ObjectId` -> `str` so json supports it + "inputVersions": list(map(str, data.get("inputVersions", []))) + } \ No newline at end of file From e49cbf34f64260bd3e9960338d84a7be8589af9e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:06:53 +0100 Subject: [PATCH 027/144] :bug: fix default for ShotGrid this is fixing default value for ShotGrid server enumerator after code refactor done in this branch --- openpype/settings/defaults/project_settings/shotgrid.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/shotgrid.json b/openpype/settings/defaults/project_settings/shotgrid.json index 83b6f69074..0dcffed28a 100644 --- a/openpype/settings/defaults/project_settings/shotgrid.json +++ b/openpype/settings/defaults/project_settings/shotgrid.json @@ -1,6 +1,6 @@ { "shotgrid_project_id": 0, - "shotgrid_server": "", + "shotgrid_server": [], "event": { "enabled": false }, From 8ebef523bc2689b3b56fe5bbd947fdfec4285eb9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:19:18 +0100 Subject: [PATCH 028/144] :rotating_light: hound fixes --- .../hosts/maya/plugins/create/create_render.py | 2 +- openpype/modules/royalrender/api.py | 2 -- .../publish/collect_rr_path_from_instance.py | 4 +--- .../plugins/publish/create_maya_royalrender_job.py | 14 ++++++++------ .../publish/create_publish_royalrender_job.py | 12 +++++------- .../plugins/publish/submit_jobs_to_royalrender.py | 4 +++- openpype/pipeline/farm/pyblish.py | 12 +++++++++--- 7 files changed, 27 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 337868d47d..71d42cc82f 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -279,7 +279,7 @@ class CreateRender(plugin.Creator): if deadline_enabled and muster_enabled and royalrender_enabled: self.log.error( - ("Multiple render farm support (Deadline/RoyalRender/Muster) " + ("Multiple render farm support (Deadline/RoyalRender/Muster) " "is enabled. We support only one at time.") ) raise RuntimeError("Both Deadline and Muster are enabled") diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index 8b13b9781f..86d27ccc6c 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -2,9 +2,7 @@ """Wrapper around Royal Render API.""" import sys import os -import platform -from openpype.settings import get_project_settings from openpype.lib.local_settings import OpenPypeSettingsRegistry from openpype.lib import Logger, run_subprocess from .rr_job import RRJob, SubmitFile, SubmitterParameter diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index 40f34561fa..cfb5b78077 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -44,6 +44,4 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): # Handle situation were we had only one url for royal render. return rr_settings["rr_paths"]["default"] - return list(rr_servers.keys())[ - int(instance.data.get("rrPaths")) - ] + return list(rr_servers.keys())[int(instance.data.get("rrPaths"))] diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index e194a7edc6..5f427353ac 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -47,11 +47,14 @@ class CreateMayaRoyalRenderJob(InstancePlugin): output_dir = os.path.dirname(first_file) self._instance.data["outputDir"] = output_dir workspace = self._instance.context.data["workspaceDir"] - default_render_file = self._instance.context.data.get('project_settings') \ - .get('maya') \ - .get('RenderSettings') \ - .get('default_render_image_folder') - file_name = os.path.basename(self.scene_path) + default_render_file = ( + self._instance.context.data + ['project_settings'] + ['maya'] + ['RenderSettings'] + ['default_render_image_folder'] + ) + # file_name = os.path.basename(self.scene_path) dir_name = os.path.join(workspace, default_render_file) layer = self._instance.data["setMembers"] # type: str layer_name = layer.removeprefix("rs_") @@ -86,7 +89,6 @@ class CreateMayaRoyalRenderJob(InstancePlugin): """Plugin entry point.""" self._instance = instance context = instance.context - from pprint import pformat self._rr_root = self._resolve_rr_path(context, instance.data.get("rrPathName")) # noqa self.log.debug(self._rr_root) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 9d8cc602c5..e62289641b 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -26,12 +26,10 @@ class CreatePublishRoyalRenderJob(InstancePlugin): self.context = context self.anatomy = instance.context.data["anatomy"] - asset = data.get("asset") - subset = data.get("subset") - source = self._remap_source( - data.get("source") or context.data["source"]) - - + # asset = data.get("asset") + # subset = data.get("subset") + # source = self._remap_source( + # data.get("source") or context.data["source"]) def _remap_source(self, source): success, rootless_path = ( @@ -175,4 +173,4 @@ class CreatePublishRoyalRenderJob(InstancePlugin): url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) if not response.ok: - raise Exception(response.text) \ No newline at end of file + raise Exception(response.text) diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py index 4fcf3a08bd..325fb36993 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -38,7 +38,9 @@ class SubmitJobsToRoyalRender(ContextPlugin): if isinstance(instance.data.get("rrJob"), RRJob): jobs.append(instance.data.get("rrJob")) if instance.data.get("rrJobs"): - if all(isinstance(job, RRJob) for job in instance.data.get("rrJobs")): + if all( + isinstance(job, RRJob) + for job in instance.data.get("rrJobs")): jobs += instance.data.get("rrJobs") if jobs: diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py index 02535f4090..15f4356b86 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish.py @@ -4,7 +4,9 @@ import attr @attr.s class InstanceSkeleton(object): - family = attr.ib(factory=) + # family = attr.ib(factory=) + pass + def remap_source(source, anatomy): success, rootless_path = ( @@ -21,7 +23,9 @@ def remap_source(source, anatomy): ).format(source)) return source -def get_skeleton_instance() + +def get_skeleton_instance(): + """ instance_skeleton_data = { "family": family, "subset": subset, @@ -46,4 +50,6 @@ def get_skeleton_instance() "useSequenceForReview": data.get("useSequenceForReview", True), # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))) - } \ No newline at end of file + } + """ + pass From 5dda835a0dba359c70512e7c21a36548eb4072a0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 18 Jan 2023 17:21:28 +0100 Subject: [PATCH 029/144] :rotating_light: hound fixes 2 --- .../plugins/publish/create_publish_royalrender_job.py | 2 +- openpype/pipeline/farm/pyblish.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index e62289641b..65de600bfa 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -21,7 +21,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "celaction": [r".*"]} def process(self, instance): - data = instance.data.copy() + # data = instance.data.copy() context = instance.context self.context = context self.anatomy = instance.context.data["anatomy"] diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py index 15f4356b86..436ad7f195 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish.py @@ -17,10 +17,9 @@ def remap_source(source, anatomy): else: # `rootless_path` is not set to `source` if none of roots match log = Logger.get_logger("farm_publishing") - log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues." - ).format(source)) + log.warning( + ("Could not find root path for remapping \"{}\"." + " This may cause issues.").format(source)) return source From cc8732aa9d05476fb3162be176b16edacf833b0f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 7 Feb 2023 19:22:54 +0100 Subject: [PATCH 030/144] :construction: work on publish job --- openpype/modules/royalrender/api.py | 11 +- .../publish/create_maya_royalrender_job.py | 1 - .../publish/create_publish_royalrender_job.py | 102 ++++++++---------- openpype/modules/royalrender/rr_job.py | 46 +++++++- 4 files changed, 94 insertions(+), 66 deletions(-) diff --git a/openpype/modules/royalrender/api.py b/openpype/modules/royalrender/api.py index 86d27ccc6c..e610a0c8a8 100644 --- a/openpype/modules/royalrender/api.py +++ b/openpype/modules/royalrender/api.py @@ -20,19 +20,19 @@ class Api: self._rr_path = rr_path os.environ["RR_ROOT"] = rr_path - def _get_rr_bin_path(self, tool_name=None, rr_root=None): + @staticmethod + def get_rr_bin_path(rr_root, tool_name=None): # type: (str, str) -> str """Get path to RR bin folder. Args: tool_name (str): Name of RR executable you want. - rr_root (str, Optional): Custom RR root if needed. + rr_root (str): Custom RR root if needed. Returns: str: Path to the tool based on current platform. """ - rr_root = rr_root or self._rr_path is_64bit_python = sys.maxsize > 2 ** 32 rr_bin_parts = [rr_root, "bin"] @@ -65,7 +65,7 @@ class Api: # type: () -> None """Set RR modules for Python.""" # default for linux - rr_bin = self._get_rr_bin_path() + rr_bin = self.get_rr_bin_path(self._rr_path) rr_module_path = os.path.join(rr_bin, "lx64/lib") if sys.platform.lower() == "win32": @@ -109,7 +109,8 @@ class Api: def _submit_using_console(self, job_file): # type: (SubmitFile) -> None - rr_start_local = self._get_rr_bin_path("rrStartLocal") + rr_start_local = self.get_rr_bin_path( + self._rr_path, "rrStartLocal") self.log.info("rr_console: {}".format(rr_start_local)) diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 5f427353ac..0b257d8b7a 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -32,7 +32,6 @@ class CreateMayaRoyalRenderJob(InstancePlugin): Returns: RRJob: RoyalRender job payload. - """ def get_rr_platform(): if sys.platform.lower() in ["win32", "win64"]: diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 65de600bfa..f87ee589b6 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -6,6 +6,10 @@ from openpype.pipeline import legacy_io import requests import os +from openpype.modules.royalrender.rr_job import RRJob, RREnvList +from openpype.pipeline.publish import KnownPublishError +from openpype.modules.royalrender.api import Api as rrApi + class CreatePublishRoyalRenderJob(InstancePlugin): label = "Create publish job in RR" @@ -45,14 +49,12 @@ class CreatePublishRoyalRenderJob(InstancePlugin): ).format(source)) return source - def _submit_post_job(self, instance, job, instances): + def get_job(self, instance, job, instances): """Submit publish job to RoyalRender.""" data = instance.data.copy() subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) - # instance.data.get("subset") != instances[0]["subset"] - # 'Main' vs 'renderMain' override_version = None instance_version = instance.data.get("version") # take this if exists if instance_version != 1: @@ -62,6 +64,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): deepcopy(instance.data["anatomyData"]), instance.data.get("asset"), instances[0]["subset"], + # TODO: this shouldn't be hardcoded and is in fact settable by + # Settings. 'render', override_version ) @@ -71,7 +75,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): metadata_path, roothless_metadata_path = \ self._create_metadata_path(instance) - environment = { + environment = RREnvList({ "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], "AVALON_ASSET": legacy_io.Session["AVALON_ASSET"], "AVALON_TASK": legacy_io.Session["AVALON_TASK"], @@ -80,7 +84,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "OPENPYPE_RENDER_JOB": "0", "OPENPYPE_REMOTE_JOB": "0", "OPENPYPE_LOG_NO_COLORS": "1" - } + }) # add environments from self.environ_keys for env_key in self.environ_keys: @@ -88,7 +92,16 @@ class CreatePublishRoyalRenderJob(InstancePlugin): environment[env_key] = os.environ[env_key] # pass environment keys from self.environ_job_filter - job_environ = job["Props"].get("Env", {}) + # and collect all pre_ids to wait for + job_environ = {} + jobs_pre_ids = [] + for job in instance["rrJobs"]: # type: RRJob + if job.rrEnvList: + job_environ.update( + dict(RREnvList.parse(job.rrEnvList)) + ) + jobs_pre_ids.append(job.PreID) + for env_j_key in self.environ_job_filter: if job_environ.get(env_j_key): environment[env_j_key] = job_environ[env_j_key] @@ -99,7 +112,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): if mongo_url: environment["OPENPYPE_MONGO"] = mongo_url - priority = self.deadline_priority or instance.data.get("priority", 50) + priority = self.priority or instance.data.get("priority", 50) args = [ "--headless", @@ -109,66 +122,37 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "--targets", "farm" ] - # Generate the payload for Deadline submission - payload = { - "JobInfo": { - "Plugin": self.deadline_plugin, - "BatchName": job["Props"]["Batch"], - "Name": job_name, - "UserName": job["Props"]["User"], - "Comment": instance.context.data.get("comment", ""), - - "Department": self.deadline_department, - "ChunkSize": self.deadline_chunk_size, - "Priority": priority, - - "Group": self.deadline_group, - "Pool": instance.data.get("primaryPool"), - "SecondaryPool": instance.data.get("secondaryPool"), - - "OutputDirectory0": output_dir - }, - "PluginInfo": { - "Version": self.plugin_pype_version, - "Arguments": " ".join(args), - "SingleFrameOnly": "True", - }, - # Mandatory for Deadline, may be empty - "AuxFiles": [], - } + job = RRJob( + Software="Execute", + Renderer="Once", + # path to OpenPype + SeqStart=1, + SeqEnd=1, + SeqStep=1, + SeqFileOffset=0, + Version="1.0", + SceneName="", + IsActive=True, + ImageFilename="execOnce.file", + ImageDir="", + ImageExtension="", + ImagePreNumberLetter="", + SceneOS=RRJob.get_rr_platform(), + rrEnvList=environment.serialize(), + Priority=priority + ) # add assembly jobs as dependencies if instance.data.get("tileRendering"): self.log.info("Adding tile assembly jobs as dependencies...") - job_index = 0 - for assembly_id in instance.data.get("assemblySubmissionJobs"): - payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 - job_index += 1 + job.WaitForPreIDs += instance.data.get("assemblySubmissionJobs") elif instance.data.get("bakingSubmissionJobs"): self.log.info("Adding baking submission jobs as dependencies...") - job_index = 0 - for assembly_id in instance.data["bakingSubmissionJobs"]: - payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 - job_index += 1 + job.WaitForPreIDs += instance.data["bakingSubmissionJobs"] else: - payload["JobInfo"]["JobDependency0"] = job["_id"] + job.WaitForPreIDs += jobs_pre_ids - if instance.data.get("suspend_publish"): - payload["JobInfo"]["InitialStatus"] = "Suspended" - - for index, (key_, value_) in enumerate(environment.items()): - payload["JobInfo"].update( - { - "EnvironmentKeyValue%d" - % index: "{key}={value}".format( - key=key_, value=value_ - ) - } - ) - # remove secondary pool - payload["JobInfo"].pop("SecondaryPool", None) - - self.log.info("Submitting Deadline job ...") + self.log.info("Creating RoyalRender Publish job ...") url = "{}/api/jobs".format(self.deadline_url) response = requests.post(url, json=payload, timeout=10) diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index f5c7033b62..21e5291bc3 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- """Python wrapper for RoyalRender XML job file.""" +import sys from xml.dom import minidom as md import attr from collections import namedtuple, OrderedDict @@ -8,6 +9,23 @@ from collections import namedtuple, OrderedDict CustomAttribute = namedtuple("CustomAttribute", ["name", "value"]) +class RREnvList(dict): + def serialize(self): + # VariableA=ValueA~~~VariableB=ValueB + return "~~~".join( + ["{}={}".format(k, v) for k, v in sorted(self.items())]) + + @staticmethod + def parse(data): + # type: (str) -> RREnvList + """Parse rrEnvList string and return it as RREnvList object.""" + out = RREnvList() + for var in data.split("~~~"): + k, v = data.split("=") + out[k] = v + return out + + @attr.s class RRJob: """Mapping of Royal Render job file to a data class.""" @@ -108,7 +126,7 @@ class RRJob: # jobs send from this machine. If a job with the PreID was found, then # this jobs waits for the other job. Note: This flag can be used multiple # times to wait for multiple jobs. - WaitForPreID = attr.ib(default=None) # type: int + WaitForPreIDs = attr.ib(factory=list) # type: list # List of submitter options per job # list item must be of `SubmitterParameter` type @@ -138,6 +156,21 @@ class RRJob: TotalFrames = attr.ib(default=None) # type: int Tiled = attr.ib(default=None) # type: str + # Environment + # only used in RR 8.3 and newer + rrEnvList = attr.ib(default=None) # type: str + + @staticmethod + def get_rr_platform(): + # type: () -> str + """Returns name of platform used in rr jobs.""" + if sys.platform.lower() in ["win32", "win64"]: + return "windows" + elif sys.platform.lower() == "darwin": + return "mac" + else: + return "linux" + class SubmitterParameter: """Wrapper for Submitter Parameters.""" @@ -242,6 +275,8 @@ class SubmitFile: job, dict_factory=OrderedDict, filter=filter_data) serialized_job.pop("CustomAttributes") serialized_job.pop("SubmitterParameters") + # we are handling `WaitForPreIDs` separately. + wait_pre_ids = serialized_job.pop("WaitForPreIDs", []) for custom_attr in job_custom_attributes: # type: CustomAttribute serialized_job["Custom{}".format( @@ -253,6 +288,15 @@ class SubmitFile: root.createTextNode(str(value)) ) xml_job.appendChild(xml_attr) + + # WaitForPreID - can be used multiple times + for pre_id in wait_pre_ids: + xml_attr = root.createElement("WaitForPreID") + xml_attr.appendChild( + root.createTextNode(str(pre_id)) + ) + xml_job.appendChild(xml_attr) + job_file.appendChild(xml_job) return root.toprettyxml(indent="\t") From 5595712e810f4a6c22480f8a8fc054d426c49856 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 23 Mar 2023 16:09:25 +0100 Subject: [PATCH 031/144] :art: add cmd line arguments attribute to job --- .../plugins/publish/create_publish_royalrender_job.py | 3 +++ openpype/modules/royalrender/rr_job.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index f87ee589b6..167a97cbe4 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -131,7 +131,10 @@ class CreatePublishRoyalRenderJob(InstancePlugin): SeqStep=1, SeqFileOffset=0, Version="1.0", + # executable SceneName="", + # command line arguments + CustomAddCmdFlags=" ".join(args), IsActive=True, ImageFilename="execOnce.file", ImageDir="", diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index 21e5291bc3..689a488a5c 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -138,6 +138,9 @@ class RRJob: # list item must be of `CustomAttribute` named tuple CustomAttributes = attr.ib(factory=list) # type: list + # This is used to hold command line arguments for Execute job + CustomAddCmdFlags = attr.ib(default=None) # type: str + # Additional information for subsequent publish script and # for better display in rrControl UserName = attr.ib(default=None) # type: str From 4e14e0b56b1ad2021cd057a3cc5f939a7f334f6e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 24 Mar 2023 15:41:13 +0100 Subject: [PATCH 032/144] :art: add render configuration --- .../render_apps/_config/E01__OpenPype.png | Bin 0 -> 2075 bytes .../_config/E01__OpenPype__PublishJob.cfg | 4 ++++ .../_config/E01__OpenPype___global.inc | 2 ++ .../render_apps/_install_paths/OpenPype.cfg | 12 ++++++++++++ .../_prepost_scripts/OpenPypeEnvironment.cfg | 11 +++++++++++ .../PreOpenPypeInjectEnvironments.py | 4 ++++ 6 files changed, 33 insertions(+) create mode 100644 openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype.png create mode 100644 openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg create mode 100644 openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype___global.inc create mode 100644 openpype/modules/royalrender/rr_root/render_apps/_install_paths/OpenPype.cfg create mode 100644 openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/OpenPypeEnvironment.cfg create mode 100644 openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/PreOpenPypeInjectEnvironments.py diff --git a/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype.png b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype.png new file mode 100644 index 0000000000000000000000000000000000000000..68c5aec1174c9ee4b0f4ec21fe0aea8c2ac48545 GIT binary patch literal 2075 zcmah~c~BE)6pvFuK|w{7O6zo8IviqlmmpYH0z$$i1Y-yqnOY6W29_Kfb{9wxMNwL$ z9u>uds#Qlv1;wK}B9=0O6to~k1rHDhhkBrRAXXW~_S=9^ulA2k_WORn_j~Vq?|pAq zOQXZ=Y@Ka+Jf2-dxL5}M?YU>9HTeA^oZrCX4gZ;xD_Dgj3Rh8jM5(6Y3B;r~0-VS5 z4>TE-DlNf6@k9b?5WBb_y)#>ljzqF6O4)`jVwtSAWl;m zPo=Oz7zQ{rAkLDSA$0>YaD0#mltWN21VjH#Crso==p6DM-Iz$R6q8692M_R;i$VB3 zLy*1?o0ycw50NF3|EBBpZykeSLsCH^29o+#Om7@8a@WduW|&?+J%l`ya_mP~MY z!6Wp_1R{zsB(RA>*lYSbz0TcVGBNtammJ| zq>0ce#H5}uFhF-Ojv;WtM?ev!qv#mW*aR@LI2)`4Zowp!8bIFfKoKI5l%_PK4q%Kd zEEtLi5&3%g`TFt&NWTmy8xCwqjajT@0ZV`hy!n_nU*IkG^2G%{xDV!udEpplQMRzb zZBQ#&<^S=yo`(E^g+zjflMGAIX3JK8qsI`*{j2_^jf= z6PrK!UiAJJ!oO}Qudd z_Lr)XZa@SyX$#-n*n$1A$%mg}_zveWB(9KzrkPMfc} zw62ZLftGz8?5n~8(sw71$hE3`8Q{C-w#~JjZByshEfQvJnA0#Swr=Uj zi~N$3;-q83l9q3a-Fyl|BJ(6I1yL2flTIMBdim4Q{3Y|Q-|Zd;|JW5f<7g&tDcySd z)u`UGgp(Z}TV{jrg^e+M0mI`VF5ENBv@eh=P*#LENbbI2Z9|#%veL2YY5lXvOV$Ox zHylSq?h6_#m@Z4+xX$^BRaKTK#J$urXV>dbWR5Los;c0xQjBAVI|cobEB&&m;ebdV zm;LLxt**#iZJcaOOIs^b?(hF%_{@XyHB%Zpn%Z3-bb2<}OJ@zEu=Ck+nv%6P_>rK;E zRmRW7(tpU1h*pyt;Ye)y`YJnE`$3lNlM%?C8LSy?JHjT^H$;$?7~|ac7;! zUhBYR&4(5ud#!)!vx=-*TEZMCdVHa?Hb=MTYIc3S)!E8E)rIFNG5O}l+FRe3c+5Ec zY23CQLF4Y5az2-6YGXa&_Kkh5^S1Qqd>2pHf_+xaD~9c!Ybt+VdnEKsZ&mP~WYwPN(dhL@)b>RjtOnMl*vYE|tG dhq}yr)u@6>KAYxurHcEeM}$O+i-Q+u{R76+2nzrJ literal 0 HcmV?d00001 diff --git a/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg new file mode 100644 index 0000000000..3f0f8285fd --- /dev/null +++ b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg @@ -0,0 +1,4 @@ +rendererName= PublishJob + +::include(E01__OpenPype____global.inc) +::include(E01__OpenPype____global__inhouse.inc) diff --git a/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype___global.inc b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype___global.inc new file mode 100644 index 0000000000..ba38337340 --- /dev/null +++ b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype___global.inc @@ -0,0 +1,2 @@ +IconApp= E01__OpenPype.png +Name= OpenPype diff --git a/openpype/modules/royalrender/rr_root/render_apps/_install_paths/OpenPype.cfg b/openpype/modules/royalrender/rr_root/render_apps/_install_paths/OpenPype.cfg new file mode 100644 index 0000000000..07f7547d29 --- /dev/null +++ b/openpype/modules/royalrender/rr_root/render_apps/_install_paths/OpenPype.cfg @@ -0,0 +1,12 @@ +[Windows] +Executable= openpype_console.exe +Path= OS; \OpenPype\*\openpype_console.exe +Path= 32; \OpenPype\*\openpype_console.exe + +[Linux] +Executable= openpype_console +Path= OS; /opt/openpype/*/openpype_console + +[Mac] +Executable= openpype_console +Path= OS; /Applications/OpenPype*/Content/MacOS/openpype_console diff --git a/openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/OpenPypeEnvironment.cfg b/openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/OpenPypeEnvironment.cfg new file mode 100644 index 0000000000..70f0bc2e24 --- /dev/null +++ b/openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/OpenPypeEnvironment.cfg @@ -0,0 +1,11 @@ +PrePostType= pre +CommandLine= + +CommandLine= rrPythonconsole" > "render_apps/_prepost_scripts/PreOpenPypeInjectEnvironments.py" + +CommandLine= + + +CommandLine= "" +CommandLine= + diff --git a/openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/PreOpenPypeInjectEnvironments.py b/openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/PreOpenPypeInjectEnvironments.py new file mode 100644 index 0000000000..891de9594c --- /dev/null +++ b/openpype/modules/royalrender/rr_root/render_apps/_prepost_scripts/PreOpenPypeInjectEnvironments.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +import os + +os.environ["OPENYPYPE_TESTVAR"] = "OpenPype was here" From f328c9848948eac8d9d8897c4bbdafb3b8981bde Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 24 Mar 2023 15:42:06 +0100 Subject: [PATCH 033/144] :heavy_minus_sign: remove old plugin --- .../royalrender/plugins/publish/submit_maya_royalrender.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py diff --git a/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_maya_royalrender.py deleted file mode 100644 index e69de29bb2..0000000000 From de5bca48fa0f54b71783832bf16d521f6ce6a792 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 24 Mar 2023 15:49:27 +0100 Subject: [PATCH 034/144] :art: add openpype version --- .../plugins/publish/create_maya_royalrender_job.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 0b257d8b7a..8b6beba031 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -6,10 +6,11 @@ import platform from maya.OpenMaya import MGlobal # noqa from pyblish.api import InstancePlugin, IntegratorOrder, Context +from openpype.lib import is_running_from_build from openpype.pipeline.publish.lib import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError from openpype.modules.royalrender.api import Api as rrApi -from openpype.modules.royalrender.rr_job import RRJob +from openpype.modules.royalrender.rr_job import RRJob, CustomAttribute class CreateMayaRoyalRenderJob(InstancePlugin): @@ -58,6 +59,14 @@ class CreateMayaRoyalRenderJob(InstancePlugin): layer = self._instance.data["setMembers"] # type: str layer_name = layer.removeprefix("rs_") + custom_attributes = [] + if is_running_from_build(): + custom_attributes = [ + CustomAttribute( + name="OpenPypeVersion", + value=os.environ.get("OPENPYPE_VERSION")) + ] + job = RRJob( Software="Maya", Renderer=self._instance.data["renderer"], @@ -81,7 +90,9 @@ class CreateMayaRoyalRenderJob(InstancePlugin): CompanyProjectName=self._instance.context.data["projectName"], ImageWidth=self._instance.data["resolutionWidth"], ImageHeight=self._instance.data["resolutionHeight"], + CustomAttributes=custom_attributes ) + return job def process(self, instance): From 226039f3c5fb753a935af66aefa378d797200701 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 24 Mar 2023 18:30:49 +0100 Subject: [PATCH 035/144] :recycle: handling of jobs --- .../publish/create_maya_royalrender_job.py | 5 ++++- .../publish/create_publish_royalrender_job.py | 16 +++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 8b6beba031..6e23fb0b74 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -120,7 +120,10 @@ class CreateMayaRoyalRenderJob(InstancePlugin): self.scene_path = file_path - self._instance.data["rrJobs"] = [self.get_job()] + if not self._instance.data.get("rrJobs"): + self._instance.data["rrJobs"] = [] + + self._instance.data["rrJobs"] += self.get_job() @staticmethod def _iter_expected_files(exp): diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 167a97cbe4..03d176a324 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -123,16 +123,16 @@ class CreatePublishRoyalRenderJob(InstancePlugin): ] job = RRJob( - Software="Execute", + Software="OpenPype", Renderer="Once", # path to OpenPype SeqStart=1, SeqEnd=1, SeqStep=1, SeqFileOffset=0, - Version="1.0", + Version=os.environ.get("OPENPYPE_VERSION"), # executable - SceneName="", + SceneName=roothless_metadata_path, # command line arguments CustomAddCmdFlags=" ".join(args), IsActive=True, @@ -157,7 +157,9 @@ class CreatePublishRoyalRenderJob(InstancePlugin): self.log.info("Creating RoyalRender Publish job ...") - url = "{}/api/jobs".format(self.deadline_url) - response = requests.post(url, json=payload, timeout=10) - if not response.ok: - raise Exception(response.text) + if not instance.data.get("rrJobs"): + self.log.error("There is no RoyalRender job on the instance.") + raise KnownPublishError( + "Can't create publish job without producing jobs") + + instance.data["rrJobs"] += job From 03b3d2f37654e0b514568e99ea48e0422c82111b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 24 Mar 2023 18:31:22 +0100 Subject: [PATCH 036/144] :art: better publish job render config --- .../_config/E01__OpenPype__PublishJob.cfg | 73 ++++++++++++++++++- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg index 3f0f8285fd..6414ae45a8 100644 --- a/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg +++ b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg @@ -1,4 +1,71 @@ -rendererName= PublishJob +IconApp= E01__OpenPype.png +Name= OpenPype +rendererName= Once +Version= 1 +Version_Minor= 0 +Type=Execute +TYPEv9=Execute +ExecuteJobType=Once -::include(E01__OpenPype____global.inc) -::include(E01__OpenPype____global__inhouse.inc) + +################################# [Windows] [Linux] [Osx] ################################## + + +CommandLine=> + +CommandLine= + + +::win CommandLine= set "CUDA_VISIBLE_DEVICES=" +::lx CommandLine= setenv CUDA_VISIBLE_DEVICES +::osx CommandLine= setenv CUDA_VISIBLE_DEVICES + + +CommandLine= + + +CommandLine= + + +CommandLine= + + +CommandLine= "" --headless publish + --targets royalrender + --targets farm + + + +CommandLine= + + + + +################################## Render Settings ################################## + + + +################################## Submitter Settings ################################## +StartMultipleInstances= 0~0 +SceneFileExtension= *.json +AllowImageNameChange= 0 +AllowImageDirChange= 0 +SequenceDivide= 0~1 +PPSequenceCheck=0~0 +PPCreateSmallVideo=0~0 +PPCreateFullVideo=0~0 +AllowLocalRenderOut= 0~0 + + +################################## Client Settings ################################## + +IconApp=E01__OpenPype.png + +licenseFailLine= + +errorSearchLine= + +permanentErrorSearchLine = + +Frozen_MinCoreUsage=0.3 +Frozen_Minutes=30 From 8adb0534bf6cf36a3705b79aebc90a3894e23e63 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 29 Mar 2023 18:44:04 +0200 Subject: [PATCH 037/144] :recycle: handle OP versions better --- .../publish/create_publish_royalrender_job.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 03d176a324..9c74bdcea0 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- """Create publishing job on RoyalRender.""" -from pyblish.api import InstancePlugin, IntegratorOrder -from copy import deepcopy -from openpype.pipeline import legacy_io -import requests import os +from copy import deepcopy +from pyblish.api import InstancePlugin, IntegratorOrder + +from openpype.pipeline import legacy_io from openpype.modules.royalrender.rr_job import RRJob, RREnvList from openpype.pipeline.publish import KnownPublishError -from openpype.modules.royalrender.api import Api as rrApi +from openpype.lib.openpype_version import get_OpenPypeVersion, get_openpype_version class CreatePublishRoyalRenderJob(InstancePlugin): @@ -122,6 +122,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "--targets", "farm" ] + openpype_version = get_OpenPypeVersion() + current_version = openpype_version(version=get_openpype_version()) job = RRJob( Software="OpenPype", Renderer="Once", @@ -130,7 +132,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): SeqEnd=1, SeqStep=1, SeqFileOffset=0, - Version=os.environ.get("OPENPYPE_VERSION"), + Version="{}.{}".format( + current_version.major(), current_version.minor()), # executable SceneName=roothless_metadata_path, # command line arguments From 9a3fe48125d17280b7306a52005a20d1e30ed154 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Apr 2023 16:42:54 +0200 Subject: [PATCH 038/144] :art: add sop path validator --- .../publish/validate_bgeo_file_sop_path.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py diff --git a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py new file mode 100644 index 0000000000..f4016a1b09 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +"""Validator plugin for SOP Path in bgeo isntance.""" +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateNoSOPPath(pyblish.api.InstancePlugin): + """Validate if SOP Path in BGEO instance exists.""" + + order = pyblish.api.ValidatorOrder + families = ["bgeo"] + label = "Validate BGEO SOP Path" + + def process(self, instance): + + import hou + + node = hou.node(instance.data.get("instance_node")) + sop_path = node.evalParm("soppath") + if not sop_path: + raise PublishValidationError("Empty SOP Path found in " + "the bgeo isntance Geometry") + if not isinstance(hou.node(sop_path), hou.SopNode): + raise PublishValidationError( + "SOP path is not pointing to valid SOP node.") From 0d1870ff5a262c9afd28777c575971742f5b79c9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Apr 2023 16:44:19 +0200 Subject: [PATCH 039/144] :recycle: enhance sop node selection logic if selected node is sop node, it will be used for path, otherwise it will try to find output node with lowest index under selected node --- .../houdini/plugins/create/create_bgeo.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index 46fa47df92..468215d76d 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -38,13 +38,17 @@ class CreateBGEO(plugin.HoudiniCreator): } if self.selected_nodes: - parms["soppath"] = self.selected_nodes[0].path() - - # try to find output node - for child in self.selected_nodes[0].children(): - if child.type().name() == "output": - parms["soppath"] = child.path() - break + # if selection is on SOP level, use it + if isinstance(self.selected_nodes[0], hou.SopNode): + parms["soppath"] = self.selected_nodes[0].path() + else: + # try to find output node with the lowest index + outputs = [ + child for child in self.selected_nodes[0].children() + if child.type().name() == "output" + ] + outputs.sort(key=lambda output: output.evalParm("outputidx")) + parms["soppath"] = outputs[0].path() instance_node.setParms(parms) instance_node.parm("trange").set(1) From 791b04cbfade4c3f1a7c5c70db81b693c00cd4d9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Apr 2023 16:49:53 +0200 Subject: [PATCH 040/144] :memo: update docs --- website/docs/artist_hosts_houdini.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md index 4564e76a14..e2c1f71aa2 100644 --- a/website/docs/artist_hosts_houdini.md +++ b/website/docs/artist_hosts_houdini.md @@ -127,8 +127,7 @@ There is a simple support for publishing and loading **BGEO** files in all suppo ### Creating BGEO instances -Just select your object to be exported as BGEO. If there is `output` node inside, first found will be used as entry -point: +Just select your SOP node to be exported as BGEO. If your selection is object level, OpenPype will try to find there is `output` node inside, the one with the lowest index will be used: ![BGEO output node](assets/houdini_bgeo_output_node.png) From 547fe50ffca326937f5ed85352cda333d93a6db6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 13 Apr 2023 17:14:36 +0200 Subject: [PATCH 041/144] :rotating_light: fix hound :dog: --- .../plugins/publish/create_publish_royalrender_job.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 9c74bdcea0..a5493dd061 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -8,7 +8,8 @@ from pyblish.api import InstancePlugin, IntegratorOrder from openpype.pipeline import legacy_io from openpype.modules.royalrender.rr_job import RRJob, RREnvList from openpype.pipeline.publish import KnownPublishError -from openpype.lib.openpype_version import get_OpenPypeVersion, get_openpype_version +from openpype.lib.openpype_version import ( + get_OpenPypeVersion, get_openpype_version) class CreatePublishRoyalRenderJob(InstancePlugin): From 23f90ec355ff4d2ce58cad7d4185ec38fcd9efc0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 17 Apr 2023 12:24:42 +0200 Subject: [PATCH 042/144] :construction: wip on skeleton instance --- openpype/pipeline/farm/pyblish.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py index 436ad7f195..3ad62962d2 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish.py @@ -1,5 +1,6 @@ from openpype.lib import Logger import attr +import pyblish.api @attr.s @@ -23,10 +24,27 @@ def remap_source(source, anatomy): return source -def get_skeleton_instance(): +def create_skeleton_instance(instance): + # type: (pyblish.api.Instance) -> dict + """Create skelenton instance from original instance data. + + This will create dictionary containing skeleton + - common - data used for publishing rendered instances. + This skeleton instance is then extended with additional data + and serialized to be processed by farm job. + + Args: + instance (pyblish.api.Instance): Original instance to + be used as a source of data. + + Returns: + dict: Dictionary with skeleton instance data. + """ - instance_skeleton_data = { - "family": family, + context = instance.context + + return { + "family": "render" if "prerender" not in instance.data["families"] else "prerender", # noqa: E401 "subset": subset, "families": families, "asset": asset, @@ -50,5 +68,3 @@ def get_skeleton_instance(): # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))) } - """ - pass From c9b6179cc84aa8347f661bfcac9d7f4fae61dcaa Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 21 Apr 2023 02:53:06 +0200 Subject: [PATCH 043/144] :construction: move common code out from deadline --- .../plugins/publish/submit_publish_job.py | 492 +-------------- openpype/pipeline/farm/pyblish.py | 578 +++++++++++++++++- openpype/pipeline/farm/pyblish.pyi | 23 + 3 files changed, 607 insertions(+), 486 deletions(-) create mode 100644 openpype/pipeline/farm/pyblish.pyi diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 4765772bcf..fd0d87a082 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -21,35 +21,10 @@ from openpype.pipeline import ( from openpype.tests.lib import is_in_tests from openpype.pipeline.farm.patterning import match_aov_pattern from openpype.lib import is_running_from_build - - -def get_resources(project_name, version, extension=None): - """Get the files from the specific version.""" - - # TODO this functions seems to be weird - # - it's looking for representation with one extension or first (any) - # representation from a version? - # - not sure how this should work, maybe it does for specific use cases - # but probably can't be used for all resources from 2D workflows - extensions = None - if extension: - extensions = [extension] - repre_docs = list(get_representations( - project_name, version_ids=[version["_id"]], extensions=extensions - )) - assert repre_docs, "This is a bug" - - representation = repre_docs[0] - directory = get_representation_path(representation) - print("Source: ", directory) - resources = sorted( - [ - os.path.normpath(os.path.join(directory, fname)) - for fname in os.listdir(directory) - ] - ) - - return resources +from openpype.pipeline.farm.pyblish import ( + create_skeleton_instance, + create_instances_for_aov +) def get_resource_files(resources, frame_range=None): @@ -356,241 +331,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): return deadline_publish_job_id - def _copy_extend_frames(self, instance, representation): - """Copy existing frames from latest version. - - This will copy all existing frames from subset's latest version back - to render directory and rename them to what renderer is expecting. - - Arguments: - instance (pyblish.plugin.Instance): instance to get required - data from - representation (dict): presentation to operate on - - """ - import speedcopy - - self.log.info("Preparing to copy ...") - start = instance.data.get("frameStart") - end = instance.data.get("frameEnd") - project_name = legacy_io.active_project() - - # get latest version of subset - # this will stop if subset wasn't published yet - project_name = legacy_io.active_project() - version = get_last_version_by_subset_name( - project_name, - instance.data.get("subset"), - asset_name=instance.data.get("asset") - ) - - # get its files based on extension - subset_resources = get_resources( - project_name, version, representation.get("ext") - ) - r_col, _ = clique.assemble(subset_resources) - - # if override remove all frames we are expecting to be rendered - # so we'll copy only those missing from current render - if instance.data.get("overrideExistingFrame"): - for frame in range(start, end + 1): - if frame not in r_col.indexes: - continue - r_col.indexes.remove(frame) - - # now we need to translate published names from representation - # back. This is tricky, right now we'll just use same naming - # and only switch frame numbers - resource_files = [] - r_filename = os.path.basename( - representation.get("files")[0]) # first file - op = re.search(self.R_FRAME_NUMBER, r_filename) - pre = r_filename[:op.start("frame")] - post = r_filename[op.end("frame"):] - assert op is not None, "padding string wasn't found" - for frame in list(r_col): - fn = re.search(self.R_FRAME_NUMBER, frame) - # silencing linter as we need to compare to True, not to - # type - assert fn is not None, "padding string wasn't found" - # list of tuples (source, destination) - staging = representation.get("stagingDir") - staging = self.anatomy.fill_root(staging) - resource_files.append( - (frame, - os.path.join(staging, - "{}{}{}".format(pre, - fn.group("frame"), - post))) - ) - - # test if destination dir exists and create it if not - output_dir = os.path.dirname(representation.get("files")[0]) - if not os.path.isdir(output_dir): - os.makedirs(output_dir) - - # copy files - for source in resource_files: - speedcopy.copy(source[0], source[1]) - self.log.info(" > {}".format(source[1])) - - self.log.info( - "Finished copying %i files" % len(resource_files)) - - def _create_instances_for_aov( - self, instance_data, exp_files, additional_data - ): - """Create instance for each AOV found. - - This will create new instance for every aov it can detect in expected - files list. - - Arguments: - instance_data (pyblish.plugin.Instance): skeleton data for instance - (those needed) later by collector - exp_files (list): list of expected files divided by aovs - - Returns: - list of instances - - """ - task = os.environ["AVALON_TASK"] - subset = instance_data["subset"] - cameras = instance_data.get("cameras", []) - instances = [] - # go through aovs in expected files - for aov, files in exp_files[0].items(): - cols, rem = clique.assemble(files) - # we shouldn't have any reminders. And if we do, it should - # be just one item for single frame renders. - if not cols and rem: - assert len(rem) == 1, ("Found multiple non related files " - "to render, don't know what to do " - "with them.") - col = rem[0] - ext = os.path.splitext(col)[1].lstrip(".") - else: - # but we really expect only one collection. - # Nothing else make sense. - assert len(cols) == 1, "only one image sequence type is expected" # noqa: E501 - ext = cols[0].tail.lstrip(".") - col = list(cols[0]) - - self.log.debug(col) - # create subset name `familyTaskSubset_AOV` - group_name = 'render{}{}{}{}'.format( - task[0].upper(), task[1:], - subset[0].upper(), subset[1:]) - - cam = [c for c in cameras if c in col.head] - if cam: - if aov: - subset_name = '{}_{}_{}'.format(group_name, cam, aov) - else: - subset_name = '{}_{}'.format(group_name, cam) - else: - if aov: - subset_name = '{}_{}'.format(group_name, aov) - else: - subset_name = '{}'.format(group_name) - - if isinstance(col, (list, tuple)): - staging = os.path.dirname(col[0]) - else: - staging = os.path.dirname(col) - - success, rootless_staging_dir = ( - self.anatomy.find_root_template_from_path(staging) - ) - if success: - staging = rootless_staging_dir - else: - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(staging)) - - self.log.info("Creating data for: {}".format(subset_name)) - - app = os.environ.get("AVALON_APP", "") - - preview = False - - if isinstance(col, list): - render_file_name = os.path.basename(col[0]) - else: - render_file_name = os.path.basename(col) - aov_patterns = self.aov_filter - - preview = match_aov_pattern(app, aov_patterns, render_file_name) - # toggle preview on if multipart is on - - if instance_data.get("multipartExr"): - self.log.debug("Adding preview tag because its multipartExr") - preview = True - self.log.debug("preview:{}".format(preview)) - new_instance = deepcopy(instance_data) - new_instance["subset"] = subset_name - new_instance["subsetGroup"] = group_name - if preview: - new_instance["review"] = True - - # create representation - if isinstance(col, (list, tuple)): - files = [os.path.basename(f) for f in col] - else: - files = os.path.basename(col) - - # Copy render product "colorspace" data to representation. - colorspace = "" - products = additional_data["renderProducts"].layer_data.products - for product in products: - if product.productName == aov: - colorspace = product.colorspace - break - - rep = { - "name": ext, - "ext": ext, - "files": files, - "frameStart": int(instance_data.get("frameStartHandle")), - "frameEnd": int(instance_data.get("frameEndHandle")), - # If expectedFile are absolute, we need only filenames - "stagingDir": staging, - "fps": new_instance.get("fps"), - "tags": ["review"] if preview else [], - "colorspaceData": { - "colorspace": colorspace, - "config": { - "path": additional_data["colorspaceConfig"], - "template": additional_data["colorspaceTemplate"] - }, - "display": additional_data["display"], - "view": additional_data["view"] - } - } - - # support conversion from tiled to scanline - if instance_data.get("convertToScanline"): - self.log.info("Adding scanline conversion.") - rep["tags"].append("toScanline") - - # poor man exclusion - if ext in self.skip_integration_repre_list: - rep["tags"].append("delete") - - self._solve_families(new_instance, preview) - - new_instance["representations"] = [rep] - - # if extending frames from existing version, copy files from there - # into our destination directory - if new_instance.get("extendFrames", False): - self._copy_extend_frames(new_instance, rep) - instances.append(new_instance) - self.log.debug("instances:{}".format(instances)) - return instances - def _get_representations(self, instance, exp_files): """Create representations for file sequences. @@ -748,8 +488,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # type: (pyblish.api.Instance) -> None """Process plugin. - Detect type of renderfarm submission and create and post dependent job - in case of Deadline. It creates json file with metadata needed for + Detect type of render farm submission and create and post dependent + job in case of Deadline. It creates json file with metadata needed for publishing in directory of render. Args: @@ -760,145 +500,16 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): self.log.info("Skipping local instance.") return - data = instance.data.copy() - context = instance.context - self.context = context - self.anatomy = instance.context.data["anatomy"] - - asset = data.get("asset") or legacy_io.Session["AVALON_ASSET"] - subset = data.get("subset") - - start = instance.data.get("frameStart") - if start is None: - start = context.data["frameStart"] - - end = instance.data.get("frameEnd") - if end is None: - end = context.data["frameEnd"] - - handle_start = instance.data.get("handleStart") - if handle_start is None: - handle_start = context.data["handleStart"] - - handle_end = instance.data.get("handleEnd") - if handle_end is None: - handle_end = context.data["handleEnd"] - - fps = instance.data.get("fps") - if fps is None: - fps = context.data["fps"] - - if data.get("extendFrames", False): - start, end = self._extend_frames( - asset, - subset, - start, - end, - data["overrideExistingFrame"]) - - try: - source = data["source"] - except KeyError: - source = context.data["currentFile"] - - success, rootless_path = ( - self.anatomy.find_root_template_from_path(source) - ) - if success: - source = rootless_path - - else: - # `rootless_path` is not set to `source` if none of roots match - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues." - ).format(source)) - - family = "render" - if "prerender" in instance.data["families"]: - family = "prerender" - families = [family] - - # pass review to families if marked as review - if data.get("review"): - families.append("review") - - instance_skeleton_data = { - "family": family, - "subset": subset, - "families": families, - "asset": asset, - "frameStart": start, - "frameEnd": end, - "handleStart": handle_start, - "handleEnd": handle_end, - "frameStartHandle": start - handle_start, - "frameEndHandle": end + handle_end, - "comment": instance.data["comment"], - "fps": fps, - "source": source, - "extendFrames": data.get("extendFrames"), - "overrideExistingFrame": data.get("overrideExistingFrame"), - "pixelAspect": data.get("pixelAspect", 1), - "resolutionWidth": data.get("resolutionWidth", 1920), - "resolutionHeight": data.get("resolutionHeight", 1080), - "multipartExr": data.get("multipartExr", False), - "jobBatchName": data.get("jobBatchName", ""), - "useSequenceForReview": data.get("useSequenceForReview", True), - # map inputVersions `ObjectId` -> `str` so json supports it - "inputVersions": list(map(str, data.get("inputVersions", []))) - } - - # skip locking version if we are creating v01 - instance_version = instance.data.get("version") # take this if exists - if instance_version != 1: - instance_skeleton_data["version"] = instance_version - - # transfer specific families from original instance to new render - for item in self.families_transfer: - if item in instance.data.get("families", []): - instance_skeleton_data["families"] += [item] - - # transfer specific properties from original instance based on - # mapping dictionary `instance_transfer` - for key, values in self.instance_transfer.items(): - if key in instance.data.get("families", []): - for v in values: - instance_skeleton_data[v] = instance.data.get(v) - - # look into instance data if representations are not having any - # which are having tag `publish_on_farm` and include them - for repre in instance.data.get("representations", []): - staging_dir = repre.get("stagingDir") - if staging_dir: - success, rootless_staging_dir = ( - self.anatomy.find_root_template_from_path( - staging_dir - ) - ) - if success: - repre["stagingDir"] = rootless_staging_dir - else: - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(staging_dir)) - repre["stagingDir"] = staging_dir - - if "publish_on_farm" in repre.get("tags"): - # create representations attribute of not there - if "representations" not in instance_skeleton_data.keys(): - instance_skeleton_data["representations"] = [] - - instance_skeleton_data["representations"].append(repre) + instance_skeleton_data = create_skeleton_instance( + instance, + families_transfer=self.families_transfer, + instance_transfer=self.instance_transfer) instances = None - assert data.get("expectedFiles"), ("Submission from old Pype version" - " - missing expectedFiles") """ - if content of `expectedFiles` are dictionaries, we will handle - it as list of AOVs, creating instance from every one of them. + if content of `expectedFiles` list are dictionaries, we will handle + it as list of AOVs, creating instance for every one of them. Example: -------- @@ -920,7 +531,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): This will create instances for `beauty` and `Z` subset adding those files to their respective representations. - If we've got only list of files, we collect all filesequences. + If we have only list of files, we collect all file sequences. More then one doesn't probably make sense, but we'll handle it like creating one instance with multiple representations. @@ -938,55 +549,14 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): `foo` and `xxx` """ - self.log.info(data.get("expectedFiles")) - - if isinstance(data.get("expectedFiles")[0], dict): - # we cannot attach AOVs to other subsets as we consider every - # AOV subset of its own. - - additional_data = { - "renderProducts": instance.data["renderProducts"], - "colorspaceConfig": instance.data["colorspaceConfig"], - "display": instance.data["colorspaceDisplay"], - "view": instance.data["colorspaceView"] - } - - # Get templated path from absolute config path. - anatomy = instance.context.data["anatomy"] - colorspaceTemplate = instance.data["colorspaceConfig"] - success, rootless_staging_dir = ( - anatomy.find_root_template_from_path(colorspaceTemplate) - ) - if success: - colorspaceTemplate = rootless_staging_dir - else: - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(colorspaceTemplate)) - additional_data["colorspaceTemplate"] = colorspaceTemplate - - if len(data.get("attachTo")) > 0: - assert len(data.get("expectedFiles")[0].keys()) == 1, ( - "attaching multiple AOVs or renderable cameras to " - "subset is not supported") - - # create instances for every AOV we found in expected files. - # note: this is done for every AOV and every render camere (if - # there are multiple renderable cameras in scene) - instances = self._create_instances_for_aov( - instance_skeleton_data, - data.get("expectedFiles"), - additional_data - ) - self.log.info("got {} instance{}".format( - len(instances), - "s" if len(instances) > 1 else "")) + if isinstance(instance.data.get("expectedFiles")[0], dict): + instances = create_instances_for_aov( + instance, instance_skeleton_data) else: representations = self._get_representations( instance_skeleton_data, - data.get("expectedFiles") + instance.data.get("expectedFiles") ) if "representations" not in instance_skeleton_data.keys(): @@ -1029,11 +599,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): render_job = None submission_type = "" if instance.data.get("toBeRenderedOn") == "deadline": - render_job = data.pop("deadlineSubmissionJob", None) + render_job = instance.data.pop("deadlineSubmissionJob", None) submission_type = "deadline" if instance.data.get("toBeRenderedOn") == "muster": - render_job = data.pop("musterSubmissionJob", None) + render_job = instance.data.pop("musterSubmissionJob", None) submission_type = "muster" if not render_job and instance.data.get("tileRendering") is False: @@ -1056,9 +626,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "jobBatchName") else: render_job["Props"]["Batch"] = os.path.splitext( - os.path.basename(context.data.get("currentFile")))[0] + os.path.basename(instance.context.data.get("currentFile")))[0] # User is deadline user - render_job["Props"]["User"] = context.data.get( + render_job["Props"]["User"] = instance.context.data.get( "deadlineUser", getpass.getuser()) render_job["Props"]["Env"] = { @@ -1080,15 +650,15 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # publish job file publish_job = { - "asset": asset, - "frameStart": start, - "frameEnd": end, - "fps": context.data.get("fps", None), - "source": source, - "user": context.data["user"], - "version": context.data["version"], # this is workfile version - "intent": context.data.get("intent"), - "comment": context.data.get("comment"), + "asset": instance_skeleton_data["asset"], + "frameStart": instance_skeleton_data["frameStart"], + "frameEnd": instance_skeleton_data["frameEnd"], + "fps": instance_skeleton_data["fps"], + "source": instance_skeleton_data["source"], + "user": instance.context.data["user"], + "version": instance.context.data["version"], # this is workfile version + "intent": instance.context.data.get("intent"), + "comment": instance.context.data.get("comment"), "job": render_job or None, "session": legacy_io.Session.copy(), "instances": instances @@ -1098,7 +668,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): publish_job["deadline_publish_job_id"] = deadline_publish_job_id # add audio to metadata file if available - audio_file = context.data.get("audioFile") + audio_file = instance.context.data.get("audioFile") if audio_file and os.path.isfile(audio_file): publish_job.update({"audio": audio_file}) diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py index 3ad62962d2..35a944444f 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish.py @@ -1,32 +1,165 @@ +from openpype.pipeline import ( + get_current_project_name, + get_representation_path, + Anatomy, +) +from openpype.client import ( + get_last_version_by_subset_name, + get_representations +) from openpype.lib import Logger import attr import pyblish.api +from openpype.pipeline.publish import KnownPublishError +import os +import clique +from copy import deepcopy +import re @attr.s -class InstanceSkeleton(object): - # family = attr.ib(factory=) - pass +class TimeData: + """Structure used to handle time related data.""" + start = attr.ib(type=int) + end = attr.ib(type=int) + fps = attr.ib() + step = attr.ib(default=1, type=int) + handle_start = attr.ib(default=0, type=int) + handle_end = attr.ib(default=0, type=int) -def remap_source(source, anatomy): +def remap_source(path, anatomy): + """Try to remap path to rootless path. + + Args: + path (str): Path to be remapped to rootless. + anatomy (Anatomy): Anatomy object to handle remapping + itself. + + Returns: + str: Remapped path. + + Throws: + ValueError: if the root cannot be found. + + """ success, rootless_path = ( - anatomy.find_root_template_from_path(source) + anatomy.find_root_template_from_path(path) ) if success: source = rootless_path else: - # `rootless_path` is not set to `source` if none of roots match - log = Logger.get_logger("farm_publishing") - log.warning( - ("Could not find root path for remapping \"{}\"." - " This may cause issues.").format(source)) + raise ValueError( + "Root from template path cannot be found: {}".format(path)) return source -def create_skeleton_instance(instance): - # type: (pyblish.api.Instance) -> dict - """Create skelenton instance from original instance data. +def extend_frames(asset, subset, start, end): + """Get latest version of asset nad update frame range. + + Based on minimum and maximum values. + + Arguments: + asset (str): asset name + subset (str): subset name + start (int): start frame + end (int): end frame + + Returns: + (int, int): update frame start/end + + """ + # Frame comparison + prev_start = None + prev_end = None + + project_name = get_current_project_name() + version = get_last_version_by_subset_name( + project_name, + subset, + asset_name=asset + ) + + # Set prev start / end frames for comparison + if not prev_start and not prev_end: + prev_start = version["data"]["frameStart"] + prev_end = version["data"]["frameEnd"] + + updated_start = min(start, prev_start) + updated_end = max(end, prev_end) + + + return updated_start, updated_end + + +def get_time_data_from_instance_or_context(instance): + """Get time data from instance (or context). + + If time data is not found on instance, data from context will be used. + + Args: + instance (pyblish.api.Instance): Source instance. + + Returns: + TimeData: dataclass holding time information. + + """ + return TimeData( + start=instance.data.get["frameStart"] or \ + instance.context.data.get("frameStart"), + end=instance.data.get("frameEnd") or \ + instance.context.data.get("frameEnd"), + fps=instance.data.get("fps") or \ + instance.context.data.get("fps"), + handle_start=instance.data.get("handleStart") or \ + instance.context.data.get("handleStart"), # noqa: E501 + handle_end=instance.data.get("handleStart") or \ + instance.context.data.get("handleStart") + ) + + +def get_transferable_representations(instance): + """Transfer representations from original instance. + + This will get all representations on the original instance that + are flagged with `publish_on_farm` and return them to be included + on skeleton instance if needed. + + Args: + instance (pyblish.api.Instance): Original instance to be processed. + + Return: + list of dicts: List of transferable representations. + + """ + anatomy = instance.data.get("anatomy") # type: Anatomy + to_transfer = [] + + for representation in instance.data.get("representations", []): + if "publish_on_farm" not in representation.get("tags"): + continue + + trans_rep = representation.copy() + + staging_dir = trans_rep.get("stagingDir") + + if staging_dir: + try: + trans_rep["stagingDir"] = remap_source(staging_dir, anatomy) + except ValueError: + log = Logger.get_logger("farm_publishing") + log.warning( + ("Could not find root path for remapping \"{}\". " + "This may cause issues on farm.").format(staging_dir)) + + to_transfer.append(trans_rep) + return to_transfer + + +def create_skeleton_instance( + instance, families_transfer=None, instance_transfer=None): + # type: (pyblish.api.Instance, list, dict) -> dict + """Create skeleton instance from original instance data. This will create dictionary containing skeleton - common - data used for publishing rendered instances. @@ -36,26 +169,64 @@ def create_skeleton_instance(instance): Args: instance (pyblish.api.Instance): Original instance to be used as a source of data. + families_transfer (list): List of family names to transfer + from the original instance to the skeleton. + instance_transfer (dict): Dict with keys as families and + values as a list of property names to transfer to the + new skeleton. Returns: dict: Dictionary with skeleton instance data. """ - context = instance.context + # list of family names to transfer to new family if present - return { + context = instance.context + data = instance.data.copy() + anatomy = data["anatomy"] # type: Anatomy + + families = [data["family"]] + + # pass review to families if marked as review + if data.get("review"): + families.append("review") + + # get time related data from instance (or context) + time_data = get_time_data_from_instance_or_context(instance) + + if data.get("extendFrames", False): + time_data.start, time_data.end = extend_frames( + data["asset"], + data["subset"], + time_data.start, + time_data.end, + ) + + source = data.get("source") or context.data.get("currentFile") + success, rootless_path = ( + anatomy.find_root_template_from_path(source) + ) + if success: + source = rootless_path + else: + # `rootless_path` is not set to `source` if none of roots match + log = Logger.get_logger("farm_publishing") + log.warning(("Could not find root path for remapping \"{}\". " + "This may cause issues.").format(source)) + + instance_skeleton_data = { "family": "render" if "prerender" not in instance.data["families"] else "prerender", # noqa: E401 - "subset": subset, + "subset": data["subset"], "families": families, - "asset": asset, - "frameStart": start, - "frameEnd": end, - "handleStart": handle_start, - "handleEnd": handle_end, - "frameStartHandle": start - handle_start, - "frameEndHandle": end + handle_end, - "comment": instance.data["comment"], - "fps": fps, + "asset": data["asset"], + "frameStart": time_data.start, + "frameEnd": time_data.end, + "handleStart": time_data.handle_start, + "handleEnd": time_data.handle_end, + "frameStartHandle": time_data.start - time_data.handle_start, + "frameEndHandle": time_data.end + time_data.handle_end, + "comment": data.get("comment"), + "fps": time_data.fps, "source": source, "extendFrames": data.get("extendFrames"), "overrideExistingFrame": data.get("overrideExistingFrame"), @@ -68,3 +239,360 @@ def create_skeleton_instance(instance): # map inputVersions `ObjectId` -> `str` so json supports it "inputVersions": list(map(str, data.get("inputVersions", []))) } + + # skip locking version if we are creating v01 + instance_version = data.get("version") # take this if exists + if instance_version != 1: + instance_skeleton_data["version"] = instance_version + + # transfer specific families from original instance to new render + for item in families_transfer: + if item in instance.data.get("families", []): + instance_skeleton_data["families"] += [item] + + # transfer specific properties from original instance based on + # mapping dictionary `instance_transfer` + for key, values in instance_transfer.items(): + if key in instance.data.get("families", []): + for v in values: + instance_skeleton_data[v] = instance.data.get(v) + + representations = get_transferable_representations(instance) + instance_skeleton_data["representations"] = [] + instance_skeleton_data["representations"] += representations + + return instance_skeleton_data + +def _solve_families(families): + """Solve families. + + TODO: This is ugly and needs to be refactored. Ftrack family should be + added in different way (based on if the module is enabled?) + + """ + # if we have one representation with preview tag + # flag whole instance for review and for ftrack + if "ftrack" not in families and os.environ.get("FTRACK_SERVER"): + families.append("ftrack") + if "review" not in families: + families.append("review") + return families +def create_instances_for_aov(instance, skeleton): + """Create instances from AOVs. + + This will create new pyblish.api.Instances by going over expected + files defined on original instance. + + Args: + instance (pyblish.api.Instance): Original instance. + skeleton (dict): Skeleton instance data. + + Returns: + list of pyblish.api.Instance: Instances created from + expected files. + + """ + # we cannot attach AOVs to other subsets as we consider every + # AOV subset of its own. + + log = Logger.get_logger("farm_publishing") + additional_color_data = { + "renderProducts": instance.data["renderProducts"], + "colorspaceConfig": instance.data["colorspaceConfig"], + "display": instance.data["colorspaceDisplay"], + "view": instance.data["colorspaceView"] + } + + # Get templated path from absolute config path. + anatomy = instance.context.data["anatomy"] + colorspace_template = instance.data["colorspaceConfig"] + try: + additional_color_data["colorspaceTemplate"] = remap_source( + colorspace_template, anatomy) + except ValueError as e: + log.warning(e) + + # if there are subset to attach to and more than one AOV, + # we cannot proceed. + if ( + len(instance.data.get("attachTo", [])) > 0 + and len(instance.data.get("expectedFiles")[0].keys()) != 1 + ): + raise KnownPublishError( + "attaching multiple AOVs or renderable cameras to " + "subset is not supported yet.") + + # create instances for every AOV we found in expected files. + # NOTE: this is done for every AOV and every render camera (if + # there are multiple renderable cameras in scene) + return _create_instances_for_aov( + instance, + skeleton, + additional_color_data + ) + + +def _create_instances_for_aov(instance, skeleton, additional_data): + """Create instance for each AOV found. + + This will create new instance for every AOV it can detect in expected + files list. + + Args: + instance (pyblish.api.Instance): Original instance. + skeleton (dict): Skeleton data for instance (those needed) later + by collector. + additional_data (dict): ... + + + Returns: + list of instances + + Throws: + ValueError: + + """ + # TODO: this needs to be taking the task from context or instance + task = os.environ["AVALON_TASK"] + + anatomy = instance.data["anatomy"] + subset = skeleton["subset"] + cameras = instance.data.get("cameras", []) + exp_files = instance.data["expectedFiles"] + log = Logger.get_logger("farm_publishing") + + instances = [] + # go through AOVs in expected files + for aov, files in exp_files[0].items(): + cols, rem = clique.assemble(files) + # we shouldn't have any reminders. And if we do, it should + # be just one item for single frame renders. + if not cols and rem: + if len(rem) != 1: + raise ValueError("Found multiple non related files " + "to render, don't know what to do " + "with them.") + col = rem[0] + ext = os.path.splitext(col)[1].lstrip(".") + else: + # but we really expect only one collection. + # Nothing else make sense. + if len(cols) != 1: + raise ValueError("Only one image sequence type is expected.") # noqa: E501 + ext = cols[0].tail.lstrip(".") + col = list(cols[0]) + + # create subset name `familyTaskSubset_AOV` + group_name = 'render{}{}{}{}'.format( + task[0].upper(), task[1:], + subset[0].upper(), subset[1:]) + + cam = [c for c in cameras if c in col.head] + if cam: + if aov: + subset_name = '{}_{}_{}'.format(group_name, cam, aov) + else: + subset_name = '{}_{}'.format(group_name, cam) + else: + if aov: + subset_name = '{}_{}'.format(group_name, aov) + else: + subset_name = '{}'.format(group_name) + + if isinstance(col, (list, tuple)): + staging = os.path.dirname(col[0]) + else: + staging = os.path.dirname(col) + + try: + staging = remap_source(staging, anatomy) + except ValueError as e: + log.warning(e) + + log.info("Creating data for: {}".format(subset_name)) + + app = os.environ.get("AVALON_APP", "") + + preview = False + + if isinstance(col, list): + render_file_name = os.path.basename(col[0]) + else: + render_file_name = os.path.basename(col) + aov_patterns = self.aov_filter + + preview = match_aov_pattern(app, aov_patterns, render_file_name) + # toggle preview on if multipart is on + + if instance.data.get("multipartExr"): + log.debug("Adding preview tag because its multipartExr") + preview = True + + + new_instance = deepcopy(skeleton) + new_instance["subsetGroup"] = group_name + if preview: + new_instance["review"] = True + + # create representation + if isinstance(col, (list, tuple)): + files = [os.path.basename(f) for f in col] + else: + files = os.path.basename(col) + + # Copy render product "colorspace" data to representation. + colorspace = "" + products = additional_data["renderProducts"].layer_data.products + for product in products: + if product.productName == aov: + colorspace = product.colorspace + break + + rep = { + "name": ext, + "ext": ext, + "files": files, + "frameStart": int(skeleton["frameStartHandle"]), + "frameEnd": int(skeleton["frameEndHandle"]), + # If expectedFile are absolute, we need only filenames + "stagingDir": staging, + "fps": new_instance.get("fps"), + "tags": ["review"] if preview else [], + "colorspaceData": { + "colorspace": colorspace, + "config": { + "path": additional_data["colorspaceConfig"], + "template": additional_data["colorspaceTemplate"] + }, + "display": additional_data["display"], + "view": additional_data["view"] + } + } + + # support conversion from tiled to scanline + if instance.data.get("convertToScanline"): + log.info("Adding scanline conversion.") + rep["tags"].append("toScanline") + + # poor man exclusion + if ext in self.skip_integration_repre_list: + rep["tags"].append("delete") + + if preview: + new_instance["families"] = _solve_families(new_instance) + + new_instance["representations"] = [rep] + + # if extending frames from existing version, copy files from there + # into our destination directory + if new_instance.get("extendFrames", False): + copy_extend_frames(new_instance, rep) + instances.append(new_instance) + log.debug("instances:{}".format(instances)) + return instances + +def get_resources(project_name, version, extension=None): + """Get the files from the specific version.""" + + # TODO this functions seems to be weird + # - it's looking for representation with one extension or first (any) + # representation from a version? + # - not sure how this should work, maybe it does for specific use cases + # but probably can't be used for all resources from 2D workflows + extensions = None + if extension: + extensions = [extension] + repre_docs = list(get_representations( + project_name, version_ids=[version["_id"]], extensions=extensions + )) + assert repre_docs, "This is a bug" + + representation = repre_docs[0] + directory = get_representation_path(representation) + print("Source: ", directory) + resources = sorted( + [ + os.path.normpath(os.path.join(directory, fname)) + for fname in os.listdir(directory) + ] + ) + + return resources + +def copy_extend_frames(self, instance, representation): + """Copy existing frames from latest version. + + This will copy all existing frames from subset's latest version back + to render directory and rename them to what renderer is expecting. + + Arguments: + instance (pyblish.plugin.Instance): instance to get required + data from + representation (dict): presentation to operate on + + """ + import speedcopy + + log = Logger.get_logger("farm_publishing") + log.info("Preparing to copy ...") + start = instance.data.get("frameStart") + end = instance.data.get("frameEnd") + project_name = instance.context.data["project"] + + # get latest version of subset + # this will stop if subset wasn't published yet + + version = get_last_version_by_subset_name( + project_name, + instance.data.get("subset"), + asset_name=instance.data.get("asset") + ) + + # get its files based on extension + subset_resources = get_resources( + project_name, version, representation.get("ext") + ) + r_col, _ = clique.assemble(subset_resources) + + # if override remove all frames we are expecting to be rendered + # so we'll copy only those missing from current render + if instance.data.get("overrideExistingFrame"): + for frame in range(start, end + 1): + if frame not in r_col.indexes: + continue + r_col.indexes.remove(frame) + + # now we need to translate published names from representation + # back. This is tricky, right now we'll just use same naming + # and only switch frame numbers + resource_files = [] + r_filename = os.path.basename( + representation.get("files")[0]) # first file + op = re.search(self.R_FRAME_NUMBER, r_filename) + pre = r_filename[:op.start("frame")] + post = r_filename[op.end("frame"):] + assert op is not None, "padding string wasn't found" + for frame in list(r_col): + fn = re.search(self.R_FRAME_NUMBER, frame) + # silencing linter as we need to compare to True, not to + # type + assert fn is not None, "padding string wasn't found" + # list of tuples (source, destination) + staging = representation.get("stagingDir") + staging = self.anatomy.fill_root(staging) + resource_files.append( + (frame, os.path.join( + staging, "{}{}{}".format(pre, fn["frame"], post))) + ) + + # test if destination dir exists and create it if not + output_dir = os.path.dirname(representation.get("files")[0]) + if not os.path.isdir(output_dir): + os.makedirs(output_dir) + + # copy files + for source in resource_files: + speedcopy.copy(source[0], source[1]) + log.info(" > {}".format(source[1])) + + log.info("Finished copying %i files" % len(resource_files)) diff --git a/openpype/pipeline/farm/pyblish.pyi b/openpype/pipeline/farm/pyblish.pyi new file mode 100644 index 0000000000..3667f2d8a5 --- /dev/null +++ b/openpype/pipeline/farm/pyblish.pyi @@ -0,0 +1,23 @@ +import pyblish.api +from openpype.pipeline import Anatomy +from typing import Tuple, Union, List + + +class TimeData: + start: int + end: int + fps: float | int + step: int + handle_start: int + handle_end: int + + def __init__(self, start: int, end: int, fps: float | int, step: int, handle_start: int, handle_end: int): + ... + ... + +def remap_source(source: str, anatomy: Anatomy): ... +def extend_frames(asset: str, subset: str, start: int, end: int) -> Tuple[int, int]: ... +def get_time_data_from_instance_or_context(instance: pyblish.api.Instance) -> TimeData: ... +def get_transferable_representations(instance: pyblish.api.Instance) -> list: ... +def create_skeleton_instance(instance: pyblish.api.Instance, families_transfer: list = ..., instance_transfer: dict = ...) -> dict: ... +def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict) -> List[pyblish.api.Instance]: ... From 7f588ee35674c364100acaa7e5b80a4364ed697a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 21 Apr 2023 02:54:38 +0200 Subject: [PATCH 044/144] :heavy_plus_sign: add mypy --- poetry.lock | 68 +++++++++++++++++++++++++++++++++++++++++++++++--- pyproject.toml | 1 + 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index f71611cb6f..d5b80d0f0a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. [[package]] name = "acre" @@ -1456,11 +1456,13 @@ python-versions = ">=3.6" files = [ {file = "lief-0.12.3-cp310-cp310-macosx_10_14_arm64.whl", hash = "sha256:66724f337e6a36cea1a9380f13b59923f276c49ca837becae2e7be93a2e245d9"}, {file = "lief-0.12.3-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:6d18aafa2028587c98f6d4387bec94346e92f2b5a8a5002f70b1cf35b1c045cc"}, + {file = "lief-0.12.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d4f69d125caaa8d5ddb574f29cc83101e165ebea1a9f18ad042eb3544081a797"}, {file = "lief-0.12.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c078d6230279ffd3bca717c79664fb8368666f610b577deb24b374607936e9c1"}, {file = "lief-0.12.3-cp310-cp310-win32.whl", hash = "sha256:e3a6af926532d0aac9e7501946134513d63217bacba666e6f7f5a0b7e15ba236"}, {file = "lief-0.12.3-cp310-cp310-win_amd64.whl", hash = "sha256:0750b72e3aa161e1fb0e2e7f571121ae05d2428aafd742ff05a7656ad2288447"}, {file = "lief-0.12.3-cp311-cp311-macosx_10_14_arm64.whl", hash = "sha256:b5c123cb99a7879d754c059e299198b34e7e30e3b64cf22e8962013db0099f47"}, {file = "lief-0.12.3-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:8bc58fa26a830df6178e36f112cb2bbdd65deff593f066d2d51434ff78386ba5"}, + {file = "lief-0.12.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74ac6143ac6ccd813c9b068d9c5f1f9d55c8813c8b407387eb57de01c3db2d74"}, {file = "lief-0.12.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:04eb6b70d646fb5bd6183575928ee23715550f161f2832cbcd8c6ff2071fb408"}, {file = "lief-0.12.3-cp311-cp311-win32.whl", hash = "sha256:7e2d0a53c403769b04adcf8df92e83c5e25f9103a052aa7f17b0a9cf057735fb"}, {file = "lief-0.12.3-cp311-cp311-win_amd64.whl", hash = "sha256:7f6395c12ee1bc4a5162f567cba96d0c72dfb660e7902e84d4f3029daf14fe33"}, @@ -1480,6 +1482,7 @@ files = [ {file = "lief-0.12.3-cp38-cp38-win_amd64.whl", hash = "sha256:b00667257b43e93d94166c959055b6147d46d302598f3ee55c194b40414c89cc"}, {file = "lief-0.12.3-cp39-cp39-macosx_10_14_arm64.whl", hash = "sha256:e6a1b5b389090d524621c2455795e1262f62dc9381bedd96f0cd72b878c4066d"}, {file = "lief-0.12.3-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:ae773196df814202c0c51056163a1478941b299512b09660a3c37be3c7fac81e"}, + {file = "lief-0.12.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:66ddf88917ec7b00752687c476bb2771dc8ec19bd7e4c0dcff1f8ef774cad4e9"}, {file = "lief-0.12.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:4a47f410032c63ac3be051d963d0337d6b47f0e94bfe8e946ab4b6c428f4d0f8"}, {file = "lief-0.12.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbd11367c2259bd1131a6c8755dcde33314324de5ea029227bfbc7d3755871e6"}, {file = "lief-0.12.3-cp39-cp39-win32.whl", hash = "sha256:2ce53e311918c3e5b54c815ef420a747208d2a88200c41cd476f3dd1eb876bcf"}, @@ -1676,6 +1679,65 @@ files = [ {file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"}, ] +[[package]] +name = "mypy" +version = "1.2.0" +description = "Optional static typing for Python" +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mypy-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:701189408b460a2ff42b984e6bd45c3f41f0ac9f5f58b8873bbedc511900086d"}, + {file = "mypy-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fe91be1c51c90e2afe6827601ca14353bbf3953f343c2129fa1e247d55fd95ba"}, + {file = "mypy-1.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d26b513225ffd3eacece727f4387bdce6469192ef029ca9dd469940158bc89e"}, + {file = "mypy-1.2.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3a2d219775a120581a0ae8ca392b31f238d452729adbcb6892fa89688cb8306a"}, + {file = "mypy-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:2e93a8a553e0394b26c4ca683923b85a69f7ccdc0139e6acd1354cc884fe0128"}, + {file = "mypy-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3efde4af6f2d3ccf58ae825495dbb8d74abd6d176ee686ce2ab19bd025273f41"}, + {file = "mypy-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:695c45cea7e8abb6f088a34a6034b1d273122e5530aeebb9c09626cea6dca4cb"}, + {file = "mypy-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0e9464a0af6715852267bf29c9553e4555b61f5904a4fc538547a4d67617937"}, + {file = "mypy-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8293a216e902ac12779eb7a08f2bc39ec6c878d7c6025aa59464e0c4c16f7eb9"}, + {file = "mypy-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:f46af8d162f3d470d8ffc997aaf7a269996d205f9d746124a179d3abe05ac602"}, + {file = "mypy-1.2.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:031fc69c9a7e12bcc5660b74122ed84b3f1c505e762cc4296884096c6d8ee140"}, + {file = "mypy-1.2.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:390bc685ec209ada4e9d35068ac6988c60160b2b703072d2850457b62499e336"}, + {file = "mypy-1.2.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:4b41412df69ec06ab141808d12e0bf2823717b1c363bd77b4c0820feaa37249e"}, + {file = "mypy-1.2.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4e4a682b3f2489d218751981639cffc4e281d548f9d517addfd5a2917ac78119"}, + {file = "mypy-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a197ad3a774f8e74f21e428f0de7f60ad26a8d23437b69638aac2764d1e06a6a"}, + {file = "mypy-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c9a084bce1061e55cdc0493a2ad890375af359c766b8ac311ac8120d3a472950"}, + {file = "mypy-1.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaeaa0888b7f3ccb7bcd40b50497ca30923dba14f385bde4af78fac713d6d6f6"}, + {file = "mypy-1.2.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bea55fc25b96c53affab852ad94bf111a3083bc1d8b0c76a61dd101d8a388cf5"}, + {file = "mypy-1.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:4c8d8c6b80aa4a1689f2a179d31d86ae1367ea4a12855cc13aa3ba24bb36b2d8"}, + {file = "mypy-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:70894c5345bea98321a2fe84df35f43ee7bb0feec117a71420c60459fc3e1eed"}, + {file = "mypy-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4a99fe1768925e4a139aace8f3fb66db3576ee1c30b9c0f70f744ead7e329c9f"}, + {file = "mypy-1.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023fe9e618182ca6317ae89833ba422c411469156b690fde6a315ad10695a521"}, + {file = "mypy-1.2.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4d19f1a239d59f10fdc31263d48b7937c585810288376671eaf75380b074f238"}, + {file = "mypy-1.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:2de7babe398cb7a85ac7f1fd5c42f396c215ab3eff731b4d761d68d0f6a80f48"}, + {file = "mypy-1.2.0-py3-none-any.whl", hash = "sha256:d8e9187bfcd5ffedbe87403195e1fc340189a68463903c39e2b63307c9fa0394"}, + {file = "mypy-1.2.0.tar.gz", hash = "sha256:f70a40410d774ae23fcb4afbbeca652905a04de7948eaf0b1789c8d1426b72d1"}, +] + +[package.dependencies] +mypy-extensions = ">=1.0.0" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10" + +[package.extras] +dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] +python2 = ["typed-ast (>=1.4.0,<2)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "nodeenv" version = "1.7.0" @@ -2352,7 +2414,7 @@ files = [ cffi = ">=1.4.1" [package.extras] -docs = ["sphinx (>=1.6.5)", "sphinx_rtd_theme"] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] tests = ["hypothesis (>=3.27.0)", "pytest (>=3.2.1,!=3.3.0)"] [[package]] @@ -3462,4 +3524,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools" [metadata] lock-version = "2.0" python-versions = ">=3.9.1,<3.10" -content-hash = "02daca205796a0f29a0d9f50707544e6804f32027eba493cd2aa7f175a00dcea" +content-hash = "9d3a574b1b6f42ae05d4f0fa6d65677ee54a51c53d984dd3f44d02f234962dbb" diff --git a/pyproject.toml b/pyproject.toml index 42ce5aa32c..afc6cc45d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ wheel = "*" enlighten = "*" # cool terminal progress bars toml = "^0.10.2" # for parsing pyproject.toml pre-commit = "*" +mypy = "*" # for better types [tool.poetry.urls] "Bug Tracker" = "https://github.com/pypeclub/openpype/issues" From 3a9109422d499f9ab843e1fa26f8a02c2d463f55 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 28 Apr 2023 15:17:47 +0200 Subject: [PATCH 045/144] :art: update code from upstream --- openpype/pipeline/farm/tools.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/openpype/pipeline/farm/tools.py b/openpype/pipeline/farm/tools.py index 8cf1af399e..506f95d6b2 100644 --- a/openpype/pipeline/farm/tools.py +++ b/openpype/pipeline/farm/tools.py @@ -53,14 +53,11 @@ def from_published_scene(instance, replace_in_path=True): template_data["comment"] = None anatomy = instance.context.data['anatomy'] - anatomy_filled = anatomy.format(template_data) - template_filled = anatomy_filled["publish"]["path"] + template_obj = anatomy.templates_obj["publish"]["path"] + template_filled = template_obj.format_strict(template_data) file_path = os.path.normpath(template_filled) - self.log.info("Using published scene for render {}".format(file_path)) - if not os.path.exists(file_path): - self.log.error("published scene does not exist!") raise if not replace_in_path: @@ -102,8 +99,4 @@ def from_published_scene(instance, replace_in_path=True): new_scene) instance.data["publishRenderMetadataFolder"] = metadata_folder - self.log.info("Scene name was switched {} -> {}".format( - orig_scene, new_scene - )) - return file_path From 92bb47ed231216ea51f4cdb70084358190082d12 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 28 Apr 2023 18:37:26 +0200 Subject: [PATCH 046/144] :art: add nuke job creator --- .../publish/create_nuke_deadline_job.py | 307 ++++++++++++++++++ .../publish/create_publish_royalrender_job.py | 117 ++++--- openpype/modules/royalrender/rr_job.py | 22 +- openpype/pipeline/farm/pyblish.py | 114 +++++-- openpype/pipeline/farm/pyblish.pyi | 3 +- 5 files changed, 487 insertions(+), 76 deletions(-) create mode 100644 openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py new file mode 100644 index 0000000000..9f49294459 --- /dev/null +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -0,0 +1,307 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to RoyalRender.""" +import os +import sys +import re +import platform +from datetime import datetime + +from pyblish.api import InstancePlugin, IntegratorOrder, Context +from openpype.tests.lib import is_in_tests +from openpype.lib import is_running_from_build +from openpype.pipeline.publish.lib import get_published_workfile_instance +from openpype.pipeline.publish import KnownPublishError +from openpype.modules.royalrender.api import Api as rrApi +from openpype.modules.royalrender.rr_job import ( + RRJob, CustomAttribute, get_rr_platform) +from openpype.lib import ( + is_running_from_build, + BoolDef, + NumberDef +) + + +class CreateNukeRoyalRenderJob(InstancePlugin): + label = "Create Nuke Render job in RR" + order = IntegratorOrder + 0.1 + hosts = ["nuke"] + families = ["render", "prerender"] + targets = ["local"] + optional = True + + priority = 50 + chunk_size = 1 + concurrent_tasks = 1 + + @classmethod + def get_attribute_defs(cls): + return [ + NumberDef( + "priority", + label="Priority", + default=cls.priority, + decimals=0 + ), + NumberDef( + "chunk", + label="Frames Per Task", + default=cls.chunk_size, + decimals=0, + minimum=1, + maximum=1000 + ), + NumberDef( + "concurrency", + label="Concurrency", + default=cls.concurrent_tasks, + decimals=0, + minimum=1, + maximum=10 + ), + BoolDef( + "use_gpu", + default=cls.use_gpu, + label="Use GPU" + ), + BoolDef( + "suspend_publish", + default=False, + label="Suspend publish" + ) + ] + + def __init__(self, *args, **kwargs): + self._instance = None + self._rr_root = None + self.scene_path = None + self.job = None + self.submission_parameters = None + self.rr_api = None + + def process(self, instance): + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + + instance.data["attributeValues"] = self.get_attr_values_from_data( + instance.data) + + # add suspend_publish attributeValue to instance data + instance.data["suspend_publish"] = instance.data["attributeValues"][ + "suspend_publish"] + + context = instance.context + + self._rr_root = self._resolve_rr_path(context, instance.data.get( + "rrPathName")) # noqa + self.log.debug(self._rr_root) + if not self._rr_root: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + + self.rr_api = rrApi(self._rr_root) + + self.scene_path = context.data["currentFile"] + if self.use_published: + file_path = get_published_workfile_instance(context) + + # fallback if nothing was set + if not file_path: + self.log.warning("Falling back to workfile") + file_path = context.data["currentFile"] + + self.scene_path = file_path + self.log.info( + "Using published scene for render {}".format(self.scene_path) + ) + + if not self._instance.data.get("expectedFiles"): + self._instance.data["expectedFiles"] = [] + + if not self._instance.data.get("rrJobs"): + self._instance.data["rrJobs"] = [] + + self._instance.data["rrJobs"] += self.create_jobs() + + # redefinition of families + if "render" in self._instance.data["family"]: + self._instance.data["family"] = "write" + self._instance.data["families"].insert(0, "render2d") + elif "prerender" in self._instance.data["family"]: + self._instance.data["family"] = "write" + self._instance.data["families"].insert(0, "prerender") + + self._instance.data["outputDir"] = os.path.dirname( + self._instance.data["path"]).replace("\\", "/") + + + def create_jobs(self): + submit_frame_start = int(self._instance.data["frameStartHandle"]) + submit_frame_end = int(self._instance.data["frameEndHandle"]) + + # get output path + render_path = self._instance.data['path'] + script_path = self.scene_path + node = self._instance.data["transientData"]["node"] + + # main job + jobs = [ + self.get_job( + script_path, + render_path, + node.name(), + submit_frame_start, + submit_frame_end, + ) + ] + + for baking_script in self._instance.data.get("bakingNukeScripts", []): + render_path = baking_script["bakeRenderPath"] + script_path = baking_script["bakeScriptPath"] + exe_node_name = baking_script["bakeWriteNodeName"] + + jobs.append(self.get_job( + script_path, + render_path, + exe_node_name, + submit_frame_start, + submit_frame_end + )) + + return jobs + + def get_job(self, script_path, render_path, + node_name, start_frame, end_frame): + """Get RR job based on current instance. + + Args: + script_path (str): Path to Nuke script. + render_path (str): Output path. + node_name (str): Name of the render node. + start_frame (int): Start frame. + end_frame (int): End frame. + + Returns: + RRJob: RoyalRender Job instance. + + """ + render_dir = os.path.normpath(os.path.dirname(render_path)) + batch_name = os.path.basename(script_path) + jobname = "%s - %s" % (batch_name, self._instance.name) + if is_in_tests(): + batch_name += datetime.now().strftime("%d%m%Y%H%M%S") + + output_filename_0 = self.preview_fname(render_path) + + custom_attributes = [] + if is_running_from_build(): + custom_attributes = [ + CustomAttribute( + name="OpenPypeVersion", + value=os.environ.get("OPENPYPE_VERSION")) + ] + + nuke_version = re.search( + r"\d+\.\d+", self._instance.context.data.get("hostVersion")) + + # this will append expected files to instance as needed. + expected_files = self.expected_files( + render_path, start_frame, end_frame) + first_file = next(self._iter_expected_files(expected_files)) + + job = RRJob( + Software="Nuke", + Renderer="", + SeqStart=int(start_frame), + SeqEnd=int(end_frame), + SeqStep=int(self._instance.data.get("byFrameStep"), 1), + SeqFileOffset=0, + Version=nuke_version.group(), + SceneName=script_path, + IsActive=True, + ImageDir=render_dir.replace("\\", "/"), + ImageFilename="{}.".format(os.path.splitext(first_file)[0]), + ImageExtension=os.path.splitext(first_file)[1], + ImagePreNumberLetter=".", + ImageSingleOutputFile=False, + SceneOS=get_rr_platform(), + Layer=node_name, + SceneDatabaseDir=script_path, + CustomSHotName=self._instance.context.data["asset"], + CompanyProjectName=self._instance.context.data["projectName"], + ImageWidth=self._instance.data["resolutionWidth"], + ImageHeight=self._instance.data["resolutionHeight"], + CustomAttributes=custom_attributes + ) + + @staticmethod + def _resolve_rr_path(context, rr_path_name): + # type: (Context, str) -> str + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except (AttributeError, KeyError): + # Handle situation were we had only one url for royal render. + return context.data["defaultRRPath"][platform.system().lower()] + + return rr_servers[rr_path_name][platform.system().lower()] + + def expected_files(self, path, start_frame, end_frame): + """Get expected files. + + This function generate expected files from provided + path and start/end frames. + + It was taken from Deadline module, but this should be + probably handled better in collector to support more + flexible scenarios. + + Args: + path (str): Output path. + start_frame (int): Start frame. + end_frame (int): End frame. + + Returns: + list: List of expected files. + + """ + dir_name = os.path.dirname(path) + file = os.path.basename(path) + + expected_files = [] + + if "#" in file: + pparts = file.split("#") + padding = "%0{}d".format(len(pparts) - 1) + file = pparts[0] + padding + pparts[-1] + + if "%" not in file: + expected_files.append(path) + return + + if self._instance.data.get("slate"): + start_frame -= 1 + + expected_files.extend( + os.path.join(dir_name, (file % i)).replace("\\", "/") + for i in range(start_frame, (end_frame + 1)) + ) + return expected_files diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index a5493dd061..b1c84c87b9 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -2,14 +2,20 @@ """Create publishing job on RoyalRender.""" import os from copy import deepcopy +import json -from pyblish.api import InstancePlugin, IntegratorOrder +from pyblish.api import InstancePlugin, IntegratorOrder, Instance from openpype.pipeline import legacy_io from openpype.modules.royalrender.rr_job import RRJob, RREnvList from openpype.pipeline.publish import KnownPublishError from openpype.lib.openpype_version import ( get_OpenPypeVersion, get_openpype_version) +from openpype.pipeline.farm.pyblish import ( + create_skeleton_instance, + create_instances_for_aov, + attach_instances_to_subset +) class CreatePublishRoyalRenderJob(InstancePlugin): @@ -31,50 +37,84 @@ class CreatePublishRoyalRenderJob(InstancePlugin): self.context = context self.anatomy = instance.context.data["anatomy"] - # asset = data.get("asset") - # subset = data.get("subset") - # source = self._remap_source( - # data.get("source") or context.data["source"]) + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + + instance_skeleton_data = create_skeleton_instance( + instance, + families_transfer=self.families_transfer, + instance_transfer=self.instance_transfer) + + instances = None + if isinstance(instance.data.get("expectedFiles")[0], dict): + instances = create_instances_for_aov( + instance, instance_skeleton_data, self.aov_filter) - def _remap_source(self, source): - success, rootless_path = ( - self.anatomy.find_root_template_from_path(source) - ) - if success: - source = rootless_path else: - # `rootless_path` is not set to `source` if none of roots match - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues." - ).format(source)) - return source + representations = self._get_representations( + instance_skeleton_data, + instance.data.get("expectedFiles") + ) - def get_job(self, instance, job, instances): - """Submit publish job to RoyalRender.""" + if "representations" not in instance_skeleton_data.keys(): + instance_skeleton_data["representations"] = [] + + # add representation + instance_skeleton_data["representations"] += representations + instances = [instance_skeleton_data] + + # attach instances to subset + if instance.data.get("attachTo"): + instances = attach_instances_to_subset( + instance.data.get("attachTo"), instances + ) + + self.log.info("Creating RoyalRender Publish job ...") + + if not instance.data.get("rrJobs"): + self.log.error(("There is no prior RoyalRender " + "job on the instance.")) + raise KnownPublishError( + "Can't create publish job without prior ppducing jobs first") + + publish_job = self.get_job(instance, instances) + + instance.data["rrJobs"] += publish_job + + metadata_path, rootless_metadata_path = self._create_metadata_path( + instance) + + self.log.info("Writing json file: {}".format(metadata_path)) + with open(metadata_path, "w") as f: + json.dump(publish_job, f, indent=4, sort_keys=True) + + def get_job(self, instance, instances): + """Create RR publishing job. + + Based on provided original instance and additional instances, + create publishing job and return it to be submitted to farm. + + Args: + instance (Instance): Original instance. + instances (list of Instance): List of instances to + be published on farm. + + Returns: + RRJob: RoyalRender publish job. + + """ data = instance.data.copy() subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) - override_version = None instance_version = instance.data.get("version") # take this if exists - if instance_version != 1: - override_version = instance_version - output_dir = self._get_publish_folder( - instance.context.data['anatomy'], - deepcopy(instance.data["anatomyData"]), - instance.data.get("asset"), - instances[0]["subset"], - # TODO: this shouldn't be hardcoded and is in fact settable by - # Settings. - 'render', - override_version - ) + override_version = instance_version if instance_version != 1 else None # Transfer the environment from the original job to this dependent # job, so they use the same environment metadata_path, roothless_metadata_path = \ - self._create_metadata_path(instance) + self._create_metadata_path(instance) environment = RREnvList({ "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], @@ -96,7 +136,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): # and collect all pre_ids to wait for job_environ = {} jobs_pre_ids = [] - for job in instance["rrJobs"]: # type: RRJob + for job in instance.data["rrJobs"]: # type: RRJob if job.rrEnvList: job_environ.update( dict(RREnvList.parse(job.rrEnvList)) @@ -159,11 +199,4 @@ class CreatePublishRoyalRenderJob(InstancePlugin): else: job.WaitForPreIDs += jobs_pre_ids - self.log.info("Creating RoyalRender Publish job ...") - - if not instance.data.get("rrJobs"): - self.log.error("There is no RoyalRender job on the instance.") - raise KnownPublishError( - "Can't create publish job without producing jobs") - - instance.data["rrJobs"] += job + return job diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index 689a488a5c..5f034e74a1 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -9,6 +9,17 @@ from collections import namedtuple, OrderedDict CustomAttribute = namedtuple("CustomAttribute", ["name", "value"]) +def get_rr_platform(): + # type: () -> str + """Returns name of platform used in rr jobs.""" + if sys.platform.lower() in ["win32", "win64"]: + return "windows" + elif sys.platform.lower() == "darwin": + return "mac" + else: + return "linux" + + class RREnvList(dict): def serialize(self): # VariableA=ValueA~~~VariableB=ValueB @@ -163,17 +174,6 @@ class RRJob: # only used in RR 8.3 and newer rrEnvList = attr.ib(default=None) # type: str - @staticmethod - def get_rr_platform(): - # type: () -> str - """Returns name of platform used in rr jobs.""" - if sys.platform.lower() in ["win32", "win64"]: - return "windows" - elif sys.platform.lower() == "darwin": - return "mac" - else: - return "linux" - class SubmitterParameter: """Wrapper for Submitter Parameters.""" diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish.py index 35a944444f..e5ebd3666c 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish.py @@ -11,10 +11,12 @@ from openpype.lib import Logger import attr import pyblish.api from openpype.pipeline.publish import KnownPublishError +from openpype.pipeline.farm.patterning import match_aov_pattern import os import clique from copy import deepcopy import re +import warnings @attr.s @@ -263,6 +265,7 @@ def create_skeleton_instance( return instance_skeleton_data + def _solve_families(families): """Solve families. @@ -277,7 +280,9 @@ def _solve_families(families): if "review" not in families: families.append("review") return families -def create_instances_for_aov(instance, skeleton): + + +def create_instances_for_aov(instance, skeleton, aov_filter): """Create instances from AOVs. This will create new pyblish.api.Instances by going over expected @@ -328,11 +333,12 @@ def create_instances_for_aov(instance, skeleton): return _create_instances_for_aov( instance, skeleton, + aov_filter, additional_color_data ) -def _create_instances_for_aov(instance, skeleton, additional_data): +def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data): """Create instance for each AOV found. This will create new instance for every AOV it can detect in expected @@ -491,35 +497,64 @@ def _create_instances_for_aov(instance, skeleton, additional_data): log.debug("instances:{}".format(instances)) return instances -def get_resources(project_name, version, extension=None): - """Get the files from the specific version.""" - # TODO this functions seems to be weird - # - it's looking for representation with one extension or first (any) - # representation from a version? - # - not sure how this should work, maybe it does for specific use cases - # but probably can't be used for all resources from 2D workflows - extensions = None +def get_resources(project_name, version, extension=None): + """Get the files from the specific version. + + This will return all get all files from representation. + + Todo: + This is really weird function, and it's use is + highly controversial. First, it will not probably work + ar all in final release of AYON, second, the logic isn't sound. + It should try to find representation matching the current one - + because it is used to pull out files from previous version to + be included in this one. + + .. deprecated:: 3.15.5 + This won't work in AYON and even the logic must be refactored. + + Args: + project_name (str): Name of the project. + version (dict): Version document. + extension (str): extension used to filter + representations. + + Returns: + list: of files + + """ + warnings.warn(( + "This won't work in AYON and even " + "the logic must be refactored."), DeprecationWarning) + extensions = [] if extension: extensions = [extension] - repre_docs = list(get_representations( - project_name, version_ids=[version["_id"]], extensions=extensions - )) - assert repre_docs, "This is a bug" - representation = repre_docs[0] + # there is a `context_filter` argument that won't probably work in + # final release of AYON. SO we'll rather not use it + repre_docs = list(get_representations( + project_name, version_ids=[version["_id"]])) + + filtered = [] + for doc in repre_docs: + if doc["context"]["ext"] in extensions: + filtered.append(doc) + + representation = filtered[0] directory = get_representation_path(representation) print("Source: ", directory) resources = sorted( [ - os.path.normpath(os.path.join(directory, fname)) - for fname in os.listdir(directory) + os.path.normpath(os.path.join(directory, file_name)) + for file_name in os.listdir(directory) ] ) return resources -def copy_extend_frames(self, instance, representation): + +def copy_extend_frames(instance, representation): """Copy existing frames from latest version. This will copy all existing frames from subset's latest version back @@ -533,11 +568,15 @@ def copy_extend_frames(self, instance, representation): """ import speedcopy + R_FRAME_NUMBER = re.compile( + r".+\.(?P[0-9]+)\..+") + log = Logger.get_logger("farm_publishing") log.info("Preparing to copy ...") start = instance.data.get("frameStart") end = instance.data.get("frameEnd") project_name = instance.context.data["project"] + anatomy = instance.data["anatomy"] # type: Anatomy # get latest version of subset # this will stop if subset wasn't published yet @@ -554,7 +593,7 @@ def copy_extend_frames(self, instance, representation): ) r_col, _ = clique.assemble(subset_resources) - # if override remove all frames we are expecting to be rendered + # if override remove all frames we are expecting to be rendered, # so we'll copy only those missing from current render if instance.data.get("overrideExistingFrame"): for frame in range(start, end + 1): @@ -568,18 +607,18 @@ def copy_extend_frames(self, instance, representation): resource_files = [] r_filename = os.path.basename( representation.get("files")[0]) # first file - op = re.search(self.R_FRAME_NUMBER, r_filename) + op = re.search(R_FRAME_NUMBER, r_filename) pre = r_filename[:op.start("frame")] post = r_filename[op.end("frame"):] assert op is not None, "padding string wasn't found" for frame in list(r_col): - fn = re.search(self.R_FRAME_NUMBER, frame) + fn = re.search(R_FRAME_NUMBER, frame) # silencing linter as we need to compare to True, not to # type assert fn is not None, "padding string wasn't found" # list of tuples (source, destination) staging = representation.get("stagingDir") - staging = self.anatomy.fill_root(staging) + staging = anatomy.fill_root(staging) resource_files.append( (frame, os.path.join( staging, "{}{}{}".format(pre, fn["frame"], post))) @@ -596,3 +635,34 @@ def copy_extend_frames(self, instance, representation): log.info(" > {}".format(source[1])) log.info("Finished copying %i files" % len(resource_files)) + + +def attach_instances_to_subset(attach_to, instances): + """Attach instance to subset. + + If we are attaching to other subsets, create copy of existing + instances, change data to match its subset and replace + existing instances with modified data. + + Args: + attach_to (list): List of instances to attach to. + instances (list): List of instances to attach. + + Returns: + list: List of attached instances. + + """ + # + + new_instances = [] + for attach_instance in attach_to: + for i in instances: + new_inst = copy(i) + new_inst["version"] = attach_instance.get("version") + new_inst["subset"] = attach_instance.get("subset") + new_inst["family"] = attach_instance.get("family") + new_inst["append"] = True + # don't set subsetGroup if we are attaching + new_inst.pop("subsetGroup") + new_instances.append(new_inst) + return new_instances diff --git a/openpype/pipeline/farm/pyblish.pyi b/openpype/pipeline/farm/pyblish.pyi index 3667f2d8a5..76f7c34dcd 100644 --- a/openpype/pipeline/farm/pyblish.pyi +++ b/openpype/pipeline/farm/pyblish.pyi @@ -20,4 +20,5 @@ def extend_frames(asset: str, subset: str, start: int, end: int) -> Tuple[int, i def get_time_data_from_instance_or_context(instance: pyblish.api.Instance) -> TimeData: ... def get_transferable_representations(instance: pyblish.api.Instance) -> list: ... def create_skeleton_instance(instance: pyblish.api.Instance, families_transfer: list = ..., instance_transfer: dict = ...) -> dict: ... -def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict) -> List[pyblish.api.Instance]: ... +def create_instances_for_aov(instance: pyblish.api.Instance, skeleton: dict, aov_filter: dict) -> List[pyblish.api.Instance]: ... +def attach_instances_to_subset(attach_to: list, instances: list) -> list: ... From 1f0572aaa0c1e69f8a6b5cf33d20546029bf6f53 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 28 Apr 2023 18:37:56 +0200 Subject: [PATCH 047/144] :recycle: refactor deadline code to make use of abstracted code --- .../plugins/publish/submit_publish_job.py | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 58f34c24b1..86f647dd1b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -23,7 +23,8 @@ from openpype.pipeline.farm.patterning import match_aov_pattern from openpype.lib import is_running_from_build from openpype.pipeline.farm.pyblish import ( create_skeleton_instance, - create_instances_for_aov + create_instances_for_aov, + attach_instances_to_subset ) @@ -551,7 +552,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if isinstance(instance.data.get("expectedFiles")[0], dict): instances = create_instances_for_aov( - instance, instance_skeleton_data) + instance, instance_skeleton_data, self.aov_filter) else: representations = self._get_representations( @@ -566,25 +567,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): instance_skeleton_data["representations"] += representations instances = [instance_skeleton_data] - # if we are attaching to other subsets, create copy of existing - # instances, change data to match its subset and replace - # existing instances with modified data + # attach instances to subset if instance.data.get("attachTo"): - self.log.info("Attaching render to subset:") - new_instances = [] - for at in instance.data.get("attachTo"): - for i in instances: - new_i = copy(i) - new_i["version"] = at.get("version") - new_i["subset"] = at.get("subset") - new_i["family"] = at.get("family") - new_i["append"] = True - # don't set subsetGroup if we are attaching - new_i.pop("subsetGroup") - new_instances.append(new_i) - self.log.info(" - {} / v{}".format( - at.get("subset"), at.get("version"))) - instances = new_instances + instances = attach_instances_to_subset( + instance.data.get("attachTo"), instances + ) r''' SUBMiT PUBLiSH JOB 2 D34DLiN3 ____ From 806b9250c89838b84a33b8fd24849a62dd1f35b7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 18:32:46 +0200 Subject: [PATCH 048/144] Fix wrong family 'rendering' is obsolete --- .../plugins/publish/collect_rr_path_from_instance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index cfb5b78077..e21cd7c39f 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -7,7 +7,7 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder label = "Collect Royal Render path name from the Instance" - families = ["rendering"] + families = ["render"] def process(self, instance): instance.data["rrPathName"] = self._collect_rr_path_name(instance) From ddab9240a1d1c51c06787ae24f9d17767bdb7a28 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 18:35:22 +0200 Subject: [PATCH 049/144] Extract iter_expected_files --- .../plugins/publish/submit_maya_deadline.py | 15 +++------------ .../publish/create_maya_royalrender_job.py | 13 ++----------- .../plugins/publish/create_nuke_deadline_job.py | 7 +++++-- openpype/pipeline/farm/tools.py | 10 ++++++++++ 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index a6cdcb7e71..16fba382e4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -39,6 +39,7 @@ from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build +from openpype.pipeline.farm.tools import iter_expected_files def _validate_deadline_bool_value(instance, attribute, value): @@ -198,7 +199,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): # Add list of expected files to job # --------------------------------- exp = instance.data.get("expectedFiles") - for filepath in self._iter_expected_files(exp): + for filepath in iter_expected_files(exp): job_info.OutputDirectory += os.path.dirname(filepath) job_info.OutputFilename += os.path.basename(filepath) @@ -254,7 +255,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): # TODO: Avoid the need for this logic here, needed for submit publish # Store output dir for unified publisher (filesequence) expected_files = instance.data["expectedFiles"] - first_file = next(self._iter_expected_files(expected_files)) + first_file = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file) instance.data["outputDir"] = output_dir instance.data["toBeRenderedOn"] = "deadline" @@ -772,16 +773,6 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): end=int(self._instance.data["frameEndHandle"]), ) - @staticmethod - def _iter_expected_files(exp): - if isinstance(exp[0], dict): - for _aov, files in exp[0].items(): - for file in files: - yield file - else: - for file in exp: - yield file - def _format_tiles( filename, diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 6e23fb0b74..8461e74d6d 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -11,6 +11,7 @@ from openpype.pipeline.publish.lib import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError from openpype.modules.royalrender.api import Api as rrApi from openpype.modules.royalrender.rr_job import RRJob, CustomAttribute +from openpype.pipeline.farm.tools import iter_expected_files class CreateMayaRoyalRenderJob(InstancePlugin): @@ -43,7 +44,7 @@ class CreateMayaRoyalRenderJob(InstancePlugin): return "linux" expected_files = self._instance.data["expectedFiles"] - first_file = next(self._iter_expected_files(expected_files)) + first_file = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file) self._instance.data["outputDir"] = output_dir workspace = self._instance.context.data["workspaceDir"] @@ -125,16 +126,6 @@ class CreateMayaRoyalRenderJob(InstancePlugin): self._instance.data["rrJobs"] += self.get_job() - @staticmethod - def _iter_expected_files(exp): - if isinstance(exp[0], dict): - for _aov, files in exp[0].items(): - for file in files: - yield file - else: - for file in exp: - yield file - @staticmethod def _resolve_rr_path(context, rr_path_name): # type: (Context, str) -> str diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 9f49294459..217ebed057 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -19,9 +19,11 @@ from openpype.lib import ( BoolDef, NumberDef ) +from openpype.pipeline import OpenPypePyblishPluginMixin +from openpype.pipeline.farm.tools import iter_expected_files -class CreateNukeRoyalRenderJob(InstancePlugin): +class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): label = "Create Nuke Render job in RR" order = IntegratorOrder + 0.1 hosts = ["nuke"] @@ -208,7 +210,8 @@ class CreateNukeRoyalRenderJob(InstancePlugin): # this will append expected files to instance as needed. expected_files = self.expected_files( render_path, start_frame, end_frame) - first_file = next(self._iter_expected_files(expected_files)) + self._instance.data["expectedFiles"].extend(expected_files) + first_file = next(iter_expected_files(expected_files)) job = RRJob( Software="Nuke", diff --git a/openpype/pipeline/farm/tools.py b/openpype/pipeline/farm/tools.py index 506f95d6b2..6f9e0ac393 100644 --- a/openpype/pipeline/farm/tools.py +++ b/openpype/pipeline/farm/tools.py @@ -100,3 +100,13 @@ def from_published_scene(instance, replace_in_path=True): instance.data["publishRenderMetadataFolder"] = metadata_folder return file_path + + +def iter_expected_files(exp): + if isinstance(exp[0], dict): + for _aov, files in exp[0].items(): + for file in files: + yield file + else: + for file in exp: + yield file From 7fe4820bec138f5f740793f3d51a346176dece49 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 18:37:49 +0200 Subject: [PATCH 050/144] Renamed file Original name collided in Nuke (Python2). --- .../farm/{pyblish.py => pyblish_functions.py} | 20 ++++++++++--------- .../{pyblish.pyi => pyblish_functions.pyi} | 0 2 files changed, 11 insertions(+), 9 deletions(-) rename openpype/pipeline/farm/{pyblish.py => pyblish_functions.py} (99%) rename openpype/pipeline/farm/{pyblish.pyi => pyblish_functions.pyi} (100%) diff --git a/openpype/pipeline/farm/pyblish.py b/openpype/pipeline/farm/pyblish_functions.py similarity index 99% rename from openpype/pipeline/farm/pyblish.py rename to openpype/pipeline/farm/pyblish_functions.py index e5ebd3666c..84d958bea1 100644 --- a/openpype/pipeline/farm/pyblish.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -1,3 +1,12 @@ +import copy +import attr +from pyblish.api import Instance +import os +import clique +from copy import deepcopy +import re +import warnings + from openpype.pipeline import ( get_current_project_name, get_representation_path, @@ -8,19 +17,12 @@ from openpype.client import ( get_representations ) from openpype.lib import Logger -import attr -import pyblish.api from openpype.pipeline.publish import KnownPublishError from openpype.pipeline.farm.patterning import match_aov_pattern -import os -import clique -from copy import deepcopy -import re -import warnings @attr.s -class TimeData: +class TimeData(object): """Structure used to handle time related data.""" start = attr.ib(type=int) end = attr.ib(type=int) @@ -160,7 +162,7 @@ def get_transferable_representations(instance): def create_skeleton_instance( instance, families_transfer=None, instance_transfer=None): - # type: (pyblish.api.Instance, list, dict) -> dict + # type: (Instance, list, dict) -> dict """Create skeleton instance from original instance data. This will create dictionary containing skeleton diff --git a/openpype/pipeline/farm/pyblish.pyi b/openpype/pipeline/farm/pyblish_functions.pyi similarity index 100% rename from openpype/pipeline/farm/pyblish.pyi rename to openpype/pipeline/farm/pyblish_functions.pyi From 266d34bebb1e1ab07554edadee556da14a2d84a5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 18:38:55 +0200 Subject: [PATCH 051/144] Fix querying anatomy Should be from context, not on instance anymore. --- openpype/pipeline/farm/pyblish_functions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 84d958bea1..a2eaddbba6 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -136,7 +136,7 @@ def get_transferable_representations(instance): list of dicts: List of transferable representations. """ - anatomy = instance.data.get("anatomy") # type: Anatomy + anatomy = instance.context.data["anatomy"] # type: Anatomy to_transfer = [] for representation in instance.data.get("representations", []): @@ -187,7 +187,7 @@ def create_skeleton_instance( context = instance.context data = instance.data.copy() - anatomy = data["anatomy"] # type: Anatomy + anatomy = instance.context.data["anatomy"] # type: Anatomy families = [data["family"]] @@ -363,7 +363,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data): # TODO: this needs to be taking the task from context or instance task = os.environ["AVALON_TASK"] - anatomy = instance.data["anatomy"] + anatomy = instance.context.data["anatomy"] subset = skeleton["subset"] cameras = instance.data.get("cameras", []) exp_files = instance.data["expectedFiles"] @@ -578,7 +578,7 @@ def copy_extend_frames(instance, representation): start = instance.data.get("frameStart") end = instance.data.get("frameEnd") project_name = instance.context.data["project"] - anatomy = instance.data["anatomy"] # type: Anatomy + anatomy = instance.context.data["anatomy"] # type: Anatomy # get latest version of subset # this will stop if subset wasn't published yet From 79132763ab9bd4cb00b1e21bb8ace6381120d01a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 18:39:29 +0200 Subject: [PATCH 052/144] Fix typo, wrong parentheses --- openpype/pipeline/farm/pyblish_functions.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index a2eaddbba6..f4c1ea2f27 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -109,16 +109,16 @@ def get_time_data_from_instance_or_context(instance): """ return TimeData( - start=instance.data.get["frameStart"] or \ - instance.context.data.get("frameStart"), - end=instance.data.get("frameEnd") or \ - instance.context.data.get("frameEnd"), - fps=instance.data.get("fps") or \ - instance.context.data.get("fps"), - handle_start=instance.data.get("handleStart") or \ - instance.context.data.get("handleStart"), # noqa: E501 - handle_end=instance.data.get("handleStart") or \ - instance.context.data.get("handleStart") + start=(instance.data.get("frameStart") or + instance.context.data.get("frameStart")), + end=(instance.data.get("frameEnd") or + instance.context.data.get("frameEnd")), + fps=(instance.data.get("fps") or + instance.context.data.get("fps")), + handle_start=(instance.data.get("handleStart") or + instance.context.data.get("handleStart")), # noqa: E501 + handle_end=(instance.data.get("handleStart") or + instance.context.data.get("handleStart")) ) From 87eb1edf891fb8f8cfe968654dc65ace58afbd84 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 18:40:47 +0200 Subject: [PATCH 053/144] Fix wrong method --- openpype/pipeline/farm/pyblish_functions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index f4c1ea2f27..645b31b2de 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -659,7 +659,7 @@ def attach_instances_to_subset(attach_to, instances): new_instances = [] for attach_instance in attach_to: for i in instances: - new_inst = copy(i) + new_inst = copy.deepcopy(i) new_inst["version"] = attach_instance.get("version") new_inst["subset"] = attach_instance.get("subset") new_inst["family"] = attach_instance.get("family") From c284908cb42244236a8d439b6e7178c101b715f3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 18:48:36 +0200 Subject: [PATCH 054/144] Fix attr.s for Python2 --- openpype/modules/royalrender/rr_job.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index 5f034e74a1..8d96b8ff4a 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -38,7 +38,7 @@ class RREnvList(dict): @attr.s -class RRJob: +class RRJob(object): """Mapping of Royal Render job file to a data class.""" # Required @@ -197,7 +197,7 @@ class SubmitterParameter: @attr.s -class SubmitFile: +class SubmitFile(object): """Class wrapping Royal Render submission XML file.""" # Syntax version of the submission file. From 1b4f452301436131c0024e80b5b80538861ceba8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 3 May 2023 19:23:58 +0200 Subject: [PATCH 055/144] Extracted prepare_representations Various fixes. WIP --- .../plugins/publish/submit_publish_job.py | 149 ++--------------- .../publish/create_nuke_deadline_job.py | 48 +++++- .../publish/create_publish_royalrender_job.py | 32 +++- .../publish/submit_jobs_to_royalrender.py | 2 +- openpype/pipeline/farm/pyblish_functions.py | 156 +++++++++++++++++- 5 files changed, 230 insertions(+), 157 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 86f647dd1b..58a0cd7219 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -21,10 +21,11 @@ from openpype.pipeline import ( from openpype.tests.lib import is_in_tests from openpype.pipeline.farm.patterning import match_aov_pattern from openpype.lib import is_running_from_build -from openpype.pipeline.farm.pyblish import ( +from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, create_instances_for_aov, - attach_instances_to_subset + attach_instances_to_subset, + prepare_representations ) @@ -332,140 +333,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): return deadline_publish_job_id - def _get_representations(self, instance, exp_files): - """Create representations for file sequences. - - This will return representations of expected files if they are not - in hierarchy of aovs. There should be only one sequence of files for - most cases, but if not - we create representation from each of them. - - Arguments: - instance (dict): instance data for which we are - setting representations - exp_files (list): list of expected files - - Returns: - list of representations - - """ - representations = [] - host_name = os.environ.get("AVALON_APP", "") - collections, remainders = clique.assemble(exp_files) - - # create representation for every collected sequence - for collection in collections: - ext = collection.tail.lstrip(".") - preview = False - # TODO 'useSequenceForReview' is temporary solution which does - # not work for 100% of cases. We must be able to tell what - # expected files contains more explicitly and from what - # should be review made. - # - "review" tag is never added when is set to 'False' - if instance["useSequenceForReview"]: - # toggle preview on if multipart is on - if instance.get("multipartExr", False): - self.log.debug( - "Adding preview tag because its multipartExr" - ) - preview = True - else: - render_file_name = list(collection)[0] - # if filtered aov name is found in filename, toggle it for - # preview video rendering - preview = match_aov_pattern( - host_name, self.aov_filter, render_file_name - ) - - staging = os.path.dirname(list(collection)[0]) - success, rootless_staging_dir = ( - self.anatomy.find_root_template_from_path(staging) - ) - if success: - staging = rootless_staging_dir - else: - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(staging)) - - frame_start = int(instance.get("frameStartHandle")) - if instance.get("slate"): - frame_start -= 1 - - rep = { - "name": ext, - "ext": ext, - "files": [os.path.basename(f) for f in list(collection)], - "frameStart": frame_start, - "frameEnd": int(instance.get("frameEndHandle")), - # If expectedFile are absolute, we need only filenames - "stagingDir": staging, - "fps": instance.get("fps"), - "tags": ["review"] if preview else [], - } - - # poor man exclusion - if ext in self.skip_integration_repre_list: - rep["tags"].append("delete") - - if instance.get("multipartExr", False): - rep["tags"].append("multipartExr") - - # support conversion from tiled to scanline - if instance.get("convertToScanline"): - self.log.info("Adding scanline conversion.") - rep["tags"].append("toScanline") - - representations.append(rep) - - self._solve_families(instance, preview) - - # add remainders as representations - for remainder in remainders: - ext = remainder.split(".")[-1] - - staging = os.path.dirname(remainder) - success, rootless_staging_dir = ( - self.anatomy.find_root_template_from_path(staging) - ) - if success: - staging = rootless_staging_dir - else: - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(staging)) - - rep = { - "name": ext, - "ext": ext, - "files": os.path.basename(remainder), - "stagingDir": staging, - } - - preview = match_aov_pattern( - host_name, self.aov_filter, remainder - ) - if preview: - rep.update({ - "fps": instance.get("fps"), - "tags": ["review"] - }) - self._solve_families(instance, preview) - - already_there = False - for repre in instance.get("representations", []): - # might be added explicitly before by publish_on_farm - already_there = repre.get("files") == rep["files"] - if already_there: - self.log.debug("repre {} already_there".format(repre)) - break - - if not already_there: - representations.append(rep) - - return representations - def _solve_families(self, instance, preview=False): families = instance.get("families") @@ -552,12 +419,16 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if isinstance(instance.data.get("expectedFiles")[0], dict): instances = create_instances_for_aov( - instance, instance_skeleton_data, self.aov_filter) + instance, instance_skeleton_data, + self.aov_filter, self.skip_integration_repre_list) else: - representations = self._get_representations( + representations = prepare_representations( instance_skeleton_data, - instance.data.get("expectedFiles") + instance.data.get("expectedFiles"), + self.anatomy, + self.aov_filter, + self.skip_integration_repre_list ) if "representations" not in instance_skeleton_data.keys(): diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 217ebed057..0472f2ea80 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -1,14 +1,13 @@ # -*- coding: utf-8 -*- """Submitting render job to RoyalRender.""" +import copy import os -import sys import re import platform from datetime import datetime from pyblish.api import InstancePlugin, IntegratorOrder, Context from openpype.tests.lib import is_in_tests -from openpype.lib import is_running_from_build from openpype.pipeline.publish.lib import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError from openpype.modules.royalrender.api import Api as rrApi @@ -34,6 +33,8 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): priority = 50 chunk_size = 1 concurrent_tasks = 1 + use_gpu = True + use_published = True @classmethod def get_attribute_defs(cls): @@ -69,6 +70,11 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): "suspend_publish", default=False, label="Suspend publish" + ), + BoolDef( + "use_published", + default=cls.use_published, + label="Use published workfile" ) ] @@ -81,6 +87,17 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): self.rr_api = None def process(self, instance): + # import json + # def _default_json(value): + # return str(value) + # filepath = "C:\\Users\\petrk\\PycharmProjects\\Pype3.0\\pype\\tests\\unit\\openpype\\modules\\royalrender\\plugins\\publish\\resources\\instance.json" + # with open(filepath, "w") as f: + # f.write(json.dumps(instance.data, indent=4, default=_default_json)) + # + # filepath = "C:\\Users\\petrk\\PycharmProjects\\Pype3.0\\pype\\tests\\unit\\openpype\\modules\\royalrender\\plugins\\publish\\resources\\context.json" + # with open(filepath, "w") as f: + # f.write(json.dumps(instance.context.data, indent=4, default=_default_json)) + if not instance.data.get("farm"): self.log.info("Skipping local instance.") return @@ -93,6 +110,7 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): "suspend_publish"] context = instance.context + self._instance = instance self._rr_root = self._resolve_rr_path(context, instance.data.get( "rrPathName")) # noqa @@ -218,7 +236,7 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): Renderer="", SeqStart=int(start_frame), SeqEnd=int(end_frame), - SeqStep=int(self._instance.data.get("byFrameStep"), 1), + SeqStep=int(self._instance.data.get("byFrameStep", 1)), SeqFileOffset=0, Version=nuke_version.group(), SceneName=script_path, @@ -298,7 +316,7 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): if "%" not in file: expected_files.append(path) - return + return expected_files if self._instance.data.get("slate"): start_frame -= 1 @@ -308,3 +326,25 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): for i in range(start_frame, (end_frame + 1)) ) return expected_files + + def preview_fname(self, path): + """Return output file path with #### for padding. + + Deadline requires the path to be formatted with # in place of numbers. + For example `/path/to/render.####.png` + + Args: + path (str): path to rendered images + + Returns: + str + + """ + self.log.debug("_ path: `{}`".format(path)) + if "%" in path: + search_results = re.search(r"(%0)(\d)(d.)", path).groups() + self.log.debug("_ search_results: `{}`".format(search_results)) + return int(search_results[1]) + if "#" in path: + self.log.debug("_ path: `{}`".format(path)) + return path diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index b1c84c87b9..6f0bc995d0 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -11,10 +11,11 @@ from openpype.modules.royalrender.rr_job import RRJob, RREnvList from openpype.pipeline.publish import KnownPublishError from openpype.lib.openpype_version import ( get_OpenPypeVersion, get_openpype_version) -from openpype.pipeline.farm.pyblish import ( +from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, create_instances_for_aov, - attach_instances_to_subset + attach_instances_to_subset, + prepare_representations ) @@ -31,6 +32,20 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "harmony": [r".*"], # for everything from AE "celaction": [r".*"]} + skip_integration_repre_list = [] + + # mapping of instance properties to be transferred to new instance + # for every specified family + instance_transfer = { + "slate": ["slateFrames", "slate"], + "review": ["lutPath"], + "render2d": ["bakingNukeScripts", "version"], + "renderlayer": ["convertToScanline"] + } + + # list of family names to transfer to new family if present + families_transfer = ["render3d", "render2d", "ftrack", "slate"] + def process(self, instance): # data = instance.data.copy() context = instance.context @@ -46,15 +61,18 @@ class CreatePublishRoyalRenderJob(InstancePlugin): families_transfer=self.families_transfer, instance_transfer=self.instance_transfer) - instances = None if isinstance(instance.data.get("expectedFiles")[0], dict): instances = create_instances_for_aov( - instance, instance_skeleton_data, self.aov_filter) + instance, instance_skeleton_data, + self.aov_filter, self.skip_integration_repre_list) else: - representations = self._get_representations( + representations = prepare_representations( instance_skeleton_data, - instance.data.get("expectedFiles") + instance.data.get("expectedFiles"), + self.anatomy, + self.aov_filter, + self.skip_integration_repre_list ) if "representations" not in instance_skeleton_data.keys(): @@ -104,7 +122,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): RRJob: RoyalRender publish job. """ - data = instance.data.copy() + data = deepcopy(instance.data) subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py index 325fb36993..8546554372 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -11,7 +11,7 @@ from openpype.pipeline.publish import KnownPublishError class SubmitJobsToRoyalRender(ContextPlugin): """Find all jobs, create submission XML and submit it to RoyalRender.""" label = "Submit jobs to RoyalRender" - order = IntegratorOrder + 0.1 + order = IntegratorOrder + 0.3 targets = ["local"] def __init__(self): diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 645b31b2de..792cc07f02 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -284,7 +284,150 @@ def _solve_families(families): return families -def create_instances_for_aov(instance, skeleton, aov_filter): +def prepare_representations(instance, exp_files, anatomy, aov_filter, + skip_integration_repre_list): + """Create representations for file sequences. + + This will return representations of expected files if they are not + in hierarchy of aovs. There should be only one sequence of files for + most cases, but if not - we create representation from each of them. + + Arguments: + instance (dict): instance data for which we are + setting representations + exp_files (list): list of expected files + anatomy (Anatomy): + aov_filter (dict): add review for specific aov names + skip_integration_repre_list (list): exclude specific extensions + + Returns: + list of representations + + """ + representations = [] + host_name = os.environ.get("AVALON_APP", "") + collections, remainders = clique.assemble(exp_files) + + log = Logger.get_logger("farm_publishing") + + # create representation for every collected sequence + for collection in collections: + ext = collection.tail.lstrip(".") + preview = False + # TODO 'useSequenceForReview' is temporary solution which does + # not work for 100% of cases. We must be able to tell what + # expected files contains more explicitly and from what + # should be review made. + # - "review" tag is never added when is set to 'False' + if instance["useSequenceForReview"]: + # toggle preview on if multipart is on + if instance.get("multipartExr", False): + log.debug( + "Adding preview tag because its multipartExr" + ) + preview = True + else: + render_file_name = list(collection)[0] + # if filtered aov name is found in filename, toggle it for + # preview video rendering + preview = match_aov_pattern( + host_name, aov_filter, render_file_name + ) + + staging = os.path.dirname(list(collection)[0]) + success, rootless_staging_dir = ( + anatomy.find_root_template_from_path(staging) + ) + if success: + staging = rootless_staging_dir + else: + log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues on farm." + ).format(staging)) + + frame_start = int(instance.get("frameStartHandle")) + if instance.get("slate"): + frame_start -= 1 + + rep = { + "name": ext, + "ext": ext, + "files": [os.path.basename(f) for f in list(collection)], + "frameStart": frame_start, + "frameEnd": int(instance.get("frameEndHandle")), + # If expectedFile are absolute, we need only filenames + "stagingDir": staging, + "fps": instance.get("fps"), + "tags": ["review"] if preview else [], + } + + # poor man exclusion + if ext in skip_integration_repre_list: + rep["tags"].append("delete") + + if instance.get("multipartExr", False): + rep["tags"].append("multipartExr") + + # support conversion from tiled to scanline + if instance.get("convertToScanline"): + log.info("Adding scanline conversion.") + rep["tags"].append("toScanline") + + representations.append(rep) + + if preview: + instance["families"] = _solve_families(instance["families"]) + + # add remainders as representations + for remainder in remainders: + ext = remainder.split(".")[-1] + + staging = os.path.dirname(remainder) + success, rootless_staging_dir = ( + anatomy.find_root_template_from_path(staging) + ) + if success: + staging = rootless_staging_dir + else: + log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues on farm." + ).format(staging)) + + rep = { + "name": ext, + "ext": ext, + "files": os.path.basename(remainder), + "stagingDir": staging, + } + + preview = match_aov_pattern( + host_name, aov_filter, remainder + ) + if preview: + rep.update({ + "fps": instance.get("fps"), + "tags": ["review"] + }) + instance["families"] = _solve_families(instance["families"]) + + already_there = False + for repre in instance.get("representations", []): + # might be added explicitly before by publish_on_farm + already_there = repre.get("files") == rep["files"] + if already_there: + log.debug("repre {} already_there".format(repre)) + break + + if not already_there: + representations.append(rep) + + return representations + + +def create_instances_for_aov(instance, skeleton, aov_filter, + skip_integration_repre_list): """Create instances from AOVs. This will create new pyblish.api.Instances by going over expected @@ -336,11 +479,13 @@ def create_instances_for_aov(instance, skeleton, aov_filter): instance, skeleton, aov_filter, - additional_color_data + additional_color_data, + skip_integration_repre_list ) -def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data): +def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, + skip_integration_repre_list): """Create instance for each AOV found. This will create new instance for every AOV it can detect in expected @@ -427,7 +572,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data): render_file_name = os.path.basename(col[0]) else: render_file_name = os.path.basename(col) - aov_patterns = self.aov_filter + aov_patterns = aov_filter preview = match_aov_pattern(app, aov_patterns, render_file_name) # toggle preview on if multipart is on @@ -436,7 +581,6 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data): log.debug("Adding preview tag because its multipartExr") preview = True - new_instance = deepcopy(skeleton) new_instance["subsetGroup"] = group_name if preview: @@ -483,7 +627,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data): rep["tags"].append("toScanline") # poor man exclusion - if ext in self.skip_integration_repre_list: + if ext in skip_integration_repre_list: rep["tags"].append("delete") if preview: From fb06a2e6819ae1e7696306c48457c9d6f681ab07 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 12:59:46 +0200 Subject: [PATCH 056/144] Extracted create_metadata_path --- .../plugins/publish/submit_publish_job.py | 43 +++---------------- .../publish/create_publish_royalrender_job.py | 12 +++--- openpype/pipeline/farm/pyblish_functions.py | 33 ++++++++++++++ 3 files changed, 45 insertions(+), 43 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 58a0cd7219..ff6bcf1801 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- """Submit publishing job to farm.""" - import os import json import re @@ -12,20 +11,18 @@ import pyblish.api from openpype.client import ( get_last_version_by_subset_name, - get_representations, ) from openpype.pipeline import ( - get_representation_path, legacy_io, ) from openpype.tests.lib import is_in_tests -from openpype.pipeline.farm.patterning import match_aov_pattern from openpype.lib import is_running_from_build from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, create_instances_for_aov, attach_instances_to_subset, - prepare_representations + prepare_representations, + create_metadata_path ) @@ -154,36 +151,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # poor man exclusion skip_integration_repre_list = [] - def _create_metadata_path(self, instance): - ins_data = instance.data - # Ensure output dir exists - output_dir = ins_data.get( - "publishRenderMetadataFolder", ins_data["outputDir"]) - - try: - if not os.path.isdir(output_dir): - os.makedirs(output_dir) - except OSError: - # directory is not available - self.log.warning("Path is unreachable: `{}`".format(output_dir)) - - metadata_filename = "{}_metadata.json".format(ins_data["subset"]) - - metadata_path = os.path.join(output_dir, metadata_filename) - - # Convert output dir to `{root}/rest/of/path/...` with Anatomy - success, rootless_mtdt_p = self.anatomy.find_root_template_from_path( - metadata_path) - if not success: - # `rootless_path` is not set to `output_dir` if none of roots match - self.log.warning(( - "Could not find root path for remapping \"{}\"." - " This may cause issues on farm." - ).format(output_dir)) - rootless_mtdt_p = metadata_path - - return metadata_path, rootless_mtdt_p - def _submit_deadline_post_job(self, instance, job, instances): """Submit publish job to Deadline. @@ -216,7 +183,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # Transfer the environment from the original job to this dependent # job so they use the same environment metadata_path, rootless_metadata_path = \ - self._create_metadata_path(instance) + create_metadata_path(instance, self.anatomy) environment = { "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], @@ -539,8 +506,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): } publish_job.update({"ftrack": ftrack}) - metadata_path, rootless_metadata_path = self._create_metadata_path( - instance) + metadata_path, rootless_metadata_path = \ + create_metadata_path(instance, self.anatomy) self.log.info("Writing json file: {}".format(metadata_path)) with open(metadata_path, "w") as f: diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 6f0bc995d0..5a64e5a9b7 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -15,7 +15,8 @@ from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, create_instances_for_aov, attach_instances_to_subset, - prepare_representations + prepare_representations, + create_metadata_path ) @@ -100,8 +101,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): instance.data["rrJobs"] += publish_job - metadata_path, rootless_metadata_path = self._create_metadata_path( - instance) + metadata_path, rootless_metadata_path = \ + create_metadata_path(instance, self.anatomy) self.log.info("Writing json file: {}".format(metadata_path)) with open(metadata_path, "w") as f: @@ -122,7 +123,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): RRJob: RoyalRender publish job. """ - data = deepcopy(instance.data) + # data = deepcopy(instance.data) + data = instance.data subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) @@ -132,7 +134,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): # Transfer the environment from the original job to this dependent # job, so they use the same environment metadata_path, roothless_metadata_path = \ - self._create_metadata_path(instance) + create_metadata_path(instance, self.anatomy) environment = RREnvList({ "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 792cc07f02..6c08545b1b 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -812,3 +812,36 @@ def attach_instances_to_subset(attach_to, instances): new_inst.pop("subsetGroup") new_instances.append(new_inst) return new_instances + + +def create_metadata_path(instance, anatomy): + ins_data = instance.data + # Ensure output dir exists + output_dir = ins_data.get( + "publishRenderMetadataFolder", ins_data["outputDir"]) + + log = Logger.get_logger("farm_publishing") + + try: + if not os.path.isdir(output_dir): + os.makedirs(output_dir) + except OSError: + # directory is not available + log.warning("Path is unreachable: `{}`".format(output_dir)) + + metadata_filename = "{}_metadata.json".format(ins_data["subset"]) + + metadata_path = os.path.join(output_dir, metadata_filename) + + # Convert output dir to `{root}/rest/of/path/...` with Anatomy + success, rootless_mtdt_p = anatomy.find_root_template_from_path( + metadata_path) + if not success: + # `rootless_path` is not set to `output_dir` if none of roots match + log.warning(( + "Could not find root path for remapping \"{}\"." + " This may cause issues on farm." + ).format(output_dir)) + rootless_mtdt_p = metadata_path + + return metadata_path, rootless_mtdt_p From dded3e1369c365e13035484209336232b35c11ff Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 14:01:11 +0200 Subject: [PATCH 057/144] Added missing variables --- .../publish/create_publish_royalrender_job.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 5a64e5a9b7..334f3a5718 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -47,6 +47,20 @@ class CreatePublishRoyalRenderJob(InstancePlugin): # list of family names to transfer to new family if present families_transfer = ["render3d", "render2d", "ftrack", "slate"] + environ_job_filter = [ + "OPENPYPE_METADATA_FILE" + ] + + environ_keys = [ + "FTRACK_API_USER", + "FTRACK_API_KEY", + "FTRACK_SERVER", + "AVALON_APP_NAME", + "OPENPYPE_USERNAME", + "OPENPYPE_SG_USER", + ] + priority = 50 + def process(self, instance): # data = instance.data.copy() context = instance.context From 3bbab4511fd5f9f30dc46ab542e79dd92e96af14 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 14:01:58 +0200 Subject: [PATCH 058/144] Removed usage of legacy_io --- .../plugins/publish/create_publish_royalrender_job.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 334f3a5718..0f0001aced 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -150,11 +150,13 @@ class CreatePublishRoyalRenderJob(InstancePlugin): metadata_path, roothless_metadata_path = \ create_metadata_path(instance, self.anatomy) + anatomy_data = instance.context.data["anatomyData"] + environment = RREnvList({ - "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], - "AVALON_ASSET": legacy_io.Session["AVALON_ASSET"], - "AVALON_TASK": legacy_io.Session["AVALON_TASK"], - "OPENPYPE_USERNAME": instance.context.data["user"], + "AVALON_PROJECT": anatomy_data["project"]["name"], + "AVALON_ASSET": anatomy_data["asset"], + "AVALON_TASK": anatomy_data["task"]["name"], + "OPENPYPE_USERNAME": anatomy_data["user"], "OPENPYPE_PUBLISH_JOB": "1", "OPENPYPE_RENDER_JOB": "0", "OPENPYPE_REMOTE_JOB": "0", From 9a95fac77c29d052facb3fce78915a85704a7eba Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 14:03:13 +0200 Subject: [PATCH 059/144] Changed to use os env OPENPYPE_VERSION get_OpenpypeVersion() returns None in hosts(or at least in Nuke) --- .../plugins/publish/create_publish_royalrender_job.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 0f0001aced..4a889ebea6 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -6,7 +6,6 @@ import json from pyblish.api import InstancePlugin, IntegratorOrder, Instance -from openpype.pipeline import legacy_io from openpype.modules.royalrender.rr_job import RRJob, RREnvList from openpype.pipeline.publish import KnownPublishError from openpype.lib.openpype_version import ( @@ -199,8 +198,6 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "--targets", "farm" ] - openpype_version = get_OpenPypeVersion() - current_version = openpype_version(version=get_openpype_version()) job = RRJob( Software="OpenPype", Renderer="Once", @@ -209,8 +206,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): SeqEnd=1, SeqStep=1, SeqFileOffset=0, - Version="{}.{}".format( - current_version.major(), current_version.minor()), + Version=os.environ.get("OPENPYPE_VERSION"), # executable SceneName=roothless_metadata_path, # command line arguments From cace4f23772cab9c0d0ad3c667a702d93b27fd93 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 14:09:50 +0200 Subject: [PATCH 060/144] Used correct function --- .../plugins/publish/create_publish_royalrender_job.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 4a889ebea6..ab1b1e881e 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -6,7 +6,11 @@ import json from pyblish.api import InstancePlugin, IntegratorOrder, Instance -from openpype.modules.royalrender.rr_job import RRJob, RREnvList +from openpype.modules.royalrender.rr_job import ( + RRJob, + RREnvList, + get_rr_platform +) from openpype.pipeline.publish import KnownPublishError from openpype.lib.openpype_version import ( get_OpenPypeVersion, get_openpype_version) @@ -216,7 +220,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): ImageDir="", ImageExtension="", ImagePreNumberLetter="", - SceneOS=RRJob.get_rr_platform(), + SceneOS=get_rr_platform(), rrEnvList=environment.serialize(), Priority=priority ) From b18e6ad431022af757a06e989205cb23fb99e761 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 14:50:42 +0200 Subject: [PATCH 061/144] Proper serialization Without it json would complain. --- .../plugins/publish/create_publish_royalrender_job.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index ab1b1e881e..602d578a4b 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Create publishing job on RoyalRender.""" import os +import attr from copy import deepcopy import json @@ -123,7 +124,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): self.log.info("Writing json file: {}".format(metadata_path)) with open(metadata_path, "w") as f: - json.dump(publish_job, f, indent=4, sort_keys=True) + json.dump(attr.asdict(publish_job), f, indent=4, sort_keys=True) def get_job(self, instance, instances): """Create RR publishing job. From 050c11ee2b30861d77805de76f5aa75b25824f14 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 14:57:53 +0200 Subject: [PATCH 062/144] Fix adding --- .../plugins/publish/create_publish_royalrender_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 602d578a4b..a74c528676 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -117,7 +117,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): publish_job = self.get_job(instance, instances) - instance.data["rrJobs"] += publish_job + instance.data["rrJobs"].append(publish_job) metadata_path, rootless_metadata_path = \ create_metadata_path(instance, self.anatomy) From c8451d142939a5b78a5ee7642768e955654d3a30 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 15:26:57 +0200 Subject: [PATCH 063/144] Fix copy Not sure if it shouldn't be deepcopy, but that doesn't work as something cannot be pickled. --- .../plugins/publish/create_publish_royalrender_job.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index a74c528676..2acd3ba4a5 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -141,8 +141,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): RRJob: RoyalRender publish job. """ - # data = deepcopy(instance.data) - data = instance.data + data = instance.data.copy() subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) From ac02eac861838435b1a752af89de551ebf534316 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 4 May 2023 15:32:48 +0200 Subject: [PATCH 064/144] Small fixes --- .../royalrender/plugins/publish/create_nuke_deadline_job.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 0472f2ea80..4a2821e195 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -142,7 +142,7 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): if not self._instance.data.get("rrJobs"): self._instance.data["rrJobs"] = [] - self._instance.data["rrJobs"] += self.create_jobs() + self._instance.data["rrJobs"].extend(self.create_jobs()) # redefinition of families if "render" in self._instance.data["family"]: @@ -155,7 +155,6 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): self._instance.data["outputDir"] = os.path.dirname( self._instance.data["path"]).replace("\\", "/") - def create_jobs(self): submit_frame_start = int(self._instance.data["frameStartHandle"]) submit_frame_end = int(self._instance.data["frameEndHandle"]) @@ -256,6 +255,8 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): CustomAttributes=custom_attributes ) + return job + @staticmethod def _resolve_rr_path(context, rr_path_name): # type: (Context, str) -> str From 4e455ead316fbc334502be51bde30e5eca0f9e3c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 5 May 2023 13:37:55 +0200 Subject: [PATCH 065/144] Instance data might not contain "publish" key In that case publish is implicit, more likely it will be set explicitly to False than not existing at all. --- openpype/pipeline/publish/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 58d9fd8a05..cf52c113c3 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -804,7 +804,7 @@ def get_published_workfile_instance(context): # test if there is instance of workfile waiting # to be published. - if i.data["publish"] is not True: + if not i.data.get("publish", True): continue return i From 8858c0c0dab5622be8816a169d785a7227e266b6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 5 May 2023 14:09:13 +0200 Subject: [PATCH 066/144] Get proper rrPathName Instance could be picked non-deterministically, without rrPathName value. --- .../plugins/publish/submit_jobs_to_royalrender.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py index 8546554372..29d5fe6d72 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -34,6 +34,7 @@ class SubmitJobsToRoyalRender(ContextPlugin): # iterate over all instances and try to find RRJobs jobs = [] + instance_rr_path = None for instance in context: if isinstance(instance.data.get("rrJob"), RRJob): jobs.append(instance.data.get("rrJob")) @@ -42,10 +43,11 @@ class SubmitJobsToRoyalRender(ContextPlugin): isinstance(job, RRJob) for job in instance.data.get("rrJobs")): jobs += instance.data.get("rrJobs") + if instance.data.get("rrPathName"): + instance_rr_path = instance.data["rrPathName"] if jobs: - self._rr_root = self._resolve_rr_path( - context, instance.data.get("rrPathName")) # noqa + self._rr_root = self._resolve_rr_path(context, instance_rr_path) if not self._rr_root: raise KnownPublishError( ("Missing RoyalRender root. " From d8e8e10806d7428aefd7f808f53c4f3ca7449f64 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 9 May 2023 16:38:40 +0200 Subject: [PATCH 067/144] Remove unwanted logging code --- .../plugins/publish/create_nuke_deadline_job.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 4a2821e195..8049398845 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -87,17 +87,6 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): self.rr_api = None def process(self, instance): - # import json - # def _default_json(value): - # return str(value) - # filepath = "C:\\Users\\petrk\\PycharmProjects\\Pype3.0\\pype\\tests\\unit\\openpype\\modules\\royalrender\\plugins\\publish\\resources\\instance.json" - # with open(filepath, "w") as f: - # f.write(json.dumps(instance.data, indent=4, default=_default_json)) - # - # filepath = "C:\\Users\\petrk\\PycharmProjects\\Pype3.0\\pype\\tests\\unit\\openpype\\modules\\royalrender\\plugins\\publish\\resources\\context.json" - # with open(filepath, "w") as f: - # f.write(json.dumps(instance.context.data, indent=4, default=_default_json)) - if not instance.data.get("farm"): self.log.info("Skipping local instance.") return From 45894da8dcecc49d06c4c582f52dcbcf4b9190a4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 9 May 2023 18:46:10 +0200 Subject: [PATCH 068/144] Fix frame placeholder Without it RR won't find rendered files. --- .../plugins/publish/create_nuke_deadline_job.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 8049398845..699f665468 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -16,7 +16,8 @@ from openpype.modules.royalrender.rr_job import ( from openpype.lib import ( is_running_from_build, BoolDef, - NumberDef + NumberDef, + collect_frames ) from openpype.pipeline import OpenPypePyblishPluginMixin from openpype.pipeline.farm.tools import iter_expected_files @@ -219,6 +220,10 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): self._instance.data["expectedFiles"].extend(expected_files) first_file = next(iter_expected_files(expected_files)) + file_name, file_ext = os.path.splitext(os.path.basename(first_file)) + frame_pattern = ".{}".format(start_frame) + file_name = file_name.replace(frame_pattern, '.#') + job = RRJob( Software="Nuke", Renderer="", @@ -230,9 +235,9 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): SceneName=script_path, IsActive=True, ImageDir=render_dir.replace("\\", "/"), - ImageFilename="{}.".format(os.path.splitext(first_file)[0]), - ImageExtension=os.path.splitext(first_file)[1], - ImagePreNumberLetter=".", + ImageFilename="{}".format(file_name), + ImageExtension=file_ext, + ImagePreNumberLetter="", ImageSingleOutputFile=False, SceneOS=get_rr_platform(), Layer=node_name, From 97043fe3107c6c8d15982b80563d6466af6bbfc7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 9 May 2023 18:47:16 +0200 Subject: [PATCH 069/144] Use absolute path instead of rootless Rootless path will result jobs won't show up in rrControl. --- .../plugins/publish/create_publish_royalrender_job.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 2acd3ba4a5..29e467e46a 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -66,7 +66,6 @@ class CreatePublishRoyalRenderJob(InstancePlugin): priority = 50 def process(self, instance): - # data = instance.data.copy() context = instance.context self.context = context self.anatomy = instance.context.data["anatomy"] @@ -150,7 +149,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): # Transfer the environment from the original job to this dependent # job, so they use the same environment - metadata_path, roothless_metadata_path = \ + metadata_path, rootless_metadata_path = \ create_metadata_path(instance, self.anatomy) anatomy_data = instance.context.data["anatomyData"] @@ -194,10 +193,13 @@ class CreatePublishRoyalRenderJob(InstancePlugin): priority = self.priority or instance.data.get("priority", 50) + ## rr requires absolut path or all jobs won't show up in rControl + abs_metadata_path = self.anatomy.fill_root(rootless_metadata_path) + args = [ "--headless", 'publish', - roothless_metadata_path, + abs_metadata_path, "--targets", "deadline", "--targets", "farm" ] @@ -212,7 +214,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): SeqFileOffset=0, Version=os.environ.get("OPENPYPE_VERSION"), # executable - SceneName=roothless_metadata_path, + SceneName=abs_metadata_path, # command line arguments CustomAddCmdFlags=" ".join(args), IsActive=True, From 07139db41c002bf686d44c2951e99a43bbe5ee7f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 11:37:58 +0200 Subject: [PATCH 070/144] Implemented waiting on job id --- .../plugins/publish/submit_jobs_to_royalrender.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py index 29d5fe6d72..689fe098d9 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -61,6 +61,14 @@ class SubmitJobsToRoyalRender(ContextPlugin): def process_submission(self, jobs): # type: ([RRJob]) -> None + + idx_pre_id = 0 + for job in jobs: + job.PreID = idx_pre_id + if idx_pre_id > 0: + job.WaitForPreIDs.append(idx_pre_id - 1) + idx_pre_id += 1 + submission = rrApi.create_submission( jobs, self._submission_parameters) From ddf0b3b3ec9a7c63813f3b0bd12d42df72d7fb33 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 11:38:52 +0200 Subject: [PATCH 071/144] Added RequiredMemory Without it default (4GB) is used. --- .../plugins/publish/submit_jobs_to_royalrender.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py index 689fe098d9..6e91dff6ac 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -4,7 +4,11 @@ import tempfile import platform from pyblish.api import IntegratorOrder, ContextPlugin, Context -from openpype.modules.royalrender.api import RRJob, Api as rrApi +from openpype.modules.royalrender.api import ( + RRJob, + Api as rrApi, + SubmitterParameter +) from openpype.pipeline.publish import KnownPublishError @@ -95,7 +99,7 @@ class SubmitJobsToRoyalRender(ContextPlugin): return temp.name def get_submission_parameters(self): - return [] + return [SubmitterParameter("RequiredMemory", "0")] @staticmethod def _resolve_rr_path(context, rr_path_name): From 449157fd73002fc2e4c14ebbde3adf9b84319074 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 16:37:22 +0200 Subject: [PATCH 072/144] Fix executable placeholder --- .../rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg index 6414ae45a8..864eeaf15a 100644 --- a/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg +++ b/openpype/modules/royalrender/rr_root/render_apps/_config/E01__OpenPype__PublishJob.cfg @@ -30,7 +30,7 @@ CommandLine= CommandLine= -CommandLine= "" --headless publish +CommandLine= "" --headless publish --targets royalrender --targets farm From edcc1cc5f34cf366ff221742d743e89a71ba6368 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 16:39:17 +0200 Subject: [PATCH 073/144] Fix content of metadata file Must contain metadata useful for publish not only properties of RR job. --- .../publish/create_publish_royalrender_job.py | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 29e467e46a..db7118103d 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -13,8 +13,9 @@ from openpype.modules.royalrender.rr_job import ( get_rr_platform ) from openpype.pipeline.publish import KnownPublishError -from openpype.lib.openpype_version import ( - get_OpenPypeVersion, get_openpype_version) +from openpype.pipeline import ( + legacy_io, +) from openpype.pipeline.farm.pyblish_functions import ( create_skeleton_instance, create_instances_for_aov, @@ -114,16 +115,31 @@ class CreatePublishRoyalRenderJob(InstancePlugin): raise KnownPublishError( "Can't create publish job without prior ppducing jobs first") - publish_job = self.get_job(instance, instances) + rr_job = self.get_job(instance, instances) + instance.data["rrJobs"].append(rr_job) - instance.data["rrJobs"].append(publish_job) + # publish job file + publish_job = { + "asset": instance_skeleton_data["asset"], + "frameStart": instance_skeleton_data["frameStart"], + "frameEnd": instance_skeleton_data["frameEnd"], + "fps": instance_skeleton_data["fps"], + "source": instance_skeleton_data["source"], + "user": instance.context.data["user"], + "version": instance.context.data["version"], # this is workfile version + "intent": instance.context.data.get("intent"), + "comment": instance.context.data.get("comment"), + "job": attr.asdict(rr_job), + "session": legacy_io.Session.copy(), + "instances": instances + } metadata_path, rootless_metadata_path = \ create_metadata_path(instance, self.anatomy) self.log.info("Writing json file: {}".format(metadata_path)) with open(metadata_path, "w") as f: - json.dump(attr.asdict(publish_job), f, indent=4, sort_keys=True) + json.dump(publish_job, f, indent=4, sort_keys=True) def get_job(self, instance, instances): """Create RR publishing job. From 0ad2e216d4582901a14954234d67766956b05fea Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 16:39:58 +0200 Subject: [PATCH 074/144] Add logging to log file --- .../plugins/publish/create_publish_royalrender_job.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index db7118103d..28d6faecc0 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -212,12 +212,12 @@ class CreatePublishRoyalRenderJob(InstancePlugin): ## rr requires absolut path or all jobs won't show up in rControl abs_metadata_path = self.anatomy.fill_root(rootless_metadata_path) + # command line set in E01__OpenPype__PublishJob.cfg, here only + # additional logging args = [ - "--headless", - 'publish', - abs_metadata_path, - "--targets", "deadline", - "--targets", "farm" + ">", os.path.join(os.path.dirname(abs_metadata_path), + "rr_out.log"), + "2>&1" ] job = RRJob( From a10bf73c725fdd3ef3ff718f454227981dfa422a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 16:40:28 +0200 Subject: [PATCH 075/144] Fix batching of publish job --- .../plugins/publish/create_publish_royalrender_job.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 28d6faecc0..101234f9f2 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -240,7 +240,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): ImagePreNumberLetter="", SceneOS=get_rr_platform(), rrEnvList=environment.serialize(), - Priority=priority + Priority=priority, + CompanyProjectName=instance.context.data["projectName"] ) # add assembly jobs as dependencies From 0a6fd30d03a9de544848ec08a24b05b30bd0d24e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 16:40:57 +0200 Subject: [PATCH 076/144] Remove unneeded import --- .../plugins/publish/create_publish_royalrender_job.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 101234f9f2..4ce96a908d 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -2,7 +2,6 @@ """Create publishing job on RoyalRender.""" import os import attr -from copy import deepcopy import json from pyblish.api import InstancePlugin, IntegratorOrder, Instance From b00126677550ce85e02d794af30715d81e5d4fb7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 17:14:25 +0200 Subject: [PATCH 077/144] Clean up --- .../publish/create_publish_royalrender_job.py | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 4ce96a908d..e6836ad4e7 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -25,6 +25,14 @@ from openpype.pipeline.farm.pyblish_functions import ( class CreatePublishRoyalRenderJob(InstancePlugin): + """Creates job which publishes rendered files to publish area. + + Job waits until all rendering jobs are finished, triggers `publish` command + where it reads from prepared .json file with metadata about what should + be published, renames prepared images and publishes them. + + When triggered it produces .log file next to .json file in work area. + """ label = "Create publish job in RR" order = IntegratorOrder + 0.2 icon = "tractor" @@ -62,6 +70,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "AVALON_APP_NAME", "OPENPYPE_USERNAME", "OPENPYPE_SG_USER", + "OPENPYPE_MONGO" ] priority = 50 @@ -112,7 +121,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): self.log.error(("There is no prior RoyalRender " "job on the instance.")) raise KnownPublishError( - "Can't create publish job without prior ppducing jobs first") + "Can't create publish job without prior rendering jobs first") rr_job = self.get_job(instance, instances) instance.data["rrJobs"].append(rr_job) @@ -125,7 +134,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "fps": instance_skeleton_data["fps"], "source": instance_skeleton_data["source"], "user": instance.context.data["user"], - "version": instance.context.data["version"], # this is workfile version + "version": instance.context.data["version"], # workfile version "intent": instance.context.data.get("intent"), "comment": instance.context.data.get("comment"), "job": attr.asdict(rr_job), @@ -157,7 +166,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): """ data = instance.data.copy() subset = data["subset"] - job_name = "Publish - {subset}".format(subset=subset) + jobname = "Publish - {subset}".format(subset=subset) instance_version = instance.data.get("version") # take this if exists override_version = instance_version if instance_version != 1 else None @@ -173,11 +182,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): "AVALON_PROJECT": anatomy_data["project"]["name"], "AVALON_ASSET": anatomy_data["asset"], "AVALON_TASK": anatomy_data["task"]["name"], - "OPENPYPE_USERNAME": anatomy_data["user"], - "OPENPYPE_PUBLISH_JOB": "1", - "OPENPYPE_RENDER_JOB": "0", - "OPENPYPE_REMOTE_JOB": "0", - "OPENPYPE_LOG_NO_COLORS": "1" + "OPENPYPE_USERNAME": anatomy_data["user"] }) # add environments from self.environ_keys @@ -200,12 +205,6 @@ class CreatePublishRoyalRenderJob(InstancePlugin): if job_environ.get(env_j_key): environment[env_j_key] = job_environ[env_j_key] - # Add mongo url if it's enabled - if instance.context.data.get("deadlinePassMongoUrl"): - mongo_url = os.environ.get("OPENPYPE_MONGO") - if mongo_url: - environment["OPENPYPE_MONGO"] = mongo_url - priority = self.priority or instance.data.get("priority", 50) ## rr requires absolut path or all jobs won't show up in rControl @@ -222,13 +221,11 @@ class CreatePublishRoyalRenderJob(InstancePlugin): job = RRJob( Software="OpenPype", Renderer="Once", - # path to OpenPype SeqStart=1, SeqEnd=1, SeqStep=1, SeqFileOffset=0, Version=os.environ.get("OPENPYPE_VERSION"), - # executable SceneName=abs_metadata_path, # command line arguments CustomAddCmdFlags=" ".join(args), @@ -240,6 +237,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): SceneOS=get_rr_platform(), rrEnvList=environment.serialize(), Priority=priority, + CustomSHotName=jobname, CompanyProjectName=instance.context.data["projectName"] ) From 540981da6ab257c5a6cfd16ae723c6a94520c938 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 17:19:33 +0200 Subject: [PATCH 078/144] Clean up --- .../plugins/publish/create_nuke_deadline_job.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 699f665468..8d05373e32 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- """Submitting render job to RoyalRender.""" -import copy import os import re import platform @@ -17,13 +16,12 @@ from openpype.lib import ( is_running_from_build, BoolDef, NumberDef, - collect_frames ) from openpype.pipeline import OpenPypePyblishPluginMixin -from openpype.pipeline.farm.tools import iter_expected_files class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): + """Creates separate rendering job for Royal Render""" label = "Create Nuke Render job in RR" order = IntegratorOrder + 0.1 hosts = ["nuke"] @@ -202,6 +200,7 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") output_filename_0 = self.preview_fname(render_path) + _, file_ext = os.path.splitext(os.path.basename(render_path)) custom_attributes = [] if is_running_from_build(): @@ -218,11 +217,6 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): expected_files = self.expected_files( render_path, start_frame, end_frame) self._instance.data["expectedFiles"].extend(expected_files) - first_file = next(iter_expected_files(expected_files)) - - file_name, file_ext = os.path.splitext(os.path.basename(first_file)) - frame_pattern = ".{}".format(start_frame) - file_name = file_name.replace(frame_pattern, '.#') job = RRJob( Software="Nuke", @@ -235,14 +229,14 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): SceneName=script_path, IsActive=True, ImageDir=render_dir.replace("\\", "/"), - ImageFilename="{}".format(file_name), + ImageFilename="{}".format(output_filename_0), ImageExtension=file_ext, ImagePreNumberLetter="", ImageSingleOutputFile=False, SceneOS=get_rr_platform(), Layer=node_name, SceneDatabaseDir=script_path, - CustomSHotName=self._instance.context.data["asset"], + CustomSHotName=jobname, CompanyProjectName=self._instance.context.data["projectName"], ImageWidth=self._instance.data["resolutionWidth"], ImageHeight=self._instance.data["resolutionHeight"], From ddb227bd959991cae8a51faa04e9aee28af7e381 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 17:25:13 +0200 Subject: [PATCH 079/144] Remove unfinished file --- .../default_modules/royal_render/test_rr_job.py | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 tests/unit/openpype/default_modules/royal_render/test_rr_job.py diff --git a/tests/unit/openpype/default_modules/royal_render/test_rr_job.py b/tests/unit/openpype/default_modules/royal_render/test_rr_job.py deleted file mode 100644 index ab8b1bfd50..0000000000 --- a/tests/unit/openpype/default_modules/royal_render/test_rr_job.py +++ /dev/null @@ -1,10 +0,0 @@ -# -*- coding: utf-8 -*- -"""Test suite for User Settings.""" -# import pytest -# from openpype.modules import ModulesManager - - -def test_rr_job(): - # manager = ModulesManager() - # rr_module = manager.modules_by_name["royalrender"] - ... From f3ce4c6b71b3f0aec014b78452ee6f489023c4a1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 18:33:13 +0200 Subject: [PATCH 080/144] Hound --- .../deadline/plugins/publish/submit_publish_job.py | 11 ++++------- .../plugins/publish/create_nuke_deadline_job.py | 5 +++-- .../plugins/publish/create_publish_royalrender_job.py | 7 ++----- openpype/modules/royalrender/rr_job.py | 2 +- openpype/pipeline/farm/pyblish_functions.py | 7 ++++--- 5 files changed, 14 insertions(+), 18 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index ff6bcf1801..be0863f9b2 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -336,12 +336,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): return instance_skeleton_data = create_skeleton_instance( - instance, - families_transfer=self.families_transfer, - instance_transfer=self.instance_transfer) - - instances = None - + instance, + families_transfer=self.families_transfer, + instance_transfer=self.instance_transfer) """ if content of `expectedFiles` list are dictionaries, we will handle it as list of AOVs, creating instance for every one of them. @@ -481,7 +478,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "fps": instance_skeleton_data["fps"], "source": instance_skeleton_data["source"], "user": instance.context.data["user"], - "version": instance.context.data["version"], # this is workfile version + "version": instance.context.data["version"], # workfile version "intent": instance.context.data.get("intent"), "comment": instance.context.data.get("comment"), "job": render_job or None, diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 8d05373e32..0d5e440c90 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -200,7 +200,8 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") output_filename_0 = self.preview_fname(render_path) - _, file_ext = os.path.splitext(os.path.basename(render_path)) + file_name, file_ext = os.path.splitext( + os.path.basename(output_filename_0)) custom_attributes = [] if is_running_from_build(): @@ -229,7 +230,7 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): SceneName=script_path, IsActive=True, ImageDir=render_dir.replace("\\", "/"), - ImageFilename="{}".format(output_filename_0), + ImageFilename=file_name, ImageExtension=file_ext, ImagePreNumberLetter="", ImageSingleOutputFile=False, diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index e6836ad4e7..3cc63db377 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -4,7 +4,7 @@ import os import attr import json -from pyblish.api import InstancePlugin, IntegratorOrder, Instance +from pyblish.api import InstancePlugin, IntegratorOrder from openpype.modules.royalrender.rr_job import ( RRJob, @@ -168,9 +168,6 @@ class CreatePublishRoyalRenderJob(InstancePlugin): subset = data["subset"] jobname = "Publish - {subset}".format(subset=subset) - instance_version = instance.data.get("version") # take this if exists - override_version = instance_version if instance_version != 1 else None - # Transfer the environment from the original job to this dependent # job, so they use the same environment metadata_path, rootless_metadata_path = \ @@ -207,7 +204,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): priority = self.priority or instance.data.get("priority", 50) - ## rr requires absolut path or all jobs won't show up in rControl + # rr requires absolut path or all jobs won't show up in rControl abs_metadata_path = self.anatomy.fill_root(rootless_metadata_path) # command line set in E01__OpenPype__PublishJob.cfg, here only diff --git a/openpype/modules/royalrender/rr_job.py b/openpype/modules/royalrender/rr_job.py index 8d96b8ff4a..b85ac592f8 100644 --- a/openpype/modules/royalrender/rr_job.py +++ b/openpype/modules/royalrender/rr_job.py @@ -32,7 +32,7 @@ class RREnvList(dict): """Parse rrEnvList string and return it as RREnvList object.""" out = RREnvList() for var in data.split("~~~"): - k, v = data.split("=") + k, v = var.split("=") out[k] = v return out diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 6c08545b1b..dbba9f8a9a 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -92,7 +92,6 @@ def extend_frames(asset, subset, start, end): updated_start = min(start, prev_start) updated_end = max(end, prev_end) - return updated_start, updated_end @@ -217,9 +216,11 @@ def create_skeleton_instance( log = Logger.get_logger("farm_publishing") log.warning(("Could not find root path for remapping \"{}\". " "This may cause issues.").format(source)) - + family = ("render" + if "prerender" not in instance.data["families"] + else "prerender") instance_skeleton_data = { - "family": "render" if "prerender" not in instance.data["families"] else "prerender", # noqa: E401 + "family": family, "subset": data["subset"], "families": families, "asset": data["asset"], From 36ef2b5b5f8712764bc2e2219d452838fafdc999 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 10 May 2023 18:39:40 +0200 Subject: [PATCH 081/144] Hound --- .../deadline/plugins/publish/submit_publish_job.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index be0863f9b2..2b5916cea5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -336,9 +336,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): return instance_skeleton_data = create_skeleton_instance( - instance, - families_transfer=self.families_transfer, - instance_transfer=self.instance_transfer) + instance, families_transfer=self.families_transfer, + instance_transfer=self.instance_transfer) """ if content of `expectedFiles` list are dictionaries, we will handle it as list of AOVs, creating instance for every one of them. @@ -447,8 +446,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): render_job["Props"]["Batch"] = instance.data.get( "jobBatchName") else: - render_job["Props"]["Batch"] = os.path.splitext( - os.path.basename(instance.context.data.get("currentFile")))[0] + batch = os.path.splitext(os.path.basename( + instance.context.data.get("currentFile")))[0] + render_job["Props"]["Batch"] = batch # User is deadline user render_job["Props"]["User"] = instance.context.data.get( "deadlineUser", getpass.getuser()) From b654b10e049a9a18860c431568d9cd645f60daf7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 11:10:42 +0200 Subject: [PATCH 082/144] Handle conflict with recent develop Added explicit disabling of adding review. --- .../plugins/publish/submit_publish_job.py | 13 ++++++---- .../publish/create_publish_royalrender_job.py | 11 +++++++-- openpype/pipeline/farm/pyblish_functions.py | 24 +++++++++++++------ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 71a609997f..acd47e3224 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -299,7 +299,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): deadline_publish_job_id = response.json()["_id"] return deadline_publish_job_id - + def _solve_families(self, instance, preview=False): families = instance.get("families") @@ -379,19 +379,24 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): This will result in one instance with two representations: `foo` and `xxx` """ - + do_not_add_review = False + if instance.data.get("review") is False: + self.log.debug("Instance has review explicitly disabled.") + do_not_add_review = True if isinstance(instance.data.get("expectedFiles")[0], dict): instances = create_instances_for_aov( instance, instance_skeleton_data, - self.aov_filter, self.skip_integration_repre_list) + self.aov_filter, self.skip_integration_repre_list, + do_not_add_review) else: representations = prepare_representations( instance_skeleton_data, instance.data.get("expectedFiles"), self.anatomy, self.aov_filter, - self.skip_integration_repre_list + self.skip_integration_repre_list, + do_not_add_review ) if "representations" not in instance_skeleton_data.keys(): diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 3cc63db377..af5bdfd5e6 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -88,10 +88,16 @@ class CreatePublishRoyalRenderJob(InstancePlugin): families_transfer=self.families_transfer, instance_transfer=self.instance_transfer) + do_not_add_review = False + if instance.data.get("review") is False: + self.log.debug("Instance has review explicitly disabled.") + do_not_add_review = True + if isinstance(instance.data.get("expectedFiles")[0], dict): instances = create_instances_for_aov( instance, instance_skeleton_data, - self.aov_filter, self.skip_integration_repre_list) + self.aov_filter, self.skip_integration_repre_list, + do_not_add_review) else: representations = prepare_representations( @@ -99,7 +105,8 @@ class CreatePublishRoyalRenderJob(InstancePlugin): instance.data.get("expectedFiles"), self.anatomy, self.aov_filter, - self.skip_integration_repre_list + self.skip_integration_repre_list, + do_not_add_review ) if "representations" not in instance_skeleton_data.keys(): diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index dbba9f8a9a..be031b1fde 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -286,7 +286,8 @@ def _solve_families(families): def prepare_representations(instance, exp_files, anatomy, aov_filter, - skip_integration_repre_list): + skip_integration_repre_list, + do_not_add_review): """Create representations for file sequences. This will return representations of expected files if they are not @@ -299,7 +300,8 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, exp_files (list): list of expected files anatomy (Anatomy): aov_filter (dict): add review for specific aov names - skip_integration_repre_list (list): exclude specific extensions + skip_integration_repre_list (list): exclude specific extensions, + do_not_add_review (bool): explicitly skip review Returns: list of representations @@ -351,6 +353,7 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, if instance.get("slate"): frame_start -= 1 + preview = preview and not do_not_add_review rep = { "name": ext, "ext": ext, @@ -406,6 +409,7 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, preview = match_aov_pattern( host_name, aov_filter, remainder ) + preview = preview and not do_not_add_review if preview: rep.update({ "fps": instance.get("fps"), @@ -428,7 +432,8 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, def create_instances_for_aov(instance, skeleton, aov_filter, - skip_integration_repre_list): + skip_integration_repre_list, + do_not_add_review): """Create instances from AOVs. This will create new pyblish.api.Instances by going over expected @@ -437,6 +442,7 @@ def create_instances_for_aov(instance, skeleton, aov_filter, Args: instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton instance data. + skip_integration_repre_list (list): skip Returns: list of pyblish.api.Instance: Instances created from @@ -481,12 +487,13 @@ def create_instances_for_aov(instance, skeleton, aov_filter, skeleton, aov_filter, additional_color_data, - skip_integration_repre_list + skip_integration_repre_list, + do_not_add_review ) def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, - skip_integration_repre_list): + skip_integration_repre_list, do_not_add_review): """Create instance for each AOV found. This will create new instance for every AOV it can detect in expected @@ -496,7 +503,10 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, instance (pyblish.api.Instance): Original instance. skeleton (dict): Skeleton data for instance (those needed) later by collector. - additional_data (dict): ... + additional_data (dict): .. + skip_integration_repre_list (list): list of extensions that shouldn't + be published + do_not_addbe _review (bool): explicitly disable review Returns: @@ -631,7 +641,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, if ext in skip_integration_repre_list: rep["tags"].append("delete") - if preview: + if preview and not do_not_add_review: new_instance["families"] = _solve_families(new_instance) new_instance["representations"] = [rep] From c302b61d729826a223651f0fa7bc36992601030c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 11:11:22 +0200 Subject: [PATCH 083/144] Removed _extend_frames Moved to publish_functions --- .../plugins/publish/submit_publish_job.py | 41 ------------------- 1 file changed, 41 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index acd47e3224..853a3983e7 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -515,47 +515,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): with open(metadata_path, "w") as f: json.dump(publish_job, f, indent=4, sort_keys=True) - def _extend_frames(self, asset, subset, start, end): - """Get latest version of asset nad update frame range. - - Based on minimum and maximuma values. - - Arguments: - asset (str): asset name - subset (str): subset name - start (int): start frame - end (int): end frame - - Returns: - (int, int): upddate frame start/end - - """ - # Frame comparison - prev_start = None - prev_end = None - - project_name = legacy_io.active_project() - version = get_last_version_by_subset_name( - project_name, - subset, - asset_name=asset - ) - - # Set prev start / end frames for comparison - if not prev_start and not prev_end: - prev_start = version["data"]["frameStart"] - prev_end = version["data"]["frameEnd"] - - updated_start = min(start, prev_start) - updated_end = max(end, prev_end) - - self.log.info( - "Updating start / end frame : " - "{} - {}".format(updated_start, updated_end) - ) - - return updated_start, updated_end - def _get_publish_folder(self, anatomy, template_data, asset, subset, family='render', version=None): From 1aef4c57f3d5d0f3fd70035ff3b6f452b43aecbc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 12:23:48 +0200 Subject: [PATCH 084/144] Fix missing import --- openpype/modules/deadline/abstract_submit_deadline.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index f6696b2c05..c2cc518e40 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -21,7 +21,10 @@ from openpype.pipeline.publish import ( AbstractMetaInstancePlugin, KnownPublishError ) -from openpype.pipeline.publish.lib import replace_published_scene +from openpype.pipeline.publish.lib import ( + replace_published_scene, + get_published_workfile_instance +) JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) From 78f9a68b35027daa1218491240be90f117b2486c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 13:10:02 +0200 Subject: [PATCH 085/144] Fix aov handling Use col.head directly as rem in second logic path has no .head, it is only str. Fix review handling --- openpype/pipeline/farm/pyblish_functions.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index be031b1fde..7444c22198 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -353,6 +353,7 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, if instance.get("slate"): frame_start -= 1 + # explicitly disable review by user preview = preview and not do_not_add_review rep = { "name": ext, @@ -544,14 +545,14 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, if len(cols) != 1: raise ValueError("Only one image sequence type is expected.") # noqa: E501 ext = cols[0].tail.lstrip(".") - col = list(cols[0]) + col = cols[0].head # create subset name `familyTaskSubset_AOV` group_name = 'render{}{}{}{}'.format( task[0].upper(), task[1:], subset[0].upper(), subset[1:]) - cam = [c for c in cameras if c in col.head] + cam = [c for c in cameras if c in col] if cam: if aov: subset_name = '{}_{}_{}'.format(group_name, cam, aov) @@ -577,8 +578,6 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, app = os.environ.get("AVALON_APP", "") - preview = False - if isinstance(col, list): render_file_name = os.path.basename(col[0]) else: @@ -587,11 +586,13 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, preview = match_aov_pattern(app, aov_patterns, render_file_name) # toggle preview on if multipart is on - if instance.data.get("multipartExr"): log.debug("Adding preview tag because its multipartExr") preview = True + # explicitly disable review by user + preview = preview and not do_not_add_review + new_instance = deepcopy(skeleton) new_instance["subsetGroup"] = group_name if preview: @@ -641,7 +642,7 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, if ext in skip_integration_repre_list: rep["tags"].append("delete") - if preview and not do_not_add_review: + if preview: new_instance["families"] = _solve_families(new_instance) new_instance["representations"] = [rep] From a4434d932b5462d5e4255152c27d3e233466467d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 14:54:25 +0200 Subject: [PATCH 086/144] Fix adding review to families Renamed function to (bit) more reasonable name. --- openpype/pipeline/farm/pyblish_functions.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 7444c22198..09cb50941a 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -269,8 +269,11 @@ def create_skeleton_instance( return instance_skeleton_data -def _solve_families(families): - """Solve families. +def _add_review_families(families): + """Adds review flag to families. + + Handles situation when new instances are created which should have review + in families. In that case they should have 'ftrack' too. TODO: This is ugly and needs to be refactored. Ftrack family should be added in different way (based on if the module is enabled?) @@ -382,7 +385,7 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, representations.append(rep) if preview: - instance["families"] = _solve_families(instance["families"]) + instance["families"] = _add_review_families(instance["families"]) # add remainders as representations for remainder in remainders: @@ -416,7 +419,7 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, "fps": instance.get("fps"), "tags": ["review"] }) - instance["families"] = _solve_families(instance["families"]) + instance["families"] = _add_review_families(instance["families"]) already_there = False for repre in instance.get("representations", []): @@ -643,7 +646,8 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, rep["tags"].append("delete") if preview: - new_instance["families"] = _solve_families(new_instance) + new_instance["families"] = _add_review_families( + new_instance["families"]) new_instance["representations"] = [rep] From d745ebce16a2d64a5b3739c3aaa795d69726379b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 14:54:50 +0200 Subject: [PATCH 087/144] Fix missing colorspaceTemplate key --- openpype/pipeline/farm/pyblish_functions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 09cb50941a..0b3e4acaae 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -472,6 +472,7 @@ def create_instances_for_aov(instance, skeleton, aov_filter, colorspace_template, anatomy) except ValueError as e: log.warning(e) + additional_color_data["colorspaceTemplate"] = colorspace_template # if there are subset to attach to and more than one AOV, # we cannot proceed. From 162b58ce7a574d8d9ea5efde58c01e2ec9c99a29 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 15:00:58 +0200 Subject: [PATCH 088/144] Fix access to anatomy --- .../plugins/publish/submit_publish_job.py | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 853a3983e7..4f12552d34 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -165,6 +165,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): subset = data["subset"] job_name = "Publish - {subset}".format(subset=subset) + anatomy = instance.context.data['anatomy'] + # instance.data.get("subset") != instances[0]["subset"] # 'Main' vs 'renderMain' override_version = None @@ -172,7 +174,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if instance_version != 1: override_version = instance_version output_dir = self._get_publish_folder( - instance.context.data['anatomy'], + anatomy, deepcopy(instance.data["anatomyData"]), instance.data.get("asset"), instances[0]["subset"], @@ -183,7 +185,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # Transfer the environment from the original job to this dependent # job so they use the same environment metadata_path, rootless_metadata_path = \ - create_metadata_path(instance, self.anatomy) + create_metadata_path(instance, anatomy) environment = { "AVALON_PROJECT": legacy_io.Session["AVALON_PROJECT"], @@ -263,13 +265,15 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): self.log.info("Adding tile assembly jobs as dependencies...") job_index = 0 for assembly_id in instance.data.get("assemblySubmissionJobs"): - payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 + payload["JobInfo"]["JobDependency{}".format( + job_index)] = assembly_id # noqa: E501 job_index += 1 elif instance.data.get("bakingSubmissionJobs"): self.log.info("Adding baking submission jobs as dependencies...") job_index = 0 for assembly_id in instance.data["bakingSubmissionJobs"]: - payload["JobInfo"]["JobDependency{}".format(job_index)] = assembly_id # noqa: E501 + payload["JobInfo"]["JobDependency{}".format( + job_index)] = assembly_id # noqa: E501 job_index += 1 else: payload["JobInfo"]["JobDependency0"] = job["_id"] @@ -300,25 +304,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): return deadline_publish_job_id - def _solve_families(self, instance, preview=False): - families = instance.get("families") - - # if we have one representation with preview tag - # flag whole instance for review and for ftrack - if preview: - if "ftrack" not in families: - if os.environ.get("FTRACK_SERVER"): - self.log.debug( - "Adding \"ftrack\" to families because of preview tag." - ) - families.append("ftrack") - if "review" not in families: - self.log.debug( - "Adding \"review\" to families because of preview tag." - ) - families.append("review") - instance["families"] = families - def process(self, instance): # type: (pyblish.api.Instance) -> None """Process plugin. @@ -335,6 +320,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): self.log.info("Skipping local instance.") return + anatomy = instance.context.data["anatomy"] + instance_skeleton_data = create_skeleton_instance( instance, families_transfer=self.families_transfer, instance_transfer=self.instance_transfer) @@ -393,7 +380,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): representations = prepare_representations( instance_skeleton_data, instance.data.get("expectedFiles"), - self.anatomy, + anatomy, self.aov_filter, self.skip_integration_repre_list, do_not_add_review @@ -509,9 +496,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): publish_job.update({"ftrack": ftrack}) metadata_path, rootless_metadata_path = \ - create_metadata_path(instance, self.anatomy) + create_metadata_path(instance, anatomy) - self.log.info("Writing json file: {}".format(metadata_path)) with open(metadata_path, "w") as f: json.dump(publish_job, f, indent=4, sort_keys=True) From ad488c7ee4f54a209c617d3a6043122d41109deb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 16:50:52 +0200 Subject: [PATCH 089/144] Fix use proper families renderLayer is only for processing, final family is 'render'/'prerender'. --- openpype/pipeline/farm/pyblish_functions.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 0b3e4acaae..1bfc5e3dc2 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -188,12 +188,6 @@ def create_skeleton_instance( data = instance.data.copy() anatomy = instance.context.data["anatomy"] # type: Anatomy - families = [data["family"]] - - # pass review to families if marked as review - if data.get("review"): - families.append("review") - # get time related data from instance (or context) time_data = get_time_data_from_instance_or_context(instance) @@ -219,6 +213,12 @@ def create_skeleton_instance( family = ("render" if "prerender" not in instance.data["families"] else "prerender") + families = [family] + + # pass review to families if marked as review + if data.get("review"): + families.append("review") + instance_skeleton_data = { "family": family, "subset": data["subset"], From c1f3201316ab938ec2d97e65ca32a0021818d0f2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 11 May 2023 16:51:41 +0200 Subject: [PATCH 090/144] Fix use proper subset name Subset name should contain task, not only 'Main' --- openpype/pipeline/farm/pyblish_functions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 1bfc5e3dc2..89bff6dfee 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -210,6 +210,7 @@ def create_skeleton_instance( log = Logger.get_logger("farm_publishing") log.warning(("Could not find root path for remapping \"{}\". " "This may cause issues.").format(source)) + family = ("render" if "prerender" not in instance.data["families"] else "prerender") @@ -594,11 +595,12 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, log.debug("Adding preview tag because its multipartExr") preview = True + new_instance = deepcopy(skeleton) + new_instance["subset"] = subset_name + new_instance["subsetGroup"] = group_name + # explicitly disable review by user preview = preview and not do_not_add_review - - new_instance = deepcopy(skeleton) - new_instance["subsetGroup"] = group_name if preview: new_instance["review"] = True From 6df0fbccf77680b2621ccbea52703a980c8a9548 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 May 2023 15:33:33 +0200 Subject: [PATCH 091/144] OP-1066 - fix changed imports from pyblish.api import breaks Python3 hosts --- openpype/hosts/maya/api/fbx.py | 4 ++-- .../plugins/publish/create_maya_royalrender_job.py | 8 ++++---- .../plugins/publish/create_nuke_deadline_job.py | 9 +++++---- .../plugins/publish/create_publish_royalrender_job.py | 6 +++--- .../plugins/publish/submit_jobs_to_royalrender.py | 8 ++++---- openpype/pipeline/farm/pyblish_functions.py | 4 ++-- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 260241f5fc..e9cf8af491 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -2,7 +2,7 @@ """Tools to work with FBX.""" import logging -from pyblish.api import Instance +import pyblish.api from maya import cmds # noqa import maya.mel as mel # noqa @@ -141,7 +141,7 @@ class FBXExtractor: return options def set_options_from_instance(self, instance): - # type: (Instance) -> None + # type: (pyblish.api.Instance) -> None """Sets FBX export options from data in the instance. Args: diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 8461e74d6d..1bb2785dd6 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -5,7 +5,7 @@ import sys import platform from maya.OpenMaya import MGlobal # noqa -from pyblish.api import InstancePlugin, IntegratorOrder, Context +import pyblish.api from openpype.lib import is_running_from_build from openpype.pipeline.publish.lib import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError @@ -14,9 +14,9 @@ from openpype.modules.royalrender.rr_job import RRJob, CustomAttribute from openpype.pipeline.farm.tools import iter_expected_files -class CreateMayaRoyalRenderJob(InstancePlugin): +class CreateMayaRoyalRenderJob(pyblish.api.InstancePlugin): label = "Create Maya Render job in RR" - order = IntegratorOrder + 0.1 + order = pyblish.api.IntegratorOrder + 0.1 families = ["renderlayer"] targets = ["local"] use_published = True @@ -128,7 +128,7 @@ class CreateMayaRoyalRenderJob(InstancePlugin): @staticmethod def _resolve_rr_path(context, rr_path_name): - # type: (Context, str) -> str + # type: (pyblish.api.Context, str) -> str rr_settings = ( context.data ["system_settings"] diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py index 0d5e440c90..a90c4c4f83 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py @@ -5,7 +5,7 @@ import re import platform from datetime import datetime -from pyblish.api import InstancePlugin, IntegratorOrder, Context +import pyblish.api from openpype.tests.lib import is_in_tests from openpype.pipeline.publish.lib import get_published_workfile_instance from openpype.pipeline.publish import KnownPublishError @@ -20,10 +20,11 @@ from openpype.lib import ( from openpype.pipeline import OpenPypePyblishPluginMixin -class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): +class CreateNukeRoyalRenderJob(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): """Creates separate rendering job for Royal Render""" label = "Create Nuke Render job in RR" - order = IntegratorOrder + 0.1 + order = pyblish.api.IntegratorOrder + 0.1 hosts = ["nuke"] families = ["render", "prerender"] targets = ["local"] @@ -248,7 +249,7 @@ class CreateNukeRoyalRenderJob(InstancePlugin, OpenPypePyblishPluginMixin): @staticmethod def _resolve_rr_path(context, rr_path_name): - # type: (Context, str) -> str + # type: (pyblish.api.Context, str) -> str rr_settings = ( context.data ["system_settings"] diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index af5bdfd5e6..c652509373 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -4,7 +4,7 @@ import os import attr import json -from pyblish.api import InstancePlugin, IntegratorOrder +import pyblish.api from openpype.modules.royalrender.rr_job import ( RRJob, @@ -24,7 +24,7 @@ from openpype.pipeline.farm.pyblish_functions import ( ) -class CreatePublishRoyalRenderJob(InstancePlugin): +class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin): """Creates job which publishes rendered files to publish area. Job waits until all rendering jobs are finished, triggers `publish` command @@ -34,7 +34,7 @@ class CreatePublishRoyalRenderJob(InstancePlugin): When triggered it produces .log file next to .json file in work area. """ label = "Create publish job in RR" - order = IntegratorOrder + 0.2 + order = pyblish.api.IntegratorOrder + 0.2 icon = "tractor" targets = ["local"] hosts = ["fusion", "maya", "nuke", "celaction", "aftereffects", "harmony"] diff --git a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py index 6e91dff6ac..8fc8604b83 100644 --- a/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py +++ b/openpype/modules/royalrender/plugins/publish/submit_jobs_to_royalrender.py @@ -3,7 +3,7 @@ import tempfile import platform -from pyblish.api import IntegratorOrder, ContextPlugin, Context +import pyblish.api from openpype.modules.royalrender.api import ( RRJob, Api as rrApi, @@ -12,10 +12,10 @@ from openpype.modules.royalrender.api import ( from openpype.pipeline.publish import KnownPublishError -class SubmitJobsToRoyalRender(ContextPlugin): +class SubmitJobsToRoyalRender(pyblish.api.ContextPlugin): """Find all jobs, create submission XML and submit it to RoyalRender.""" label = "Submit jobs to RoyalRender" - order = IntegratorOrder + 0.3 + order = pyblish.api.IntegratorOrder + 0.3 targets = ["local"] def __init__(self): @@ -103,7 +103,7 @@ class SubmitJobsToRoyalRender(ContextPlugin): @staticmethod def _resolve_rr_path(context, rr_path_name): - # type: (Context, str) -> str + # type: (pyblish.api.Context, str) -> str rr_settings = ( context.data ["system_settings"] diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 89bff6dfee..8be0887b0d 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -1,6 +1,6 @@ import copy import attr -from pyblish.api import Instance +import pyblish.api import os import clique from copy import deepcopy @@ -161,7 +161,7 @@ def get_transferable_representations(instance): def create_skeleton_instance( instance, families_transfer=None, instance_transfer=None): - # type: (Instance, list, dict) -> dict + # type: (pyblish.api.Instance, list, dict) -> dict """Create skeleton instance from original instance data. This will create dictionary containing skeleton From 2c3cd1c630e75292b9b32676ce2fb7638fcdb94d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 May 2023 15:34:32 +0200 Subject: [PATCH 092/144] OP-1066 - renamed file --- ...create_nuke_deadline_job.py => create_nuke_royalrender_job.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename openpype/modules/royalrender/plugins/publish/{create_nuke_deadline_job.py => create_nuke_royalrender_job.py} (100%) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py similarity index 100% rename from openpype/modules/royalrender/plugins/publish/create_nuke_deadline_job.py rename to openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py From 86a1357033c18a82f76385df8ae8531ae446f48e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 12 May 2023 18:23:38 +0200 Subject: [PATCH 093/144] OP-1066 - sanitize version RR expect version in format 3.157 instead proper 3.15.7-nightly.2 --- .../publish/create_publish_royalrender_job.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index c652509373..6eb8f2649e 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -3,6 +3,7 @@ import os import attr import json +import re import pyblish.api @@ -229,7 +230,7 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin): SeqEnd=1, SeqStep=1, SeqFileOffset=0, - Version=os.environ.get("OPENPYPE_VERSION"), + Version=self._sanitize_version(os.environ.get("OPENPYPE_VERSION")), SceneName=abs_metadata_path, # command line arguments CustomAddCmdFlags=" ".join(args), @@ -256,3 +257,26 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin): job.WaitForPreIDs += jobs_pre_ids return job + + def _sanitize_version(self, version): + """Returns version in format MAJOR.MINORPATCH + + 3.15.7-nightly.2 >> 3.157 + """ + VERSION_REGEX = re.compile( + r"(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"\.(?P0|[1-9]\d*)" + r"(?:-(?P[a-zA-Z\d\-.]*))?" + r"(?:\+(?P[a-zA-Z\d\-.]*))?" + ) + + valid_parts = VERSION_REGEX.findall(version) + if len(valid_parts) != 1: + # Return invalid version with filled 'origin' attribute + return version + + # Unpack found version + major, minor, patch, pre, post = valid_parts[0] + + return "{}.{}{}".format(major, minor, patch) From 56f2edfa53a7cf4bde01cc69b8c1ae28514d90bd Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 17 May 2023 16:56:59 +0200 Subject: [PATCH 094/144] Add prerender family to collector for RR path --- .../plugins/publish/collect_rr_path_from_instance.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index e21cd7c39f..ff5040c8df 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -7,7 +7,7 @@ class CollectRRPathFromInstance(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder label = "Collect Royal Render path name from the Instance" - families = ["render"] + families = ["render", "prerender"] def process(self, instance): instance.data["rrPathName"] = self._collect_rr_path_name(instance) From f6118ed6a8c79c9b6a89890e610771521d0f62e2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 18 May 2023 17:42:37 +0200 Subject: [PATCH 095/144] Added docuumentation --- website/docs/admin_settings_system.md | 4 +++ website/docs/module_royalrender.md | 37 +++++++++++++++++++++++++++ website/sidebars.js | 1 + 3 files changed, 42 insertions(+) create mode 100644 website/docs/module_royalrender.md diff --git a/website/docs/admin_settings_system.md b/website/docs/admin_settings_system.md index d61713ccd5..8abcefd24d 100644 --- a/website/docs/admin_settings_system.md +++ b/website/docs/admin_settings_system.md @@ -102,6 +102,10 @@ workstation that should be submitting render jobs to muster via OpenPype. **`templates mapping`** - you can customize Muster templates to match your existing setup here. +### Royal Render + +**`Royal Render Root Paths`** - multi platform paths to Royal Render installation. + ### Clockify **`Workspace Name`** - name of the clockify workspace where you would like to be sending all the timelogs. diff --git a/website/docs/module_royalrender.md b/website/docs/module_royalrender.md new file mode 100644 index 0000000000..2b75fbefef --- /dev/null +++ b/website/docs/module_royalrender.md @@ -0,0 +1,37 @@ +--- +id: module_royalrender +title: Royal Render Administration +sidebar_label: Royal Render +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + + +## Preparation + +For [Royal Render](hhttps://www.royalrender.de/) support you need to set a few things up in both OpenPype and Royal Render itself + +1. Deploy OpenPype executable to all nodes of Royal Render farm. See [Install & Run](admin_use.md) + +2. Enable Royal Render Module in the [OpenPype Admin Settings](admin_settings_system.md#royal-render). + +3. Point OpenPype to your Royal Render installation in the [OpenPype Admin Settings](admin_settings_system.md#royal-render). + +4. Install our custom plugin and scripts to your RR repository. It should be as simple as copying content of `openpype/modules/royalrender/rr_root` to `path/to/your/royalrender/repository`. + + +## Configuration + +OpenPype integration for Royal Render consists of pointing RR to location of Openpype executable. That is being done by copying `_install_paths/OpenPype.cfg` to +RR root folder. This file contains reasonable defaults. They could be changed in this file or modified Render apps in `rrControl`. + + +## Debugging + +Current implementation uses dynamically build '.xml' file which is stored in temporary folder accessible by RR. It might make sense to +use this Openpype built file and try to run it via `*__rrServerConsole` executable from command line in case of unforeseeable issues. + +## Known issues + +Currently environment values set in Openpype are not propagated into render jobs on RR. It is studio responsibility to synchronize environment variables from Openpype with all render nodes for now. diff --git a/website/sidebars.js b/website/sidebars.js index 4874782197..c846b04ca7 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -111,6 +111,7 @@ module.exports = { "module_site_sync", "module_deadline", "module_muster", + "module_royalrender", "module_clockify", "module_slack" ], From d7fd9c8e18b7f47fa43c559038d08d71e54ab87c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 5 Jun 2023 12:36:18 +0200 Subject: [PATCH 096/144] :recycle: non-optional access to rep data --- openpype/pipeline/farm/tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/farm/tools.py b/openpype/pipeline/farm/tools.py index 6f9e0ac393..f3acac7a32 100644 --- a/openpype/pipeline/farm/tools.py +++ b/openpype/pipeline/farm/tools.py @@ -47,7 +47,7 @@ def from_published_scene(instance, replace_in_path=True): # determine published path from Anatomy. template_data = workfile_instance.data.get("anatomyData") - rep = workfile_instance.data.get("representations")[0] + rep = workfile_instance.data["representations"][0] template_data["representation"] = rep.get("name") template_data["ext"] = rep.get("ext") template_data["comment"] = None From 705368897936811a33f18d92b33939028cf0f85b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 7 Jun 2023 16:20:30 +0200 Subject: [PATCH 097/144] :bug: fix rendering from published file in Deadline --- openpype/modules/deadline/abstract_submit_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index c2cc518e40..525e112b71 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -430,7 +430,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin): file_path = None if self.use_published: if not self.import_reference: - file_path = get_published_workfile_instance(instance) + file_path = self.from_published_scene(context) else: self.log.info("use the scene with imported reference for rendering") # noqa file_path = context.data["currentFile"] From 2851b3230ec4ae332a0106913491fcbadfd6a179 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 7 Jun 2023 17:29:23 +0200 Subject: [PATCH 098/144] :bug: fix collections files must be lists, fixed camera handling of single frame renders --- openpype/pipeline/farm/pyblish_functions.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 8be0887b0d..0ace02edb9 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -550,14 +550,19 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, if len(cols) != 1: raise ValueError("Only one image sequence type is expected.") # noqa: E501 ext = cols[0].tail.lstrip(".") - col = cols[0].head + col = list(cols[0]) # create subset name `familyTaskSubset_AOV` group_name = 'render{}{}{}{}'.format( task[0].upper(), task[1:], subset[0].upper(), subset[1:]) - cam = [c for c in cameras if c in col] + # if there are multiple cameras, we need to add camera name + if isinstance(col, (list, tuple)): + cam = [c for c in cameras if c in col[0]] + else: + # in case of single frame + cam = [c for c in cameras if c in col] if cam: if aov: subset_name = '{}_{}_{}'.format(group_name, cam, aov) From 01b5e6b9500a8b4460429427ee06f6021208be97 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Jun 2023 17:30:35 +0200 Subject: [PATCH 099/144] OP-6037 - extracted generic logic from working Nuke implementation Will be used as a base for Maya impl too. --- openpype/modules/royalrender/lib.py | 301 ++++++++++++++++ .../publish/create_nuke_royalrender_job.py | 320 ++---------------- 2 files changed, 326 insertions(+), 295 deletions(-) create mode 100644 openpype/modules/royalrender/lib.py diff --git a/openpype/modules/royalrender/lib.py b/openpype/modules/royalrender/lib.py new file mode 100644 index 0000000000..ce78b1a738 --- /dev/null +++ b/openpype/modules/royalrender/lib.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +"""Submitting render job to RoyalRender.""" +import os +import re +import platform +from datetime import datetime + +import pyblish.api +from openpype.tests.lib import is_in_tests +from openpype.pipeline.publish.lib import get_published_workfile_instance +from openpype.pipeline.publish import KnownPublishError +from openpype.modules.royalrender.api import Api as rrApi +from openpype.modules.royalrender.rr_job import ( + RRJob, CustomAttribute, get_rr_platform) +from openpype.lib import ( + is_running_from_build, + BoolDef, + NumberDef, +) +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Creates separate rendering job for Royal Render""" + label = "Create Nuke Render job in RR" + order = pyblish.api.IntegratorOrder + 0.1 + hosts = ["nuke"] + families = ["render", "prerender"] + targets = ["local"] + optional = True + + priority = 50 + chunk_size = 1 + concurrent_tasks = 1 + use_gpu = True + use_published = True + + @classmethod + def get_attribute_defs(cls): + return [ + NumberDef( + "priority", + label="Priority", + default=cls.priority, + decimals=0 + ), + NumberDef( + "chunk", + label="Frames Per Task", + default=cls.chunk_size, + decimals=0, + minimum=1, + maximum=1000 + ), + NumberDef( + "concurrency", + label="Concurrency", + default=cls.concurrent_tasks, + decimals=0, + minimum=1, + maximum=10 + ), + BoolDef( + "use_gpu", + default=cls.use_gpu, + label="Use GPU" + ), + BoolDef( + "suspend_publish", + default=False, + label="Suspend publish" + ), + BoolDef( + "use_published", + default=cls.use_published, + label="Use published workfile" + ) + ] + + def __init__(self, *args, **kwargs): + self._instance = None + self._rr_root = None + self.scene_path = None + self.job = None + self.submission_parameters = None + self.rr_api = None + + def process(self, instance): + if not instance.data.get("farm"): + self.log.info("Skipping local instance.") + return + + instance.data["attributeValues"] = self.get_attr_values_from_data( + instance.data) + + # add suspend_publish attributeValue to instance data + instance.data["suspend_publish"] = instance.data["attributeValues"][ + "suspend_publish"] + + context = instance.context + self._instance = instance + + self._rr_root = self._resolve_rr_path(context, instance.data.get( + "rrPathName")) # noqa + self.log.debug(self._rr_root) + if not self._rr_root: + raise KnownPublishError( + ("Missing RoyalRender root. " + "You need to configure RoyalRender module.")) + + self.rr_api = rrApi(self._rr_root) + + self.scene_path = context.data["currentFile"] + if self.use_published: + file_path = get_published_workfile_instance(context) + + # fallback if nothing was set + if not file_path: + self.log.warning("Falling back to workfile") + file_path = context.data["currentFile"] + + self.scene_path = file_path + self.log.info( + "Using published scene for render {}".format(self.scene_path) + ) + + if not self._instance.data.get("expectedFiles"): + self._instance.data["expectedFiles"] = [] + + if not self._instance.data.get("rrJobs"): + self._instance.data["rrJobs"] = [] + + self._instance.data["outputDir"] = os.path.dirname( + self._instance.data["path"]).replace("\\", "/") + + def get_job(self, instance, script_path, render_path, node_name): + """Get RR job based on current instance. + + Args: + script_path (str): Path to Nuke script. + render_path (str): Output path. + node_name (str): Name of the render node. + + Returns: + RRJob: RoyalRender Job instance. + + """ + start_frame = int(instance.data["frameStartHandle"]) + end_frame = int(instance.data["frameEndHandle"]) + + batch_name = os.path.basename(script_path) + jobname = "%s - %s" % (batch_name, self._instance.name) + if is_in_tests(): + batch_name += datetime.now().strftime("%d%m%Y%H%M%S") + + render_dir = os.path.normpath(os.path.dirname(render_path)) + output_filename_0 = self.preview_fname(render_path) + file_name, file_ext = os.path.splitext( + os.path.basename(output_filename_0)) + + custom_attributes = [] + if is_running_from_build(): + custom_attributes = [ + CustomAttribute( + name="OpenPypeVersion", + value=os.environ.get("OPENPYPE_VERSION")) + ] + + # this will append expected files to instance as needed. + expected_files = self.expected_files( + instance, render_path, start_frame, end_frame) + instance.data["expectedFiles"].extend(expected_files) + + job = RRJob( + Software="", + Renderer="", + SeqStart=int(start_frame), + SeqEnd=int(end_frame), + SeqStep=int(instance.data.get("byFrameStep", 1)), + SeqFileOffset=0, + Version=0, + SceneName=script_path, + IsActive=True, + ImageDir=render_dir.replace("\\", "/"), + ImageFilename=file_name, + ImageExtension=file_ext, + ImagePreNumberLetter="", + ImageSingleOutputFile=False, + SceneOS=get_rr_platform(), + Layer=node_name, + SceneDatabaseDir=script_path, + CustomSHotName=jobname, + CompanyProjectName=instance.context.data["projectName"], + ImageWidth=instance.data["resolutionWidth"], + ImageHeight=instance.data["resolutionHeight"], + CustomAttributes=custom_attributes + ) + + return job + + def update_job_with_host_specific(self, instance, job): + """Host specific mapping for RRJob""" + raise NotImplementedError + + @staticmethod + def _resolve_rr_path(context, rr_path_name): + # type: (pyblish.api.Context, str) -> str + rr_settings = ( + context.data + ["system_settings"] + ["modules"] + ["royalrender"] + ) + try: + default_servers = rr_settings["rr_paths"] + project_servers = ( + context.data + ["project_settings"] + ["royalrender"] + ["rr_paths"] + ) + rr_servers = { + k: default_servers[k] + for k in project_servers + if k in default_servers + } + + except (AttributeError, KeyError): + # Handle situation were we had only one url for royal render. + return context.data["defaultRRPath"][platform.system().lower()] + + return rr_servers[rr_path_name][platform.system().lower()] + + def expected_files(self, instance, path, start_frame, end_frame): + """Get expected files. + + This function generate expected files from provided + path and start/end frames. + + It was taken from Deadline module, but this should be + probably handled better in collector to support more + flexible scenarios. + + Args: + instance (Instance) + path (str): Output path. + start_frame (int): Start frame. + end_frame (int): End frame. + + Returns: + list: List of expected files. + + """ + if instance.data.get("expectedFiles"): + return instance.data["expectedFiles"] + + dir_name = os.path.dirname(path) + file = os.path.basename(path) + + expected_files = [] + + if "#" in file: + pparts = file.split("#") + padding = "%0{}d".format(len(pparts) - 1) + file = pparts[0] + padding + pparts[-1] + + if "%" not in file: + expected_files.append(path) + return expected_files + + if self._instance.data.get("slate"): + start_frame -= 1 + + expected_files.extend( + os.path.join(dir_name, (file % i)).replace("\\", "/") + for i in range(start_frame, (end_frame + 1)) + ) + return expected_files + + def preview_fname(self, path): + """Return output file path with #### for padding. + + RR requires the path to be formatted with # in place of numbers. + For example `/path/to/render.####.png` + + Args: + path (str): path to rendered images + + Returns: + str + + """ + self.log.debug("_ path: `{}`".format(path)) + if "%" in path: + search_results = re.search(r"(%0)(\d)(d.)", path).groups() + self.log.debug("_ search_results: `{}`".format(search_results)) + return int(search_results[1]) + if "#" in path: + self.log.debug("_ path: `{}`".format(path)) + return path diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py index a90c4c4f83..62636a6744 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py @@ -1,137 +1,18 @@ # -*- coding: utf-8 -*- """Submitting render job to RoyalRender.""" -import os import re -import platform -from datetime import datetime -import pyblish.api -from openpype.tests.lib import is_in_tests -from openpype.pipeline.publish.lib import get_published_workfile_instance -from openpype.pipeline.publish import KnownPublishError -from openpype.modules.royalrender.api import Api as rrApi -from openpype.modules.royalrender.rr_job import ( - RRJob, CustomAttribute, get_rr_platform) -from openpype.lib import ( - is_running_from_build, - BoolDef, - NumberDef, -) -from openpype.pipeline import OpenPypePyblishPluginMixin +from openpype.modules.royalrender import lib -class CreateNukeRoyalRenderJob(pyblish.api.InstancePlugin, - OpenPypePyblishPluginMixin): +class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): """Creates separate rendering job for Royal Render""" label = "Create Nuke Render job in RR" - order = pyblish.api.IntegratorOrder + 0.1 hosts = ["nuke"] families = ["render", "prerender"] - targets = ["local"] - optional = True - - priority = 50 - chunk_size = 1 - concurrent_tasks = 1 - use_gpu = True - use_published = True - - @classmethod - def get_attribute_defs(cls): - return [ - NumberDef( - "priority", - label="Priority", - default=cls.priority, - decimals=0 - ), - NumberDef( - "chunk", - label="Frames Per Task", - default=cls.chunk_size, - decimals=0, - minimum=1, - maximum=1000 - ), - NumberDef( - "concurrency", - label="Concurrency", - default=cls.concurrent_tasks, - decimals=0, - minimum=1, - maximum=10 - ), - BoolDef( - "use_gpu", - default=cls.use_gpu, - label="Use GPU" - ), - BoolDef( - "suspend_publish", - default=False, - label="Suspend publish" - ), - BoolDef( - "use_published", - default=cls.use_published, - label="Use published workfile" - ) - ] - - def __init__(self, *args, **kwargs): - self._instance = None - self._rr_root = None - self.scene_path = None - self.job = None - self.submission_parameters = None - self.rr_api = None def process(self, instance): - if not instance.data.get("farm"): - self.log.info("Skipping local instance.") - return - - instance.data["attributeValues"] = self.get_attr_values_from_data( - instance.data) - - # add suspend_publish attributeValue to instance data - instance.data["suspend_publish"] = instance.data["attributeValues"][ - "suspend_publish"] - - context = instance.context - self._instance = instance - - self._rr_root = self._resolve_rr_path(context, instance.data.get( - "rrPathName")) # noqa - self.log.debug(self._rr_root) - if not self._rr_root: - raise KnownPublishError( - ("Missing RoyalRender root. " - "You need to configure RoyalRender module.")) - - self.rr_api = rrApi(self._rr_root) - - self.scene_path = context.data["currentFile"] - if self.use_published: - file_path = get_published_workfile_instance(context) - - # fallback if nothing was set - if not file_path: - self.log.warning("Falling back to workfile") - file_path = context.data["currentFile"] - - self.scene_path = file_path - self.log.info( - "Using published scene for render {}".format(self.scene_path) - ) - - if not self._instance.data.get("expectedFiles"): - self._instance.data["expectedFiles"] = [] - - if not self._instance.data.get("rrJobs"): - self._instance.data["rrJobs"] = [] - - self._instance.data["rrJobs"].extend(self.create_jobs()) + super(CreateNukeRoyalRenderJob, self).process(instance) # redefinition of families if "render" in self._instance.data["family"]: @@ -141,26 +22,37 @@ class CreateNukeRoyalRenderJob(pyblish.api.InstancePlugin, self._instance.data["family"] = "write" self._instance.data["families"].insert(0, "prerender") - self._instance.data["outputDir"] = os.path.dirname( - self._instance.data["path"]).replace("\\", "/") + jobs = self.create_jobs(self._instance) + for job in jobs: + job = self.update_job_with_host_specific(instance, job) - def create_jobs(self): - submit_frame_start = int(self._instance.data["frameStartHandle"]) - submit_frame_end = int(self._instance.data["frameEndHandle"]) + instance.data["rrJobs"] += jobs + def update_job_with_host_specific(self, instance, job): + nuke_version = re.search( + r"\d+\.\d+", self._instance.context.data.get("hostVersion")) + + job.Software = "Nuke" + job.Version = nuke_version.group() + + return job + + def create_jobs(self, instance): + """Nuke creates multiple RR jobs - for baking etc.""" # get output path - render_path = self._instance.data['path'] + render_path = instance.data['path'] + self.log.info("render::{}".format(render_path)) + self.log.info("expected::{}".format(instance.data.get("expectedFiles"))) script_path = self.scene_path node = self._instance.data["transientData"]["node"] # main job jobs = [ self.get_job( + instance, script_path, render_path, - node.name(), - submit_frame_start, - submit_frame_end, + node.name() ) ] @@ -170,172 +62,10 @@ class CreateNukeRoyalRenderJob(pyblish.api.InstancePlugin, exe_node_name = baking_script["bakeWriteNodeName"] jobs.append(self.get_job( + instance, script_path, render_path, - exe_node_name, - submit_frame_start, - submit_frame_end + exe_node_name )) return jobs - - def get_job(self, script_path, render_path, - node_name, start_frame, end_frame): - """Get RR job based on current instance. - - Args: - script_path (str): Path to Nuke script. - render_path (str): Output path. - node_name (str): Name of the render node. - start_frame (int): Start frame. - end_frame (int): End frame. - - Returns: - RRJob: RoyalRender Job instance. - - """ - render_dir = os.path.normpath(os.path.dirname(render_path)) - batch_name = os.path.basename(script_path) - jobname = "%s - %s" % (batch_name, self._instance.name) - if is_in_tests(): - batch_name += datetime.now().strftime("%d%m%Y%H%M%S") - - output_filename_0 = self.preview_fname(render_path) - file_name, file_ext = os.path.splitext( - os.path.basename(output_filename_0)) - - custom_attributes = [] - if is_running_from_build(): - custom_attributes = [ - CustomAttribute( - name="OpenPypeVersion", - value=os.environ.get("OPENPYPE_VERSION")) - ] - - nuke_version = re.search( - r"\d+\.\d+", self._instance.context.data.get("hostVersion")) - - # this will append expected files to instance as needed. - expected_files = self.expected_files( - render_path, start_frame, end_frame) - self._instance.data["expectedFiles"].extend(expected_files) - - job = RRJob( - Software="Nuke", - Renderer="", - SeqStart=int(start_frame), - SeqEnd=int(end_frame), - SeqStep=int(self._instance.data.get("byFrameStep", 1)), - SeqFileOffset=0, - Version=nuke_version.group(), - SceneName=script_path, - IsActive=True, - ImageDir=render_dir.replace("\\", "/"), - ImageFilename=file_name, - ImageExtension=file_ext, - ImagePreNumberLetter="", - ImageSingleOutputFile=False, - SceneOS=get_rr_platform(), - Layer=node_name, - SceneDatabaseDir=script_path, - CustomSHotName=jobname, - CompanyProjectName=self._instance.context.data["projectName"], - ImageWidth=self._instance.data["resolutionWidth"], - ImageHeight=self._instance.data["resolutionHeight"], - CustomAttributes=custom_attributes - ) - - return job - - @staticmethod - def _resolve_rr_path(context, rr_path_name): - # type: (pyblish.api.Context, str) -> str - rr_settings = ( - context.data - ["system_settings"] - ["modules"] - ["royalrender"] - ) - try: - default_servers = rr_settings["rr_paths"] - project_servers = ( - context.data - ["project_settings"] - ["royalrender"] - ["rr_paths"] - ) - rr_servers = { - k: default_servers[k] - for k in project_servers - if k in default_servers - } - - except (AttributeError, KeyError): - # Handle situation were we had only one url for royal render. - return context.data["defaultRRPath"][platform.system().lower()] - - return rr_servers[rr_path_name][platform.system().lower()] - - def expected_files(self, path, start_frame, end_frame): - """Get expected files. - - This function generate expected files from provided - path and start/end frames. - - It was taken from Deadline module, but this should be - probably handled better in collector to support more - flexible scenarios. - - Args: - path (str): Output path. - start_frame (int): Start frame. - end_frame (int): End frame. - - Returns: - list: List of expected files. - - """ - dir_name = os.path.dirname(path) - file = os.path.basename(path) - - expected_files = [] - - if "#" in file: - pparts = file.split("#") - padding = "%0{}d".format(len(pparts) - 1) - file = pparts[0] + padding + pparts[-1] - - if "%" not in file: - expected_files.append(path) - return expected_files - - if self._instance.data.get("slate"): - start_frame -= 1 - - expected_files.extend( - os.path.join(dir_name, (file % i)).replace("\\", "/") - for i in range(start_frame, (end_frame + 1)) - ) - return expected_files - - def preview_fname(self, path): - """Return output file path with #### for padding. - - Deadline requires the path to be formatted with # in place of numbers. - For example `/path/to/render.####.png` - - Args: - path (str): path to rendered images - - Returns: - str - - """ - self.log.debug("_ path: `{}`".format(path)) - if "%" in path: - search_results = re.search(r"(%0)(\d)(d.)", path).groups() - self.log.debug("_ search_results: `{}`".format(search_results)) - return int(search_results[1]) - if "#" in path: - self.log.debug("_ path: `{}`".format(path)) - return path From 644b734585cadecb32f953b4efa3c4859150bebb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Jun 2023 17:32:11 +0200 Subject: [PATCH 100/144] OP-6037 - first iteration of Maya to RR Uses generic logic, probably needs fine tuning. --- .../publish/create_maya_royalrender_job.py | 160 +++--------------- 1 file changed, 23 insertions(+), 137 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 1bb2785dd6..5ad3c90bdc 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -1,156 +1,42 @@ # -*- coding: utf-8 -*- """Submitting render job to RoyalRender.""" +# -*- coding: utf-8 -*- +"""Submitting render job to RoyalRender.""" import os -import sys -import platform -from maya.OpenMaya import MGlobal # noqa -import pyblish.api -from openpype.lib import is_running_from_build -from openpype.pipeline.publish.lib import get_published_workfile_instance -from openpype.pipeline.publish import KnownPublishError -from openpype.modules.royalrender.api import Api as rrApi -from openpype.modules.royalrender.rr_job import RRJob, CustomAttribute +from maya.OpenMaya import MGlobal + +from openpype.modules.royalrender import lib from openpype.pipeline.farm.tools import iter_expected_files -class CreateMayaRoyalRenderJob(pyblish.api.InstancePlugin): +class CreateMayaRoyalRenderJob(lib.BaseCreateRoyalRenderJob): label = "Create Maya Render job in RR" - order = pyblish.api.IntegratorOrder + 0.1 families = ["renderlayer"] - targets = ["local"] - use_published = True - def __init__(self, *args, **kwargs): - self._instance = None - self._rrRoot = None - self.scene_path = None - self.job = None - self.submission_parameters = None - self.rr_api = None - - def get_job(self): - """Prepare job payload. - - Returns: - RRJob: RoyalRender job payload. - """ - def get_rr_platform(): - if sys.platform.lower() in ["win32", "win64"]: - return "windows" - elif sys.platform.lower() == "darwin": - return "mac" - else: - return "linux" - - expected_files = self._instance.data["expectedFiles"] - first_file = next(iter_expected_files(expected_files)) - output_dir = os.path.dirname(first_file) - self._instance.data["outputDir"] = output_dir - workspace = self._instance.context.data["workspaceDir"] - default_render_file = ( - self._instance.context.data - ['project_settings'] - ['maya'] - ['RenderSettings'] - ['default_render_image_folder'] - ) - # file_name = os.path.basename(self.scene_path) - dir_name = os.path.join(workspace, default_render_file) - layer = self._instance.data["setMembers"] # type: str - layer_name = layer.removeprefix("rs_") - - custom_attributes = [] - if is_running_from_build(): - custom_attributes = [ - CustomAttribute( - name="OpenPypeVersion", - value=os.environ.get("OPENPYPE_VERSION")) - ] - - job = RRJob( - Software="Maya", - Renderer=self._instance.data["renderer"], - SeqStart=int(self._instance.data["frameStartHandle"]), - SeqEnd=int(self._instance.data["frameEndHandle"]), - SeqStep=int(self._instance.data["byFrameStep"]), - SeqFileOffset=0, - Version="{0:.2f}".format(MGlobal.apiVersion() / 10000), - SceneName=self.scene_path, - IsActive=True, - ImageDir=dir_name, - ImageFilename="{}.".format(layer_name), - ImageExtension=os.path.splitext(first_file)[1], - ImagePreNumberLetter=".", - ImageSingleOutputFile=False, - SceneOS=get_rr_platform(), - Camera=self._instance.data["cameras"][0], - Layer=layer_name, - SceneDatabaseDir=workspace, - CustomSHotName=self._instance.context.data["asset"], - CompanyProjectName=self._instance.context.data["projectName"], - ImageWidth=self._instance.data["resolutionWidth"], - ImageHeight=self._instance.data["resolutionHeight"], - CustomAttributes=custom_attributes - ) + def update_job_with_host_specific(self, instance, job): + job.Software = "Maya" + job.Version = "{0:.2f}".format(MGlobal.apiVersion() / 10000) + job.Camera = instance.data["cameras"][0], + workspace = instance.context.data["workspaceDir"] + job.SceneDatabaseDir = workspace return job def process(self, instance): """Plugin entry point.""" - self._instance = instance - context = instance.context + super(CreateMayaRoyalRenderJob, self).process(instance) - self._rr_root = self._resolve_rr_path(context, instance.data.get("rrPathName")) # noqa - self.log.debug(self._rr_root) - if not self._rr_root: - raise KnownPublishError( - ("Missing RoyalRender root. " - "You need to configure RoyalRender module.")) + expected_files = self._instance.data["expectedFiles"] + first_file_path = next(iter_expected_files(expected_files)) + output_dir = os.path.dirname(first_file_path) + self._instance.data["outputDir"] = output_dir - self.rr_api = rrApi(self._rr_root) + layer = self._instance.data["setMembers"] # type: str + layer_name = layer.removeprefix("rs_") - file_path = None - if self.use_published: - file_path = get_published_workfile_instance(context) + job = self.get_job(instance, self.scene_path, first_file_path, + layer_name) + job = self.update_job_with_host_specific(instance, job) - # fallback if nothing was set - if not file_path: - self.log.warning("Falling back to workfile") - file_path = context.data["currentFile"] - - self.scene_path = file_path - - if not self._instance.data.get("rrJobs"): - self._instance.data["rrJobs"] = [] - - self._instance.data["rrJobs"] += self.get_job() - - @staticmethod - def _resolve_rr_path(context, rr_path_name): - # type: (pyblish.api.Context, str) -> str - rr_settings = ( - context.data - ["system_settings"] - ["modules"] - ["royalrender"] - ) - try: - default_servers = rr_settings["rr_paths"] - project_servers = ( - context.data - ["project_settings"] - ["royalrender"] - ["rr_paths"] - ) - rr_servers = { - k: default_servers[k] - for k in project_servers - if k in default_servers - } - - except (AttributeError, KeyError): - # Handle situation were we had only one url for royal render. - return context.data["defaultRRPath"][platform.system().lower()] - - return rr_servers[rr_path_name][platform.system().lower()] + instance.data["rrJobs"] += job From 9cb121601a27727dd73df8b3241bd72c666edda0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 14 Jun 2023 10:13:48 +0200 Subject: [PATCH 101/144] Resolved conflict --- .../plugins/publish/collect_rr_path_from_instance.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py index 6a3dc276f3..c2ba1e2c19 100644 --- a/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py +++ b/openpype/modules/royalrender/plugins/publish/collect_rr_path_from_instance.py @@ -5,9 +5,9 @@ import pyblish.api class CollectRRPathFromInstance(pyblish.api.InstancePlugin): """Collect RR Path from instance.""" - order = pyblish.api.CollectorOrder + 0.01 - label = "Royal Render Path from the Instance" - families = ["rendering"] + order = pyblish.api.CollectorOrder + label = "Collect Royal Render path name from the Instance" + families = ["render", "prerender", "renderlayer"] def process(self, instance): instance.data["rrPath"] = self._collect_rr_path(instance) From 1e3e63aaecb5524839c06b3a74f2cfe253f82ca9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 15 Jun 2023 16:41:10 +0100 Subject: [PATCH 102/144] Implemented loader for blend representations --- .../hosts/blender/plugins/load/load_blend.py | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 openpype/hosts/blender/plugins/load/load_blend.py diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py new file mode 100644 index 0000000000..d35df366cf --- /dev/null +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -0,0 +1,124 @@ +from typing import Dict, List, Optional + +import bpy + +from openpype.pipeline import ( + AVALON_CONTAINER_ID, +) +from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.pipeline import ( + AVALON_CONTAINERS, + AVALON_PROPERTY, +) + + +class BlendLoader(plugin.AssetLoader): + """Load assets from a .blend file.""" + + families = ["model", "rig"] + representations = ["blend"] + + label = "Load Blend" + icon = "code-fork" + color = "orange" + + @staticmethod + def _get_asset_container(objects): + empties = [obj for obj in objects if obj.type == 'EMPTY'] + + for empty in empties: + if empty.get(AVALON_PROPERTY): + return empty + + return None + + def _process_data(self, libpath, group_name): + # Append all the data from the .blend file + with bpy.data.libraries.load( + libpath, link=False, relative=False + ) as (data_from, data_to): + for attr in dir(data_to): + setattr(data_to, attr, getattr(data_from, attr)) + + # Rename the object to add the asset name + for attr in dir(data_to): + for data in getattr(data_to, attr): + data.name = f"{group_name}:{data.name}" + + container = self._get_asset_container(data_to.objects) + assert container, "No asset group found" + + container.name = group_name + container.empty_display_type = 'SINGLE_ARROW' + + # Link the collection to the scene + bpy.context.scene.collection.objects.link(container) + + # Link all the container children to the collection + for obj in container.children_recursive: + bpy.context.scene.collection.objects.link(obj) + + return container + + def process_asset( + self, context: dict, name: str, namespace: Optional[str] = None, + options: Optional[Dict] = None + ) -> Optional[List]: + """ + Arguments: + name: Use pre-defined name + namespace: Use pre-defined namespace + context: Full parenthood of representation to load + options: Additional settings dictionary + """ + libpath = self.fname + asset = context["asset"]["name"] + subset = context["subset"]["name"] + + asset_name = plugin.asset_name(asset, subset) + unique_number = plugin.get_unique_number(asset, subset) + group_name = plugin.asset_name(asset, subset, unique_number) + namespace = namespace or f"{asset}_{unique_number}" + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + if not avalon_container: + avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) + bpy.context.scene.collection.children.link(avalon_container) + + container = self._process_data(libpath, group_name) + + avalon_container.objects.link(container) + + container[AVALON_PROPERTY] = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": name, + "namespace": namespace or '', + "loader": str(self.__class__.__name__), + "representation": str(context["representation"]["_id"]), + "libpath": libpath, + "asset_name": asset_name, + "parent": str(context["representation"]["parent"]), + "family": context["representation"]["context"]["family"], + "objectName": group_name + } + + objects = [ + obj for obj in bpy.data.objects + if f"{group_name}:" in obj.name + ] + + self[:] = objects + return objects + + def exec_update(self, container: Dict, representation: Dict): + """ + Update the loaded asset. + """ + raise NotImplementedError() + + def exec_remove(self, container: Dict) -> bool: + """ + Remove an existing container from a Blender scene. + """ + raise NotImplementedError() From b0f42ae6cf5d690b8d0fbf1e93f72f933de45f0c Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 15 Jun 2023 16:50:51 +0100 Subject: [PATCH 103/144] Remove library after loading --- openpype/hosts/blender/plugins/load/load_blend.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index d35df366cf..83ce4ffd3e 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -33,7 +33,7 @@ class BlendLoader(plugin.AssetLoader): return None def _process_data(self, libpath, group_name): - # Append all the data from the .blend file + # Append all the data from the .blend file with bpy.data.libraries.load( libpath, link=False, relative=False ) as (data_from, data_to): @@ -89,6 +89,10 @@ class BlendLoader(plugin.AssetLoader): avalon_container.objects.link(container) + # Remove the library from the blend file + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) + container[AVALON_PROPERTY] = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, From 1fe5cb0e0cfa50c1a2ec50876d168a39284ebcfd Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 16 Jun 2023 10:28:11 +0100 Subject: [PATCH 104/144] Implemented update and remove --- .../hosts/blender/plugins/load/load_blend.py | 61 +++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 83ce4ffd3e..c7f916f10e 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -1,11 +1,14 @@ from typing import Dict, List, Optional +from pathlib import Path import bpy from openpype.pipeline import ( + get_representation_path, AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import plugin +from openpype.hosts.blender.api.lib import imprint from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, @@ -93,7 +96,7 @@ class BlendLoader(plugin.AssetLoader): library = bpy.data.libraries.get(bpy.path.basename(libpath)) bpy.data.libraries.remove(library) - container[AVALON_PROPERTY] = { + data = { "schema": "openpype:container-2.0", "id": AVALON_CONTAINER_ID, "name": name, @@ -107,9 +110,11 @@ class BlendLoader(plugin.AssetLoader): "objectName": group_name } + container[AVALON_PROPERTY] = data + objects = [ obj for obj in bpy.data.objects - if f"{group_name}:" in obj.name + if obj.name.startswith(f"{group_name}:") ] self[:] = objects @@ -119,10 +124,58 @@ class BlendLoader(plugin.AssetLoader): """ Update the loaded asset. """ - raise NotImplementedError() + group_name = container["objectName"] + asset_group = bpy.data.objects.get(group_name) + libpath = Path(get_representation_path(representation)).as_posix() + + assert asset_group, ( + f"The asset is not loaded: {container['objectName']}" + ) + + transform = asset_group.matrix_basis.copy() + old_data = dict(asset_group.get(AVALON_PROPERTY)) + + self.exec_remove(container) + + asset_group = self._process_data(libpath, group_name) + + avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) + avalon_container.objects.link(asset_group) + + # Remove the library from the blend file + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) + + asset_group.matrix_basis = transform + + asset_group[AVALON_PROPERTY] = old_data + + new_data = { + "libpath": libpath, + "representation": str(representation["_id"]), + "parent": str(representation["parent"]), + } + + imprint(asset_group, new_data) def exec_remove(self, container: Dict) -> bool: """ Remove an existing container from a Blender scene. """ - raise NotImplementedError() + group_name = container["objectName"] + asset_group = bpy.data.objects.get(group_name) + + attrs = [ + attr for attr in dir(bpy.data) + if isinstance( + getattr(bpy.data, attr), + bpy.types.bpy_prop_collection + ) + ] + + for attr in attrs: + for data in getattr(bpy.data, attr): + if data.name.startswith(f"{group_name}:"): + getattr(bpy.data, attr).remove(data) + + bpy.data.objects.remove(asset_group) From d6aabc93ddb485bda730d251dd64a70ed57899dc Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 16 Jun 2023 15:29:09 +0100 Subject: [PATCH 105/144] Added support for layouts in new blend loader --- .../hosts/blender/plugins/load/load_blend.py | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index c7f916f10e..b64793566f 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -4,9 +4,11 @@ from pathlib import Path import bpy from openpype.pipeline import ( + legacy_create, get_representation_path, AVALON_CONTAINER_ID, ) +from openpype.pipeline.create import get_legacy_creator_by_name from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.lib import imprint from openpype.hosts.blender.api.pipeline import ( @@ -18,7 +20,7 @@ from openpype.hosts.blender.api.pipeline import ( class BlendLoader(plugin.AssetLoader): """Load assets from a .blend file.""" - families = ["model", "rig"] + families = ["model", "rig", "layout"] representations = ["blend"] label = "Load Blend" @@ -35,6 +37,31 @@ class BlendLoader(plugin.AssetLoader): return None + def _post_process_layout(self, container, asset, representation): + rigs = [ + obj for obj in container.children_recursive + if ( + obj.type == 'EMPTY' and + obj.get(AVALON_PROPERTY) and + obj.get(AVALON_PROPERTY).get('family') == 'rig' + ) + ] + + for rig in rigs: + creator_plugin = get_legacy_creator_by_name("CreateAnimation") + legacy_create( + creator_plugin, + name=rig.name.split(':')[-1] + "_animation", + asset=asset, + options={ + "useSelection": False, + "asset_group": rig + }, + data={ + "dependencies": representation + } + ) + def _process_data(self, libpath, group_name): # Append all the data from the .blend file with bpy.data.libraries.load( @@ -61,6 +88,10 @@ class BlendLoader(plugin.AssetLoader): for obj in container.children_recursive: bpy.context.scene.collection.objects.link(obj) + # Remove the library from the blend file + library = bpy.data.libraries.get(bpy.path.basename(libpath)) + bpy.data.libraries.remove(library) + return container def process_asset( @@ -78,6 +109,13 @@ class BlendLoader(plugin.AssetLoader): asset = context["asset"]["name"] subset = context["subset"]["name"] + try: + family = context["representation"]["context"]["family"] + except ValueError: + family = "model" + + representation = str(context["representation"]["_id"]) + asset_name = plugin.asset_name(asset, subset) unique_number = plugin.get_unique_number(asset, subset) group_name = plugin.asset_name(asset, subset, unique_number) @@ -90,11 +128,10 @@ class BlendLoader(plugin.AssetLoader): container = self._process_data(libpath, group_name) - avalon_container.objects.link(container) + if family == "layout": + self._post_process_layout(container, asset, representation) - # Remove the library from the blend file - library = bpy.data.libraries.get(bpy.path.basename(libpath)) - bpy.data.libraries.remove(library) + avalon_container.objects.link(container) data = { "schema": "openpype:container-2.0", @@ -134,6 +171,7 @@ class BlendLoader(plugin.AssetLoader): transform = asset_group.matrix_basis.copy() old_data = dict(asset_group.get(AVALON_PROPERTY)) + parent = asset_group.parent self.exec_remove(container) @@ -142,11 +180,8 @@ class BlendLoader(plugin.AssetLoader): avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.objects.link(asset_group) - # Remove the library from the blend file - library = bpy.data.libraries.get(bpy.path.basename(libpath)) - bpy.data.libraries.remove(library) - asset_group.matrix_basis = transform + asset_group.parent = parent asset_group[AVALON_PROPERTY] = old_data From e41621875f9b01a0f4aa7c5ad92ebd85b3ad0098 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 16 Jun 2023 15:32:55 +0100 Subject: [PATCH 106/144] Removed old loaders and redundant plugins --- .../plugins/create/create_blender_scene.py | 37 -- .../blender/plugins/load/load_layout_blend.py | 469 ------------------ .../hosts/blender/plugins/load/load_model.py | 296 ----------- .../hosts/blender/plugins/load/load_rig.py | 417 ---------------- .../publish/extract_blender_scene_raw.py | 49 -- openpype/plugins/publish/integrate_legacy.py | 3 +- 6 files changed, 1 insertion(+), 1270 deletions(-) delete mode 100644 openpype/hosts/blender/plugins/create/create_blender_scene.py delete mode 100644 openpype/hosts/blender/plugins/load/load_layout_blend.py delete mode 100644 openpype/hosts/blender/plugins/load/load_model.py delete mode 100644 openpype/hosts/blender/plugins/load/load_rig.py delete mode 100644 openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py diff --git a/openpype/hosts/blender/plugins/create/create_blender_scene.py b/openpype/hosts/blender/plugins/create/create_blender_scene.py deleted file mode 100644 index b63ed4fd3f..0000000000 --- a/openpype/hosts/blender/plugins/create/create_blender_scene.py +++ /dev/null @@ -1,37 +0,0 @@ -import bpy - -from openpype.pipeline import legacy_io -from openpype.hosts.blender.api import plugin, lib, ops -from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES - - -class CreateBlenderScene(plugin.Creator): - """Raw Blender Scene file export""" - - name = "blenderScene" - label = "Blender Scene" - family = "blenderScene" - icon = "file-archive-o" - - def process(self): - """ Run the creator on Blender main thread""" - mti = ops.MainThreadItem(self._process) - ops.execute_in_main_thread(mti) - - def _process(self): - # Get Instance Container or create it if it does not exist - instances = bpy.data.collections.get(AVALON_INSTANCES) - if not instances: - instances = bpy.data.collections.new(name=AVALON_INSTANCES) - bpy.context.scene.collection.children.link(instances) - - asset = self.data["asset"] - subset = self.data["subset"] - name = plugin.asset_name(asset, subset) - - asset_group = bpy.data.collections.new(name=name) - instances.children.link(asset_group) - self.data['task'] = legacy_io.Session.get('AVALON_TASK') - lib.imprint(asset_group, self.data) - - return asset_group diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py deleted file mode 100644 index 7d2fd23444..0000000000 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ /dev/null @@ -1,469 +0,0 @@ -"""Load a layout in Blender.""" - -from pathlib import Path -from pprint import pformat -from typing import Dict, List, Optional - -import bpy - -from openpype.pipeline import ( - legacy_create, - get_representation_path, - AVALON_CONTAINER_ID, -) -from openpype.pipeline.create import get_legacy_creator_by_name -from openpype.hosts.blender.api import plugin -from openpype.hosts.blender.api.pipeline import ( - AVALON_CONTAINERS, - AVALON_PROPERTY, -) - - -class BlendLayoutLoader(plugin.AssetLoader): - """Load layout from a .blend file.""" - - families = ["layout"] - representations = ["blend"] - - label = "Link Layout" - icon = "code-fork" - color = "orange" - - def _remove(self, asset_group): - objects = list(asset_group.children) - - for obj in objects: - if obj.type == 'MESH': - for material_slot in list(obj.material_slots): - if material_slot.material: - bpy.data.materials.remove(material_slot.material) - bpy.data.meshes.remove(obj.data) - elif obj.type == 'ARMATURE': - objects.extend(obj.children) - bpy.data.armatures.remove(obj.data) - elif obj.type == 'CURVE': - bpy.data.curves.remove(obj.data) - elif obj.type == 'EMPTY': - objects.extend(obj.children) - bpy.data.objects.remove(obj) - - def _remove_asset_and_library(self, asset_group): - if not asset_group.get(AVALON_PROPERTY): - return - - libpath = asset_group.get(AVALON_PROPERTY).get('libpath') - - if not libpath: - return - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).all_objects: - if obj.get(AVALON_PROPERTY).get('libpath') == libpath: - count += 1 - - self._remove(asset_group) - - bpy.data.objects.remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(libpath)) - if library: - bpy.data.libraries.remove(library) - - def _process( - self, libpath, asset_group, group_name, asset, representation, - actions, anim_instances - ): - with bpy.data.libraries.load( - libpath, link=True, relative=False - ) as (data_from, data_to): - data_to.objects = data_from.objects - - parent = bpy.context.scene.collection - - empties = [obj for obj in data_to.objects if obj.type == 'EMPTY'] - - container = None - - for empty in empties: - if (empty.get(AVALON_PROPERTY) and - empty.get(AVALON_PROPERTY).get('family') == 'layout'): - container = empty - break - - assert container, "No asset group found" - - # Children must be linked before parents, - # otherwise the hierarchy will break - objects = [] - nodes = list(container.children) - - allowed_types = ['ARMATURE', 'MESH', 'EMPTY'] - - for obj in nodes: - if obj.type in allowed_types: - obj.parent = asset_group - - for obj in nodes: - if obj.type in allowed_types: - objects.append(obj) - nodes.extend(list(obj.children)) - - objects.reverse() - - constraints = [] - - armatures = [obj for obj in objects if obj.type == 'ARMATURE'] - - for armature in armatures: - for bone in armature.pose.bones: - for constraint in bone.constraints: - if hasattr(constraint, 'target'): - constraints.append(constraint) - - for obj in objects: - parent.objects.link(obj) - - for obj in objects: - local_obj = plugin.prepare_data(obj) - - action = None - - if actions: - action = actions.get(local_obj.name, None) - - if local_obj.type == 'MESH': - plugin.prepare_data(local_obj.data) - - if obj != local_obj: - for constraint in constraints: - if constraint.target == obj: - constraint.target = local_obj - - for material_slot in local_obj.material_slots: - if material_slot.material: - plugin.prepare_data(material_slot.material) - elif local_obj.type == 'ARMATURE': - plugin.prepare_data(local_obj.data) - - if action: - if local_obj.animation_data is None: - local_obj.animation_data_create() - local_obj.animation_data.action = action - elif (local_obj.animation_data and - local_obj.animation_data.action): - plugin.prepare_data( - local_obj.animation_data.action) - - # Set link the drivers to the local object - if local_obj.data.animation_data: - for d in local_obj.data.animation_data.drivers: - for v in d.driver.variables: - for t in v.targets: - t.id = local_obj - - elif local_obj.type == 'EMPTY': - if (not anim_instances or - (anim_instances and - local_obj.name not in anim_instances.keys())): - avalon = local_obj.get(AVALON_PROPERTY) - if avalon and avalon.get('family') == 'rig': - creator_plugin = get_legacy_creator_by_name( - "CreateAnimation") - if not creator_plugin: - raise ValueError( - "Creator plugin \"CreateAnimation\" was " - "not found.") - - legacy_create( - creator_plugin, - name=local_obj.name.split(':')[-1] + "_animation", - asset=asset, - options={"useSelection": False, - "asset_group": local_obj}, - data={"dependencies": representation} - ) - - if not local_obj.get(AVALON_PROPERTY): - local_obj[AVALON_PROPERTY] = dict() - - avalon_info = local_obj[AVALON_PROPERTY] - avalon_info.update({"container_name": group_name}) - - objects.reverse() - - armatures = [ - obj for obj in bpy.data.objects - if obj.type == 'ARMATURE' and obj.library is None] - arm_act = {} - - # The armatures with an animation need to be at the center of the - # scene to be hooked correctly by the curves modifiers. - for armature in armatures: - if armature.animation_data and armature.animation_data.action: - arm_act[armature] = armature.animation_data.action - armature.animation_data.action = None - armature.location = (0.0, 0.0, 0.0) - for bone in armature.pose.bones: - bone.location = (0.0, 0.0, 0.0) - bone.rotation_euler = (0.0, 0.0, 0.0) - - curves = [obj for obj in data_to.objects if obj.type == 'CURVE'] - - for curve in curves: - curve_name = curve.name.split(':')[0] - curve_obj = bpy.data.objects.get(curve_name) - - local_obj = plugin.prepare_data(curve) - plugin.prepare_data(local_obj.data) - - # Curves need to reset the hook, but to do that they need to be - # in the view layer. - parent.objects.link(local_obj) - plugin.deselect_all() - local_obj.select_set(True) - bpy.context.view_layer.objects.active = local_obj - if local_obj.library is None: - bpy.ops.object.mode_set(mode='EDIT') - bpy.ops.object.hook_reset() - bpy.ops.object.mode_set(mode='OBJECT') - parent.objects.unlink(local_obj) - - local_obj.use_fake_user = True - - for mod in local_obj.modifiers: - mod.object = bpy.data.objects.get(f"{mod.object.name}") - - if not local_obj.get(AVALON_PROPERTY): - local_obj[AVALON_PROPERTY] = dict() - - avalon_info = local_obj[AVALON_PROPERTY] - avalon_info.update({"container_name": group_name}) - - local_obj.parent = curve_obj - objects.append(local_obj) - - for armature in armatures: - if arm_act.get(armature): - armature.animation_data.action = arm_act[armature] - - while bpy.data.orphans_purge(do_local_ids=False): - pass - - plugin.deselect_all() - - return objects - - def process_asset( - self, context: dict, name: str, namespace: Optional[str] = None, - options: Optional[Dict] = None - ) -> Optional[List]: - """ - Arguments: - name: Use pre-defined name - namespace: Use pre-defined namespace - context: Full parenthood of representation to load - options: Additional settings dictionary - """ - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - representation = str(context["representation"]["_id"]) - - asset_name = plugin.asset_name(asset, subset) - unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) - namespace = namespace or f"{asset}_{unique_number}" - - avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) - if not avalon_container: - avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) - bpy.context.scene.collection.children.link(avalon_container) - - asset_group = bpy.data.objects.new(group_name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - avalon_container.objects.link(asset_group) - - objects = self._process( - libpath, asset_group, group_name, asset, representation, - None, None) - - for child in asset_group.children: - if child.get(AVALON_PROPERTY): - avalon_container.objects.link(child) - - bpy.context.scene.collection.objects.link(asset_group) - - asset_group[AVALON_PROPERTY] = { - "schema": "openpype:container-2.0", - "id": AVALON_CONTAINER_ID, - "name": name, - "namespace": namespace or '', - "loader": str(self.__class__.__name__), - "representation": str(context["representation"]["_id"]), - "libpath": libpath, - "asset_name": asset_name, - "parent": str(context["representation"]["parent"]), - "family": context["representation"]["context"]["family"], - "objectName": group_name - } - - self[:] = objects - return objects - - def update(self, container: Dict, representation: Dict): - """Update the loaded asset. - - This will remove all objects of the current collection, load the new - ones and add them to the collection. - If the objects of the collection are used in another collection they - will not be removed, only unlinked. Normally this should not be the - case though. - - Warning: - No nested collections are supported at the moment! - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - libpath = Path(get_representation_path(representation)) - extension = libpath.suffix.lower() - - self.log.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert asset_group, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - metadata = asset_group.get(AVALON_PROPERTY) - group_libpath = metadata["libpath"] - - normalized_group_libpath = ( - str(Path(bpy.path.abspath(group_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - self.log.debug( - "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_group_libpath, - normalized_libpath, - ) - if normalized_group_libpath == normalized_libpath: - self.log.info("Library already loaded, not updating...") - return - - actions = {} - anim_instances = {} - - for obj in asset_group.children: - obj_meta = obj.get(AVALON_PROPERTY) - if obj_meta.get('family') == 'rig': - # Get animation instance - collections = list(obj.users_collection) - for c in collections: - avalon = c.get(AVALON_PROPERTY) - if avalon and avalon.get('family') == 'animation': - anim_instances[obj.name] = c.name - break - - # Get armature's action - rig = None - for child in obj.children: - if child.type == 'ARMATURE': - rig = child - break - if not rig: - raise Exception("No armature in the rig asset group.") - if rig.animation_data and rig.animation_data.action: - instance_name = obj_meta.get('instance_name') - actions[instance_name] = rig.animation_data.action - - mat = asset_group.matrix_basis.copy() - - # Remove the children of the asset_group first - for child in list(asset_group.children): - self._remove_asset_and_library(child) - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: - if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath: - count += 1 - - self._remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) - if library: - bpy.data.libraries.remove(library) - - asset = container.get("asset_name").split("_")[0] - - self._process( - str(libpath), asset_group, object_name, asset, - str(representation.get("_id")), actions, anim_instances - ) - - # Link the new objects to the animation collection - for inst in anim_instances.keys(): - try: - obj = bpy.data.objects[inst] - bpy.data.collections[anim_instances[inst]].objects.link(obj) - except KeyError: - self.log.info(f"Object {inst} does not exist anymore.") - coll = bpy.data.collections.get(anim_instances[inst]) - if (coll): - bpy.data.collections.remove(coll) - - avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) - for child in asset_group.children: - if child.get(AVALON_PROPERTY): - avalon_container.objects.link(child) - - asset_group.matrix_basis = mat - - metadata["libpath"] = str(libpath) - metadata["representation"] = str(representation["_id"]) - - def exec_remove(self, container: Dict) -> bool: - """Remove an existing container from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the container was deleted. - - Warning: - No nested collections are supported at the moment! - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - - if not asset_group: - return False - - # Remove the children of the asset_group first - for child in list(asset_group.children): - self._remove_asset_and_library(child) - - self._remove_asset_and_library(asset_group) - - return True diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py deleted file mode 100644 index 0a5d98ffa0..0000000000 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Load a model asset in Blender.""" - -from pathlib import Path -from pprint import pformat -from typing import Dict, List, Optional - -import bpy - -from openpype.pipeline import ( - get_representation_path, - AVALON_CONTAINER_ID, -) -from openpype.hosts.blender.api import plugin -from openpype.hosts.blender.api.pipeline import ( - AVALON_CONTAINERS, - AVALON_PROPERTY, -) - - -class BlendModelLoader(plugin.AssetLoader): - """Load models from a .blend file. - - Because they come from a .blend file we can simply link the collection that - contains the model. There is no further need to 'containerise' it. - """ - - families = ["model"] - representations = ["blend"] - - label = "Link Model" - icon = "code-fork" - color = "orange" - - def _remove(self, asset_group): - objects = list(asset_group.children) - - for obj in objects: - if obj.type == 'MESH': - for material_slot in list(obj.material_slots): - bpy.data.materials.remove(material_slot.material) - bpy.data.meshes.remove(obj.data) - elif obj.type == 'EMPTY': - objects.extend(obj.children) - bpy.data.objects.remove(obj) - - def _process(self, libpath, asset_group, group_name): - with bpy.data.libraries.load( - libpath, link=True, relative=False - ) as (data_from, data_to): - data_to.objects = data_from.objects - - parent = bpy.context.scene.collection - - empties = [obj for obj in data_to.objects if obj.type == 'EMPTY'] - - container = None - - for empty in empties: - if empty.get(AVALON_PROPERTY): - container = empty - break - - assert container, "No asset group found" - - # Children must be linked before parents, - # otherwise the hierarchy will break - objects = [] - nodes = list(container.children) - - for obj in nodes: - obj.parent = asset_group - - for obj in nodes: - objects.append(obj) - nodes.extend(list(obj.children)) - - objects.reverse() - - for obj in objects: - parent.objects.link(obj) - - for obj in objects: - local_obj = plugin.prepare_data(obj, group_name) - if local_obj.type != 'EMPTY': - plugin.prepare_data(local_obj.data, group_name) - - for material_slot in local_obj.material_slots: - if material_slot.material: - plugin.prepare_data(material_slot.material, group_name) - - if not local_obj.get(AVALON_PROPERTY): - local_obj[AVALON_PROPERTY] = dict() - - avalon_info = local_obj[AVALON_PROPERTY] - avalon_info.update({"container_name": group_name}) - - objects.reverse() - - bpy.data.orphans_purge(do_local_ids=False) - - plugin.deselect_all() - - return objects - - def process_asset( - self, context: dict, name: str, namespace: Optional[str] = None, - options: Optional[Dict] = None - ) -> Optional[List]: - """ - Arguments: - name: Use pre-defined name - namespace: Use pre-defined namespace - context: Full parenthood of representation to load - options: Additional settings dictionary - """ - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - - asset_name = plugin.asset_name(asset, subset) - unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) - namespace = namespace or f"{asset}_{unique_number}" - - avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) - if not avalon_container: - avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) - bpy.context.scene.collection.children.link(avalon_container) - - asset_group = bpy.data.objects.new(group_name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - avalon_container.objects.link(asset_group) - - plugin.deselect_all() - - if options is not None: - parent = options.get('parent') - transform = options.get('transform') - - if parent and transform: - location = transform.get('translation') - rotation = transform.get('rotation') - scale = transform.get('scale') - - asset_group.location = ( - location.get('x'), - location.get('y'), - location.get('z') - ) - asset_group.rotation_euler = ( - rotation.get('x'), - rotation.get('y'), - rotation.get('z') - ) - asset_group.scale = ( - scale.get('x'), - scale.get('y'), - scale.get('z') - ) - - bpy.context.view_layer.objects.active = parent - asset_group.select_set(True) - - bpy.ops.object.parent_set(keep_transform=True) - - plugin.deselect_all() - - objects = self._process(libpath, asset_group, group_name) - - bpy.context.scene.collection.objects.link(asset_group) - - asset_group[AVALON_PROPERTY] = { - "schema": "openpype:container-2.0", - "id": AVALON_CONTAINER_ID, - "name": name, - "namespace": namespace or '', - "loader": str(self.__class__.__name__), - "representation": str(context["representation"]["_id"]), - "libpath": libpath, - "asset_name": asset_name, - "parent": str(context["representation"]["parent"]), - "family": context["representation"]["context"]["family"], - "objectName": group_name - } - - self[:] = objects - return objects - - def exec_update(self, container: Dict, representation: Dict): - """Update the loaded asset. - - This will remove all objects of the current collection, load the new - ones and add them to the collection. - If the objects of the collection are used in another collection they - will not be removed, only unlinked. Normally this should not be the - case though. - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - libpath = Path(get_representation_path(representation)) - extension = libpath.suffix.lower() - - self.log.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert asset_group, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - metadata = asset_group.get(AVALON_PROPERTY) - group_libpath = metadata["libpath"] - - normalized_group_libpath = ( - str(Path(bpy.path.abspath(group_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - self.log.debug( - "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_group_libpath, - normalized_libpath, - ) - if normalized_group_libpath == normalized_libpath: - self.log.info("Library already loaded, not updating...") - return - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: - if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath: - count += 1 - - mat = asset_group.matrix_basis.copy() - - self._remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) - if library: - bpy.data.libraries.remove(library) - - self._process(str(libpath), asset_group, object_name) - - asset_group.matrix_basis = mat - - metadata["libpath"] = str(libpath) - metadata["representation"] = str(representation["_id"]) - metadata["parent"] = str(representation["parent"]) - - def exec_remove(self, container: Dict) -> bool: - """Remove an existing container from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the container was deleted. - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - libpath = asset_group.get(AVALON_PROPERTY).get('libpath') - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: - if obj.get(AVALON_PROPERTY).get('libpath') == libpath: - count += 1 - - if not asset_group: - return False - - self._remove(asset_group) - - bpy.data.objects.remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(libpath)) - bpy.data.libraries.remove(library) - - return True diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py deleted file mode 100644 index 1d23a70061..0000000000 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ /dev/null @@ -1,417 +0,0 @@ -"""Load a rig asset in Blender.""" - -from pathlib import Path -from pprint import pformat -from typing import Dict, List, Optional - -import bpy - -from openpype.pipeline import ( - legacy_create, - get_representation_path, - AVALON_CONTAINER_ID, -) -from openpype.pipeline.create import get_legacy_creator_by_name -from openpype.hosts.blender.api import ( - plugin, - get_selection, -) -from openpype.hosts.blender.api.pipeline import ( - AVALON_CONTAINERS, - AVALON_PROPERTY, -) - - -class BlendRigLoader(plugin.AssetLoader): - """Load rigs from a .blend file.""" - - families = ["rig"] - representations = ["blend"] - - label = "Link Rig" - icon = "code-fork" - color = "orange" - - def _remove(self, asset_group): - objects = list(asset_group.children) - - for obj in objects: - if obj.type == 'MESH': - for material_slot in list(obj.material_slots): - if material_slot.material: - bpy.data.materials.remove(material_slot.material) - bpy.data.meshes.remove(obj.data) - elif obj.type == 'ARMATURE': - objects.extend(obj.children) - bpy.data.armatures.remove(obj.data) - elif obj.type == 'CURVE': - bpy.data.curves.remove(obj.data) - elif obj.type == 'EMPTY': - objects.extend(obj.children) - bpy.data.objects.remove(obj) - - def _process(self, libpath, asset_group, group_name, action): - with bpy.data.libraries.load( - libpath, link=True, relative=False - ) as (data_from, data_to): - data_to.objects = data_from.objects - - parent = bpy.context.scene.collection - - empties = [obj for obj in data_to.objects if obj.type == 'EMPTY'] - - container = None - - for empty in empties: - if empty.get(AVALON_PROPERTY): - container = empty - break - - assert container, "No asset group found" - - # Children must be linked before parents, - # otherwise the hierarchy will break - objects = [] - nodes = list(container.children) - - allowed_types = ['ARMATURE', 'MESH'] - - for obj in nodes: - if obj.type in allowed_types: - obj.parent = asset_group - - for obj in nodes: - if obj.type in allowed_types: - objects.append(obj) - nodes.extend(list(obj.children)) - - objects.reverse() - - constraints = [] - - armatures = [obj for obj in objects if obj.type == 'ARMATURE'] - - for armature in armatures: - for bone in armature.pose.bones: - for constraint in bone.constraints: - if hasattr(constraint, 'target'): - constraints.append(constraint) - - for obj in objects: - parent.objects.link(obj) - - for obj in objects: - local_obj = plugin.prepare_data(obj, group_name) - - if local_obj.type == 'MESH': - plugin.prepare_data(local_obj.data, group_name) - - if obj != local_obj: - for constraint in constraints: - if constraint.target == obj: - constraint.target = local_obj - - for material_slot in local_obj.material_slots: - if material_slot.material: - plugin.prepare_data(material_slot.material, group_name) - elif local_obj.type == 'ARMATURE': - plugin.prepare_data(local_obj.data, group_name) - - if action is not None: - if local_obj.animation_data is None: - local_obj.animation_data_create() - local_obj.animation_data.action = action - elif (local_obj.animation_data and - local_obj.animation_data.action is not None): - plugin.prepare_data( - local_obj.animation_data.action, group_name) - - # Set link the drivers to the local object - if local_obj.data.animation_data: - for d in local_obj.data.animation_data.drivers: - for v in d.driver.variables: - for t in v.targets: - t.id = local_obj - - if not local_obj.get(AVALON_PROPERTY): - local_obj[AVALON_PROPERTY] = dict() - - avalon_info = local_obj[AVALON_PROPERTY] - avalon_info.update({"container_name": group_name}) - - objects.reverse() - - curves = [obj for obj in data_to.objects if obj.type == 'CURVE'] - - for curve in curves: - local_obj = plugin.prepare_data(curve, group_name) - plugin.prepare_data(local_obj.data, group_name) - - local_obj.use_fake_user = True - - for mod in local_obj.modifiers: - mod_target_name = mod.object.name - mod.object = bpy.data.objects.get( - f"{group_name}:{mod_target_name}") - - if not local_obj.get(AVALON_PROPERTY): - local_obj[AVALON_PROPERTY] = dict() - - avalon_info = local_obj[AVALON_PROPERTY] - avalon_info.update({"container_name": group_name}) - - local_obj.parent = asset_group - objects.append(local_obj) - - while bpy.data.orphans_purge(do_local_ids=False): - pass - - plugin.deselect_all() - - return objects - - def process_asset( - self, context: dict, name: str, namespace: Optional[str] = None, - options: Optional[Dict] = None - ) -> Optional[List]: - """ - Arguments: - name: Use pre-defined name - namespace: Use pre-defined namespace - context: Full parenthood of representation to load - options: Additional settings dictionary - """ - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - - asset_name = plugin.asset_name(asset, subset) - unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) - namespace = namespace or f"{asset}_{unique_number}" - - avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) - if not avalon_container: - avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) - bpy.context.scene.collection.children.link(avalon_container) - - asset_group = bpy.data.objects.new(group_name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - avalon_container.objects.link(asset_group) - - action = None - - plugin.deselect_all() - - create_animation = False - anim_file = None - - if options is not None: - parent = options.get('parent') - transform = options.get('transform') - action = options.get('action') - create_animation = options.get('create_animation') - anim_file = options.get('animation_file') - - if parent and transform: - location = transform.get('translation') - rotation = transform.get('rotation') - scale = transform.get('scale') - - asset_group.location = ( - location.get('x'), - location.get('y'), - location.get('z') - ) - asset_group.rotation_euler = ( - rotation.get('x'), - rotation.get('y'), - rotation.get('z') - ) - asset_group.scale = ( - scale.get('x'), - scale.get('y'), - scale.get('z') - ) - - bpy.context.view_layer.objects.active = parent - asset_group.select_set(True) - - bpy.ops.object.parent_set(keep_transform=True) - - plugin.deselect_all() - - objects = self._process(libpath, asset_group, group_name, action) - - if create_animation: - creator_plugin = get_legacy_creator_by_name("CreateAnimation") - if not creator_plugin: - raise ValueError("Creator plugin \"CreateAnimation\" was " - "not found.") - - asset_group.select_set(True) - - animation_asset = options.get('animation_asset') - - legacy_create( - creator_plugin, - name=namespace + "_animation", - # name=f"{unique_number}_{subset}_animation", - asset=animation_asset, - options={"useSelection": False, "asset_group": asset_group}, - data={"dependencies": str(context["representation"]["_id"])} - ) - - plugin.deselect_all() - - if anim_file: - bpy.ops.import_scene.fbx(filepath=anim_file, anim_offset=0.0) - - imported = get_selection() - - armature = [ - o for o in asset_group.children if o.type == 'ARMATURE'][0] - - imported_group = [ - o for o in imported if o.type == 'EMPTY'][0] - - for obj in imported: - if obj.type == 'ARMATURE': - if not armature.animation_data: - armature.animation_data_create() - armature.animation_data.action = obj.animation_data.action - - self._remove(imported_group) - bpy.data.objects.remove(imported_group) - - bpy.context.scene.collection.objects.link(asset_group) - - asset_group[AVALON_PROPERTY] = { - "schema": "openpype:container-2.0", - "id": AVALON_CONTAINER_ID, - "name": name, - "namespace": namespace or '', - "loader": str(self.__class__.__name__), - "representation": str(context["representation"]["_id"]), - "libpath": libpath, - "asset_name": asset_name, - "parent": str(context["representation"]["parent"]), - "family": context["representation"]["context"]["family"], - "objectName": group_name - } - - self[:] = objects - return objects - - def exec_update(self, container: Dict, representation: Dict): - """Update the loaded asset. - - This will remove all children of the asset group, load the new ones - and add them as children of the group. - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - libpath = Path(get_representation_path(representation)) - extension = libpath.suffix.lower() - - self.log.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert asset_group, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - metadata = asset_group.get(AVALON_PROPERTY) - group_libpath = metadata["libpath"] - - normalized_group_libpath = ( - str(Path(bpy.path.abspath(group_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - self.log.debug( - "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_group_libpath, - normalized_libpath, - ) - if normalized_group_libpath == normalized_libpath: - self.log.info("Library already loaded, not updating...") - return - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: - if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath: - count += 1 - - # Get the armature of the rig - objects = asset_group.children - armature = [obj for obj in objects if obj.type == 'ARMATURE'][0] - - action = None - if armature.animation_data and armature.animation_data.action: - action = armature.animation_data.action - - mat = asset_group.matrix_basis.copy() - - self._remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) - bpy.data.libraries.remove(library) - - self._process(str(libpath), asset_group, object_name, action) - - asset_group.matrix_basis = mat - - metadata["libpath"] = str(libpath) - metadata["representation"] = str(representation["_id"]) - metadata["parent"] = str(representation["parent"]) - - def exec_remove(self, container: Dict) -> bool: - """Remove an existing asset group from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the asset group was deleted. - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - libpath = asset_group.get(AVALON_PROPERTY).get('libpath') - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: - if obj.get(AVALON_PROPERTY).get('libpath') == libpath: - count += 1 - - if not asset_group: - return False - - self._remove(asset_group) - - bpy.data.objects.remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(libpath)) - bpy.data.libraries.remove(library) - - return True diff --git a/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py b/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py deleted file mode 100644 index b3f6f6460c..0000000000 --- a/openpype/hosts/blender/plugins/publish/extract_blender_scene_raw.py +++ /dev/null @@ -1,49 +0,0 @@ -import os - -import bpy - -from openpype.pipeline import publish - - -class ExtractBlenderSceneRaw(publish.Extractor): - """Extract as Blender Scene (raw). - - This will preserve all references, construction history, etc. - """ - - label = "Blender Scene (Raw)" - hosts = ["blender"] - families = ["blenderScene", "layout"] - scene_type = "blend" - - def process(self, instance): - # Define extract output file path - dir_path = self.staging_dir(instance) - filename = "{0}.{1}".format(instance.name, self.scene_type) - path = os.path.join(dir_path, filename) - - # We need to get all the data blocks for all the blender objects. - # The following set will contain all the data blocks from version - # 2.93 of Blender. - data_blocks = set() - - for attr in dir(bpy.data): - data_block = getattr(bpy.data, attr) - if isinstance(data_block, bpy.types.bpy_prop_collection): - data_blocks |= {*data_block} - - # Write the datablocks in a new blend file. - bpy.data.libraries.write(path, data_blocks) - - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - 'name': self.scene_type, - 'ext': self.scene_type, - 'files': filename, - "stagingDir": dir_path - } - instance.data["representations"].append(representation) - - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) diff --git a/openpype/plugins/publish/integrate_legacy.py b/openpype/plugins/publish/integrate_legacy.py index f773a50ad2..c238cca633 100644 --- a/openpype/plugins/publish/integrate_legacy.py +++ b/openpype/plugins/publish/integrate_legacy.py @@ -127,8 +127,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "mvLook", "mvUsdComposition", "mvUsdOverride", - "simpleUnrealTexture", - "blenderScene" + "simpleUnrealTexture" ] exclude_families = ["render.farm"] db_representation_context_keys = [ From 30032214f6efd3ff34d90975b34a8c16f533f293 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 16 Jun 2023 15:52:38 +0100 Subject: [PATCH 107/144] Added support for camera --- .../hosts/blender/plugins/load/load_blend.py | 2 +- .../blender/plugins/load/load_camera_blend.py | 256 ------------------ 2 files changed, 1 insertion(+), 257 deletions(-) delete mode 100644 openpype/hosts/blender/plugins/load/load_camera_blend.py diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index b64793566f..53933a1934 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -20,7 +20,7 @@ from openpype.hosts.blender.api.pipeline import ( class BlendLoader(plugin.AssetLoader): """Load assets from a .blend file.""" - families = ["model", "rig", "layout"] + families = ["model", "rig", "layout", "camera"] representations = ["blend"] label = "Load Blend" diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py deleted file mode 100644 index f00027f0b4..0000000000 --- a/openpype/hosts/blender/plugins/load/load_camera_blend.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Load a camera asset in Blender.""" - -import logging -from pathlib import Path -from pprint import pformat -from typing import Dict, List, Optional - -import bpy - -from openpype.pipeline import ( - get_representation_path, - AVALON_CONTAINER_ID, -) -from openpype.hosts.blender.api import plugin -from openpype.hosts.blender.api.pipeline import ( - AVALON_CONTAINERS, - AVALON_PROPERTY, -) - -logger = logging.getLogger("openpype").getChild( - "blender").getChild("load_camera") - - -class BlendCameraLoader(plugin.AssetLoader): - """Load a camera from a .blend file. - - Warning: - Loading the same asset more then once is not properly supported at the - moment. - """ - - families = ["camera"] - representations = ["blend"] - - label = "Link Camera (Blend)" - icon = "code-fork" - color = "orange" - - def _remove(self, asset_group): - objects = list(asset_group.children) - - for obj in objects: - if obj.type == 'CAMERA': - bpy.data.cameras.remove(obj.data) - - def _process(self, libpath, asset_group, group_name): - with bpy.data.libraries.load( - libpath, link=True, relative=False - ) as (data_from, data_to): - data_to.objects = data_from.objects - - parent = bpy.context.scene.collection - - empties = [obj for obj in data_to.objects if obj.type == 'EMPTY'] - - container = None - - for empty in empties: - if empty.get(AVALON_PROPERTY): - container = empty - break - - assert container, "No asset group found" - - # Children must be linked before parents, - # otherwise the hierarchy will break - objects = [] - nodes = list(container.children) - - for obj in nodes: - obj.parent = asset_group - - for obj in nodes: - objects.append(obj) - nodes.extend(list(obj.children)) - - objects.reverse() - - for obj in objects: - parent.objects.link(obj) - - for obj in objects: - local_obj = plugin.prepare_data(obj, group_name) - - if local_obj.type != 'EMPTY': - plugin.prepare_data(local_obj.data, group_name) - - if not local_obj.get(AVALON_PROPERTY): - local_obj[AVALON_PROPERTY] = dict() - - avalon_info = local_obj[AVALON_PROPERTY] - avalon_info.update({"container_name": group_name}) - - objects.reverse() - - bpy.data.orphans_purge(do_local_ids=False) - - plugin.deselect_all() - - return objects - - def process_asset( - self, context: dict, name: str, namespace: Optional[str] = None, - options: Optional[Dict] = None - ) -> Optional[List]: - """ - Arguments: - name: Use pre-defined name - namespace: Use pre-defined namespace - context: Full parenthood of representation to load - options: Additional settings dictionary - """ - libpath = self.fname - asset = context["asset"]["name"] - subset = context["subset"]["name"] - - asset_name = plugin.asset_name(asset, subset) - unique_number = plugin.get_unique_number(asset, subset) - group_name = plugin.asset_name(asset, subset, unique_number) - namespace = namespace or f"{asset}_{unique_number}" - - avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) - if not avalon_container: - avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) - bpy.context.scene.collection.children.link(avalon_container) - - asset_group = bpy.data.objects.new(group_name, object_data=None) - asset_group.empty_display_type = 'SINGLE_ARROW' - avalon_container.objects.link(asset_group) - - objects = self._process(libpath, asset_group, group_name) - - bpy.context.scene.collection.objects.link(asset_group) - - asset_group[AVALON_PROPERTY] = { - "schema": "openpype:container-2.0", - "id": AVALON_CONTAINER_ID, - "name": name, - "namespace": namespace or '', - "loader": str(self.__class__.__name__), - "representation": str(context["representation"]["_id"]), - "libpath": libpath, - "asset_name": asset_name, - "parent": str(context["representation"]["parent"]), - "family": context["representation"]["context"]["family"], - "objectName": group_name - } - - self[:] = objects - return objects - - def exec_update(self, container: Dict, representation: Dict): - """Update the loaded asset. - - This will remove all children of the asset group, load the new ones - and add them as children of the group. - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - libpath = Path(get_representation_path(representation)) - extension = libpath.suffix.lower() - - self.log.info( - "Container: %s\nRepresentation: %s", - pformat(container, indent=2), - pformat(representation, indent=2), - ) - - assert asset_group, ( - f"The asset is not loaded: {container['objectName']}" - ) - assert libpath, ( - "No existing library file found for {container['objectName']}" - ) - assert libpath.is_file(), ( - f"The file doesn't exist: {libpath}" - ) - assert extension in plugin.VALID_EXTENSIONS, ( - f"Unsupported file: {libpath}" - ) - - metadata = asset_group.get(AVALON_PROPERTY) - group_libpath = metadata["libpath"] - - normalized_group_libpath = ( - str(Path(bpy.path.abspath(group_libpath)).resolve()) - ) - normalized_libpath = ( - str(Path(bpy.path.abspath(str(libpath))).resolve()) - ) - self.log.debug( - "normalized_group_libpath:\n %s\nnormalized_libpath:\n %s", - normalized_group_libpath, - normalized_libpath, - ) - if normalized_group_libpath == normalized_libpath: - self.log.info("Library already loaded, not updating...") - return - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: - if obj.get(AVALON_PROPERTY).get('libpath') == group_libpath: - count += 1 - - mat = asset_group.matrix_basis.copy() - - self._remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(group_libpath)) - if library: - bpy.data.libraries.remove(library) - - self._process(str(libpath), asset_group, object_name) - - asset_group.matrix_basis = mat - - metadata["libpath"] = str(libpath) - metadata["representation"] = str(representation["_id"]) - metadata["parent"] = str(representation["parent"]) - - def exec_remove(self, container: Dict) -> bool: - """Remove an existing container from a Blender scene. - - Arguments: - container (openpype:container-1.0): Container to remove, - from `host.ls()`. - - Returns: - bool: Whether the container was deleted. - """ - object_name = container["objectName"] - asset_group = bpy.data.objects.get(object_name) - libpath = asset_group.get(AVALON_PROPERTY).get('libpath') - - # Check how many assets use the same library - count = 0 - for obj in bpy.data.collections.get(AVALON_CONTAINERS).objects: - if obj.get(AVALON_PROPERTY).get('libpath') == libpath: - count += 1 - - if not asset_group: - return False - - self._remove(asset_group) - - bpy.data.objects.remove(asset_group) - - # If it is the last object to use that library, remove it - if count == 1: - library = bpy.data.libraries.get(bpy.path.basename(libpath)) - bpy.data.libraries.remove(library) - - return True From 10d0fda1a27343b9546a8e4ac73d78bc59c2ec2b Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 20 Jun 2023 16:18:29 +0200 Subject: [PATCH 108/144] :recycle: switch bgeo to poincache and differentiate abc workflow alembic and bgeo are now both under pointcache families, their individual plugins differentiated --- openpype/hosts/houdini/plugins/create/create_bgeo.py | 7 +++++-- .../hosts/houdini/plugins/create/create_pointcache.py | 4 ++++ openpype/hosts/houdini/plugins/publish/extract_alembic.py | 2 +- openpype/hosts/houdini/plugins/publish/extract_bgeo.py | 8 +++++++- .../plugins/publish/validate_abc_primitive_to_detail.py | 2 +- .../houdini/plugins/publish/validate_alembic_face_sets.py | 2 +- .../plugins/publish/validate_alembic_input_node.py | 2 +- .../houdini/plugins/publish/validate_file_extension.py | 3 +-- .../plugins/publish/validate_primitive_hierarchy_paths.py | 2 +- 9 files changed, 22 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index 468215d76d..6edc2e76d2 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -9,7 +9,7 @@ class CreateBGEO(plugin.HoudiniCreator): """BGEO pointcache creator.""" identifier = "io.openpype.creators.houdini.bgeo" label = "BGEO PointCache" - family = "bgeo" + family = "pointcache" icon = "gears" def create(self, subset_name, instance_data, pre_create_data): @@ -18,7 +18,10 @@ class CreateBGEO(plugin.HoudiniCreator): instance_data.pop("active", None) instance_data.update({"node_type": "geometry"}) - instance_data["bgeo_type"] = pre_create_data.get("bgeo_type") + + if not instance_data.get("families"): + instance_data["families"] = [] + instance_data["families"] += ["bgeo"] instance = super(CreateBGEO, self).create( subset_name, diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index df74070fee..2e8ced19c6 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -19,6 +19,10 @@ class CreatePointCache(plugin.HoudiniCreator): instance_data.pop("active", None) instance_data.update({"node_type": "alembic"}) + if not instance_data.get("families"): + instance_data["families"] = [] + instance_data["families"] += ["abc"] + instance = super(CreatePointCache, self).create( subset_name, instance_data, diff --git a/openpype/hosts/houdini/plugins/publish/extract_alembic.py b/openpype/hosts/houdini/plugins/publish/extract_alembic.py index cb2d4ef424..bdd19b23d4 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_alembic.py +++ b/openpype/hosts/houdini/plugins/publish/extract_alembic.py @@ -13,7 +13,7 @@ class ExtractAlembic(publish.Extractor): order = pyblish.api.ExtractorOrder label = "Extract Alembic" hosts = ["houdini"] - families = ["pointcache", "camera"] + families = ["abc", "camera"] def process(self, instance): diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index 23c3b78813..4d876cb181 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -4,6 +4,7 @@ import pyblish.api from openpype.pipeline import publish from openpype.hosts.houdini.api.lib import render_rop +from openpype.hosts.houdini.api import lib import hou @@ -35,12 +36,17 @@ class ExtractBGEO(publish.Extractor): output = instance.data["frames"] + _, ext = lib.splitext( + output[0], allowed_multidot_extensions=[ + ".ass.gz", ".bgeo.sc", ".bgeo.gz", + ".bgeo.lzma", ".bgeo.bz2"]) + if "representations" not in instance.data: instance.data["representations"] = [] representation = { "name": "bgeo", - "ext": instance.data["bgeo_type"], + "ext": ext.lstrip("."), "files": output, "stagingDir": staging_dir, "frameStart": instance.data["frameStart"], diff --git a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py index 86e92a052f..94aa0edc96 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py +++ b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py @@ -17,7 +17,7 @@ class ValidateAbcPrimitiveToDetail(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder + 0.1 - families = ["pointcache"] + families = ["abc"] hosts = ["houdini"] label = "Validate Primitive to Detail (Abc)" diff --git a/openpype/hosts/houdini/plugins/publish/validate_alembic_face_sets.py b/openpype/hosts/houdini/plugins/publish/validate_alembic_face_sets.py index 44d58cfa36..40114bc40e 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_alembic_face_sets.py +++ b/openpype/hosts/houdini/plugins/publish/validate_alembic_face_sets.py @@ -18,7 +18,7 @@ class ValidateAlembicROPFaceSets(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder + 0.1 - families = ["pointcache"] + families = ["abc"] hosts = ["houdini"] label = "Validate Alembic ROP Face Sets" diff --git a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py index b0cf4cdc58..47c47e4ea2 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py @@ -14,7 +14,7 @@ class ValidateAlembicInputNode(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder + 0.1 - families = ["pointcache"] + families = ["abc"] hosts = ["houdini"] label = "Validate Input Node (Abc)" diff --git a/openpype/hosts/houdini/plugins/publish/validate_file_extension.py b/openpype/hosts/houdini/plugins/publish/validate_file_extension.py index 4584e78f4f..6594d10851 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_file_extension.py +++ b/openpype/hosts/houdini/plugins/publish/validate_file_extension.py @@ -19,12 +19,11 @@ class ValidateFileExtension(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["pointcache", "camera", "vdbcache"] + families = ["camera", "vdbcache"] hosts = ["houdini"] label = "Output File Extension" family_extensions = { - "pointcache": ".abc", "camera": ".abc", "vdbcache": ".vdb", } diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index d3a4c0cfbf..599e1797b2 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -15,7 +15,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): """ order = ValidateContentsOrder + 0.1 - families = ["pointcache"] + families = ["abc"] hosts = ["houdini"] label = "Validate Prims Hierarchy Path" From d6e5687a26f3b97ef166dfcaf77856e362866d1a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 21 Jun 2023 17:45:22 +0800 Subject: [PATCH 109/144] roy's comment --- .../publish/collect_arnold_scene_source.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py index f160a3a0c5..0edc0f9205 100644 --- a/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py @@ -21,15 +21,11 @@ class CollectArnoldSceneSource(pyblish.api.InstancePlugin): if members is None: self.log.warning("Skipped empty instance: \"%s\" " % objset) continue - if objset.endswith("content_SET"): - members = cmds.ls(members, long=True) - children = get_all_children(members) - instance.data["contentMembers"] = children - self.log.debug("content members: {}".format(children)) - elif objset.endswith("proxy_SET"): - set_members = get_all_children(cmds.ls(members, long=True)) - instance.data["proxy"] = set_members - self.log.debug("proxy members: {}".format(set_members)) + + instance.data["contentMembers"] = self.get_ass_data( + objset, members, "content_SET") + instance.data["proxy"] = self.get_ass_data( + objset, members, "proxy_SET") # Use camera in object set if present else default to render globals # camera. @@ -48,3 +44,13 @@ class CollectArnoldSceneSource(pyblish.api.InstancePlugin): self.log.debug("No renderable cameras found.") self.log.debug("data: {}".format(instance.data)) + + def get_ass_data(self, objset, members, suffix): + if objset.endswith(suffix): + members = cmds.ls(members, long=True) + if not members: + return + children = get_all_children(members) + members = list(set(members) - set(children)) + + return children + members From 4b371b6c59ea94b935bf245370bcc32608ad2a50 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 21 Jun 2023 18:45:28 +0800 Subject: [PATCH 110/144] revert the unnecessary code --- .../publish/collect_arnold_scene_source.py | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py index 0edc0f9205..f160a3a0c5 100644 --- a/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py @@ -21,11 +21,15 @@ class CollectArnoldSceneSource(pyblish.api.InstancePlugin): if members is None: self.log.warning("Skipped empty instance: \"%s\" " % objset) continue - - instance.data["contentMembers"] = self.get_ass_data( - objset, members, "content_SET") - instance.data["proxy"] = self.get_ass_data( - objset, members, "proxy_SET") + if objset.endswith("content_SET"): + members = cmds.ls(members, long=True) + children = get_all_children(members) + instance.data["contentMembers"] = children + self.log.debug("content members: {}".format(children)) + elif objset.endswith("proxy_SET"): + set_members = get_all_children(cmds.ls(members, long=True)) + instance.data["proxy"] = set_members + self.log.debug("proxy members: {}".format(set_members)) # Use camera in object set if present else default to render globals # camera. @@ -44,13 +48,3 @@ class CollectArnoldSceneSource(pyblish.api.InstancePlugin): self.log.debug("No renderable cameras found.") self.log.debug("data: {}".format(instance.data)) - - def get_ass_data(self, objset, members, suffix): - if objset.endswith(suffix): - members = cmds.ls(members, long=True) - if not members: - return - children = get_all_children(members) - members = list(set(members) - set(children)) - - return children + members From dae6257f881ca5044cdc6d708b1bb2eb6dede547 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jun 2023 16:48:11 +0200 Subject: [PATCH 111/144] :bug: handle legacy pointcache instance conversion --- openpype/hosts/houdini/plugins/create/convert_legacy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/houdini/plugins/create/convert_legacy.py b/openpype/hosts/houdini/plugins/create/convert_legacy.py index e549c9dc26..86103e3369 100644 --- a/openpype/hosts/houdini/plugins/create/convert_legacy.py +++ b/openpype/hosts/houdini/plugins/create/convert_legacy.py @@ -69,6 +69,8 @@ class HoudiniLegacyConvertor(SubsetConvertorPlugin): "creator_identifier": self.family_to_id[family], "instance_node": subset.path() } + if family == "pointcache": + data["families"] = ["abc"] self.log.info("Converting {} to {}".format( subset.path(), self.family_to_id[family])) imprint(subset, data) From 2725c87065767a47e9836f453d667643278e285e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jun 2023 16:49:01 +0200 Subject: [PATCH 112/144] :heavy_minus_sign: remove bgeo family from integration --- openpype/plugins/publish/integrate.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 54b5e56868..f392cf67f7 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -137,8 +137,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "mvUsdOverride", "simpleUnrealTexture", "online", - "uasset", - "bgeo" + "uasset" ] default_template_name = "publish" From 35c4d89742a853c0cc4e0c5d6c8d10a448fbd928 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jun 2023 17:07:35 +0200 Subject: [PATCH 113/144] :speech_balloon: enhance validation error message --- .../houdini/plugins/publish/validate_bgeo_file_sop_path.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py index f4016a1b09..1736e58cdc 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py @@ -18,8 +18,9 @@ class ValidateNoSOPPath(pyblish.api.InstancePlugin): node = hou.node(instance.data.get("instance_node")) sop_path = node.evalParm("soppath") if not sop_path: - raise PublishValidationError("Empty SOP Path found in " - "the bgeo isntance Geometry") + raise PublishValidationError( + ("Empty SOP Path ('soppath' parameter) found in " + f"the BGEO instance Geometry - {node.path()}")) if not isinstance(hou.node(sop_path), hou.SopNode): raise PublishValidationError( "SOP path is not pointing to valid SOP node.") From ec5355bfdeeda55bc7a90ba206c463f5d47c56bb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 21 Jun 2023 17:09:11 +0200 Subject: [PATCH 114/144] :rotating_light: fix hound issue --- .../houdini/plugins/publish/validate_bgeo_file_sop_path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py index 1736e58cdc..22746aabb0 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py +++ b/openpype/hosts/houdini/plugins/publish/validate_bgeo_file_sop_path.py @@ -19,7 +19,7 @@ class ValidateNoSOPPath(pyblish.api.InstancePlugin): sop_path = node.evalParm("soppath") if not sop_path: raise PublishValidationError( - ("Empty SOP Path ('soppath' parameter) found in " + ("Empty SOP Path ('soppath' parameter) found in " f"the BGEO instance Geometry - {node.path()}")) if not isinstance(hou.node(sop_path), hou.SopNode): raise PublishValidationError( From 6619be9bc72159aa0cfe60d63403bfa28b903a5a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 27 Jun 2023 11:49:29 +0200 Subject: [PATCH 115/144] Fix append job --- .../plugins/publish/create_maya_royalrender_job.py | 5 ++--- .../plugins/publish/create_nuke_royalrender_job.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 5ad3c90bdc..695f33cc35 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- """Submitting render job to RoyalRender.""" -# -*- coding: utf-8 -*- -"""Submitting render job to RoyalRender.""" import os from maya.OpenMaya import MGlobal @@ -12,6 +10,7 @@ from openpype.pipeline.farm.tools import iter_expected_files class CreateMayaRoyalRenderJob(lib.BaseCreateRoyalRenderJob): label = "Create Maya Render job in RR" + hosts = ["maya"] families = ["renderlayer"] def update_job_with_host_specific(self, instance, job): @@ -39,4 +38,4 @@ class CreateMayaRoyalRenderJob(lib.BaseCreateRoyalRenderJob): layer_name) job = self.update_job_with_host_specific(instance, job) - instance.data["rrJobs"] += job + instance.data["rrJobs"].append(job) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py index 62636a6744..7b4a66a920 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py @@ -26,7 +26,7 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): for job in jobs: job = self.update_job_with_host_specific(instance, job) - instance.data["rrJobs"] += jobs + instance.data["rrJobs"].append(job) def update_job_with_host_specific(self, instance, job): nuke_version = re.search( From d6b0b9c74c70d33f57374229a1c3352237da53a7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 27 Jun 2023 11:50:25 +0200 Subject: [PATCH 116/144] Remove outputDir --- openpype/modules/royalrender/lib.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/modules/royalrender/lib.py b/openpype/modules/royalrender/lib.py index ce78b1a738..1433bac6a2 100644 --- a/openpype/modules/royalrender/lib.py +++ b/openpype/modules/royalrender/lib.py @@ -131,9 +131,6 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, if not self._instance.data.get("rrJobs"): self._instance.data["rrJobs"] = [] - self._instance.data["outputDir"] = os.path.dirname( - self._instance.data["path"]).replace("\\", "/") - def get_job(self, instance, script_path, render_path, node_name): """Get RR job based on current instance. From d0e1d5c36fbaa96d12bb0b372aa207e7ec000be4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 27 Jun 2023 18:29:33 +0200 Subject: [PATCH 117/144] Fix cleanup of camera info Without it .mel script in RR would fail with syntax error. --- .../royalrender/plugins/publish/create_maya_royalrender_job.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 695f33cc35..69e54f54b7 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -16,7 +16,8 @@ class CreateMayaRoyalRenderJob(lib.BaseCreateRoyalRenderJob): def update_job_with_host_specific(self, instance, job): job.Software = "Maya" job.Version = "{0:.2f}".format(MGlobal.apiVersion() / 10000) - job.Camera = instance.data["cameras"][0], + if instance.data.get("cameras"): + job.Camera = instance.data["cameras"][0].replace("'", '"') workspace = instance.context.data["workspaceDir"] job.SceneDatabaseDir = workspace From ec54515b8caf08773ef721b6a9e7a6edc3e4ecb5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 28 Jun 2023 12:53:28 +0200 Subject: [PATCH 118/144] Fix - correct resolution of published workile path --- openpype/modules/royalrender/lib.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/modules/royalrender/lib.py b/openpype/modules/royalrender/lib.py index 1433bac6a2..cbddb22b4e 100644 --- a/openpype/modules/royalrender/lib.py +++ b/openpype/modules/royalrender/lib.py @@ -113,12 +113,15 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, self.scene_path = context.data["currentFile"] if self.use_published: - file_path = get_published_workfile_instance(context) + published_workfile = get_published_workfile_instance(context) # fallback if nothing was set - if not file_path: + if published_workfile is None: self.log.warning("Falling back to workfile") file_path = context.data["currentFile"] + else: + workfile_repre = published_workfile.data["representations"][0] + file_path = workfile_repre["published_path"] self.scene_path = file_path self.log.info( From 0df104be54146921944fa32a02f98fac4db455d1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 28 Jun 2023 15:10:57 +0200 Subject: [PATCH 119/144] Fix - proper padding of file name RR requires replacing frame value with # properly padded. --- openpype/modules/royalrender/lib.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/openpype/modules/royalrender/lib.py b/openpype/modules/royalrender/lib.py index cbddb22b4e..006ebc5bfd 100644 --- a/openpype/modules/royalrender/lib.py +++ b/openpype/modules/royalrender/lib.py @@ -155,7 +155,7 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, batch_name += datetime.now().strftime("%d%m%Y%H%M%S") render_dir = os.path.normpath(os.path.dirname(render_path)) - output_filename_0 = self.preview_fname(render_path) + output_filename_0 = self.pad_file_name(render_path, str(start_frame)) file_name, file_ext = os.path.splitext( os.path.basename(output_filename_0)) @@ -278,14 +278,16 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, ) return expected_files - def preview_fname(self, path): + def pad_file_name(self, path, first_frame): """Return output file path with #### for padding. RR requires the path to be formatted with # in place of numbers. For example `/path/to/render.####.png` Args: - path (str): path to rendered images + path (str): path to rendered image + first_frame (str): from representation to cleany replace with # + padding Returns: str @@ -298,4 +300,10 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, return int(search_results[1]) if "#" in path: self.log.debug("_ path: `{}`".format(path)) + return path + + if first_frame: + padding = len(first_frame) + path = path.replace(first_frame, "#" * padding) + return path From 4a672c5c12ee8d8b3659f08d1a581f31f272ca20 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 28 Jun 2023 15:15:36 +0200 Subject: [PATCH 120/144] Hound --- openpype/modules/deadline/abstract_submit_deadline.py | 3 +-- .../royalrender/plugins/publish/create_nuke_royalrender_job.py | 2 -- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index b63a43598d..6ff9afbc42 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -23,8 +23,7 @@ from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin ) from openpype.pipeline.publish.lib import ( - replace_published_scene, - get_published_workfile_instance + replace_published_scene ) JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py index 7b4a66a920..763792bdc2 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py @@ -41,8 +41,6 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): """Nuke creates multiple RR jobs - for baking etc.""" # get output path render_path = instance.data['path'] - self.log.info("render::{}".format(render_path)) - self.log.info("expected::{}".format(instance.data.get("expectedFiles"))) script_path = self.scene_path node = self._instance.data["transientData"]["node"] From b9e362d1e8a0edb4c7c2bc44d5af8be8a94a6b37 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 3 Jul 2023 17:15:59 +0200 Subject: [PATCH 121/144] Fix - Nuke needs to have multiple jobs First job is pure rendering, then there could be baking etc. scripts. --- .../royalrender/plugins/publish/create_nuke_royalrender_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py index 763792bdc2..7f503c80a9 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py @@ -26,7 +26,7 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): for job in jobs: job = self.update_job_with_host_specific(instance, job) - instance.data["rrJobs"].append(job) + instance.data["rrJobs"].append(job) def update_job_with_host_specific(self, instance, job): nuke_version = re.search( From 614d600564643fdd4ebcc543a2571425611ea385 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 4 Jul 2023 17:21:09 +0200 Subject: [PATCH 122/144] Refactor - removed unnecessary variable Fixed expectedFiles, they need to extended (at least in Nuke, first renders, then baking etc.) --- openpype/modules/royalrender/lib.py | 21 +++++++----------- .../publish/create_maya_royalrender_job.py | 6 ++--- .../publish/create_nuke_royalrender_job.py | 22 +++++++++---------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/openpype/modules/royalrender/lib.py b/openpype/modules/royalrender/lib.py index 006ebc5bfd..4708d25eed 100644 --- a/openpype/modules/royalrender/lib.py +++ b/openpype/modules/royalrender/lib.py @@ -79,7 +79,6 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, ] def __init__(self, *args, **kwargs): - self._instance = None self._rr_root = None self.scene_path = None self.job = None @@ -99,7 +98,6 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, "suspend_publish"] context = instance.context - self._instance = instance self._rr_root = self._resolve_rr_path(context, instance.data.get( "rrPathName")) # noqa @@ -128,11 +126,11 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, "Using published scene for render {}".format(self.scene_path) ) - if not self._instance.data.get("expectedFiles"): - self._instance.data["expectedFiles"] = [] + if not instance.data.get("expectedFiles"): + instance.data["expectedFiles"] = [] - if not self._instance.data.get("rrJobs"): - self._instance.data["rrJobs"] = [] + if not instance.data.get("rrJobs"): + instance.data["rrJobs"] = [] def get_job(self, instance, script_path, render_path, node_name): """Get RR job based on current instance. @@ -150,7 +148,7 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, end_frame = int(instance.data["frameEndHandle"]) batch_name = os.path.basename(script_path) - jobname = "%s - %s" % (batch_name, self._instance.name) + jobname = "%s - %s" % (batch_name, instance.name) if is_in_tests(): batch_name += datetime.now().strftime("%d%m%Y%H%M%S") @@ -252,9 +250,6 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, list: List of expected files. """ - if instance.data.get("expectedFiles"): - return instance.data["expectedFiles"] - dir_name = os.path.dirname(path) file = os.path.basename(path) @@ -269,7 +264,7 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, expected_files.append(path) return expected_files - if self._instance.data.get("slate"): + if instance.data.get("slate"): start_frame -= 1 expected_files.extend( @@ -293,13 +288,13 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin, str """ - self.log.debug("_ path: `{}`".format(path)) + self.log.debug("pad_file_name path: `{}`".format(path)) if "%" in path: search_results = re.search(r"(%0)(\d)(d.)", path).groups() self.log.debug("_ search_results: `{}`".format(search_results)) return int(search_results[1]) if "#" in path: - self.log.debug("_ path: `{}`".format(path)) + self.log.debug("already padded: `{}`".format(path)) return path if first_frame: diff --git a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py index 69e54f54b7..22d910b7cd 100644 --- a/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_maya_royalrender_job.py @@ -27,12 +27,12 @@ class CreateMayaRoyalRenderJob(lib.BaseCreateRoyalRenderJob): """Plugin entry point.""" super(CreateMayaRoyalRenderJob, self).process(instance) - expected_files = self._instance.data["expectedFiles"] + expected_files = instance.data["expectedFiles"] first_file_path = next(iter_expected_files(expected_files)) output_dir = os.path.dirname(first_file_path) - self._instance.data["outputDir"] = output_dir + instance.data["outputDir"] = output_dir - layer = self._instance.data["setMembers"] # type: str + layer = instance.data["setMembers"] # type: str layer_name = layer.removeprefix("rs_") job = self.get_job(instance, self.scene_path, first_file_path, diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py index 7f503c80a9..6db0fe959e 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py @@ -15,22 +15,22 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): super(CreateNukeRoyalRenderJob, self).process(instance) # redefinition of families - if "render" in self._instance.data["family"]: - self._instance.data["family"] = "write" - self._instance.data["families"].insert(0, "render2d") - elif "prerender" in self._instance.data["family"]: - self._instance.data["family"] = "write" - self._instance.data["families"].insert(0, "prerender") + if "render" in instance.data["family"]: + instance.data["family"] = "write" + instance.data["families"].insert(0, "render2d") + elif "prerender" in instance.data["family"]: + instance.data["family"] = "write" + instance.data["families"].insert(0, "prerender") - jobs = self.create_jobs(self._instance) + jobs = self.create_jobs(instance) for job in jobs: job = self.update_job_with_host_specific(instance, job) - instance.data["rrJobs"].append(job) + instance.data["rrJobs"].append(job) def update_job_with_host_specific(self, instance, job): nuke_version = re.search( - r"\d+\.\d+", self._instance.context.data.get("hostVersion")) + r"\d+\.\d+", instance.context.data.get("hostVersion")) job.Software = "Nuke" job.Version = nuke_version.group() @@ -42,7 +42,7 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): # get output path render_path = instance.data['path'] script_path = self.scene_path - node = self._instance.data["transientData"]["node"] + node = instance.data["transientData"]["node"] # main job jobs = [ @@ -54,7 +54,7 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): ) ] - for baking_script in self._instance.data.get("bakingNukeScripts", []): + for baking_script in instance.data.get("bakingNukeScripts", []): render_path = baking_script["bakeRenderPath"] script_path = baking_script["bakeScriptPath"] exe_node_name = baking_script["bakeWriteNodeName"] From 8754345b44ebb7747a96bcf6386b702596cf9429 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 4 Jul 2023 17:35:42 +0200 Subject: [PATCH 123/144] Fix - Nuke needs to have multiple jobs got accidentally reverted It needs to loop through jobs to add them all. --- .../royalrender/plugins/publish/create_nuke_royalrender_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py index 6db0fe959e..71daa6edf8 100644 --- a/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_nuke_royalrender_job.py @@ -26,7 +26,7 @@ class CreateNukeRoyalRenderJob(lib.BaseCreateRoyalRenderJob): for job in jobs: job = self.update_job_with_host_specific(instance, job) - instance.data["rrJobs"].append(job) + instance.data["rrJobs"].append(job) def update_job_with_host_specific(self, instance, job): nuke_version = re.search( From 708819f8b13980ebacea6468f15e6b4e2cf9051c Mon Sep 17 00:00:00 2001 From: Pype Club Date: Wed, 5 Jul 2023 16:50:58 +0200 Subject: [PATCH 124/144] Refactor - renamed replace_published_scene Former name pointed to replacing of whole file. This function is only about using path to published workfile instead of work workfile. --- openpype/modules/deadline/abstract_submit_deadline.py | 5 +++-- openpype/pipeline/publish/lib.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 6ff9afbc42..d9d250fe9e 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -23,7 +23,7 @@ from openpype.pipeline.publish import ( OpenPypePyblishPluginMixin ) from openpype.pipeline.publish.lib import ( - replace_published_scene + replace_with_published_scene_path ) JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) @@ -528,7 +528,8 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin, published. """ - return replace_published_scene(self._instance, replace_in_path=True) + return replace_with_published_scene_path( + self._instance, replace_in_path=replace_in_path) def assemble_payload( self, job_info=None, plugin_info=None, aux_files=None): diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index a03303bc39..5d2a88fa3b 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -882,10 +882,11 @@ def get_published_workfile_instance(context): return i -def replace_published_scene(instance, replace_in_path=True): - """Switch work scene for published scene. +def replace_with_published_scene_path(instance, replace_in_path=True): + """Switch work scene path for published scene. If rendering/exporting from published scenes is enabled, this will replace paths from working scene to published scene. + This only works if publish contains workfile instance! Args: instance (pyblish.api.Instance): Pyblish instance. replace_in_path (bool): if True, it will try to find From 26cd4f0029473aa361bfe0b46139a1e0b0f30109 Mon Sep 17 00:00:00 2001 From: Pype Club Date: Wed, 5 Jul 2023 16:51:51 +0200 Subject: [PATCH 125/144] Hound --- openpype/pipeline/farm/pyblish_functions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 0ace02edb9..afd2ef3eeb 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -822,8 +822,6 @@ def attach_instances_to_subset(attach_to, instances): list: List of attached instances. """ - # - new_instances = [] for attach_instance in attach_to: for i in instances: From 9975b87a9b3501a6c573ae75d6eba3d1dc0064c5 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 10 Jul 2023 19:01:17 +0200 Subject: [PATCH 126/144] :recycle: move type family to collector --- .../houdini/plugins/create/create_bgeo.py | 15 +++++++------ .../plugins/create/create_pointcache.py | 4 ---- .../publish/collect_pointcache_type.py | 21 +++++++++++++++++++ 3 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index 6edc2e76d2..118d2f4391 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache bgeo files.""" from openpype.hosts.houdini.api import plugin -from openpype.pipeline import CreatedInstance +from openpype.pipeline import CreatedInstance, CreatorError from openpype.lib import EnumDef @@ -19,10 +19,6 @@ class CreateBGEO(plugin.HoudiniCreator): instance_data.update({"node_type": "geometry"}) - if not instance_data.get("families"): - instance_data["families"] = [] - instance_data["families"] += ["bgeo"] - instance = super(CreateBGEO, self).create( subset_name, instance_data, @@ -40,6 +36,7 @@ class CreateBGEO(plugin.HoudiniCreator): "sopoutput": file_path } + instance_node.parm("trange").set(1) if self.selected_nodes: # if selection is on SOP level, use it if isinstance(self.selected_nodes[0], hou.SopNode): @@ -50,11 +47,17 @@ class CreateBGEO(plugin.HoudiniCreator): child for child in self.selected_nodes[0].children() if child.type().name() == "output" ] + if not outputs: + instance_node.setParms(parms) + raise CreatorError(( + "Missing output node in SOP level for the selection. " + "Please select correct SOP path in created instance." + )) outputs.sort(key=lambda output: output.evalParm("outputidx")) parms["soppath"] = outputs[0].path() instance_node.setParms(parms) - instance_node.parm("trange").set(1) + def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() diff --git a/openpype/hosts/houdini/plugins/create/create_pointcache.py b/openpype/hosts/houdini/plugins/create/create_pointcache.py index 3e3df45e5b..554d5f2016 100644 --- a/openpype/hosts/houdini/plugins/create/create_pointcache.py +++ b/openpype/hosts/houdini/plugins/create/create_pointcache.py @@ -16,10 +16,6 @@ class CreatePointCache(plugin.HoudiniCreator): instance_data.pop("active", None) instance_data.update({"node_type": "alembic"}) - if not instance_data.get("families"): - instance_data["families"] = [] - instance_data["families"] += ["abc"] - instance = super(CreatePointCache, self).create( subset_name, instance_data, diff --git a/openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py b/openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py new file mode 100644 index 0000000000..6c527377e0 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_pointcache_type.py @@ -0,0 +1,21 @@ +"""Collector for pointcache types. + +This will add additional family to pointcache instance based on +the creator_identifier parameter. +""" +import pyblish.api + + +class CollectPointcacheType(pyblish.api.InstancePlugin): + """Collect data type for pointcache instance.""" + + order = pyblish.api.CollectorOrder + hosts = ["houdini"] + families = ["pointcache"] + label = "Collect type of pointcache" + + def process(self, instance): + if instance.data["creator_identifier"] == "io.openpype.creators.houdini.bgeo": # noqa: E501 + instance.data["families"] += ["bgeo"] + elif instance.data["creator_identifier"] == "io.openpype.creators.houdini.alembic": # noqa: E501 + instance.data["families"] += ["abc"] From 7f508d7837dfbe0eb4ea4787ddf971a7913149b3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 10 Jul 2023 19:01:46 +0200 Subject: [PATCH 127/144] :recycle: make code simpler --- openpype/hosts/houdini/plugins/publish/extract_bgeo.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index 4d876cb181..c9625ec880 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -22,11 +22,9 @@ class ExtractBGEO(publish.Extractor): # Get the filename from the filename parameter output = ropnode.evalParm("sopoutput") - staging_dir = os.path.dirname(output) + staging_dir, file_name = os.path.split(output) instance.data["stagingDir"] = staging_dir - file_name = os.path.basename(output) - # We run the render self.log.info("Writing bgeo files '{}' to '{}'.".format( file_name, staging_dir)) From ca7f75ec6f2123208b80bdefe10a332da6a63a46 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 10 Jul 2023 19:02:27 +0200 Subject: [PATCH 128/144] :memo: fix docs --- website/docs/artist_hosts_houdini.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md index 8a9d2e1f93..940d5ac351 100644 --- a/website/docs/artist_hosts_houdini.md +++ b/website/docs/artist_hosts_houdini.md @@ -139,7 +139,7 @@ There is a simple support for publishing and loading **BGEO** files in all suppo ### Creating BGEO instances -Just select your SOP node to be exported as BGEO. If your selection is object level, OpenPype will try to find there is `output` node inside, the one with the lowest index will be used: +Select your SOP node to be exported as BGEO. If your selection is in the object level, OpenPype will try to find if there is an `output` node inside, the one with the lowest index will be used: ![BGEO output node](assets/houdini_bgeo_output_node.png) @@ -147,8 +147,7 @@ Then you can open Publisher, in Create you select **BGEO PointCache**: ![BGEO Publisher](assets/houdini_bgeo-publisher.png) -You can select compression type and if the current selection should be connected to ROPs SOP path parameter. Publishing -will produce sequence of files based on your timeline settings. +You can select compression type and if the current selection should be connected to ROPs SOP path parameter. Publishing will produce sequence of files based on your timeline settings. ### Loading BGEO From a3da1a4c02aed5265adc78a756bc66ed327e7386 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 10 Jul 2023 19:07:40 +0200 Subject: [PATCH 129/144] :dog: fix hound --- openpype/hosts/houdini/plugins/create/create_bgeo.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index 118d2f4391..a1101fd045 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -58,7 +58,6 @@ class CreateBGEO(plugin.HoudiniCreator): instance_node.setParms(parms) - def get_pre_create_attr_defs(self): attrs = super().get_pre_create_attr_defs() bgeo_enum = [ From 726f6f3fdb3dd704f0144d061f6b0ca7646249fe Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 11 Jul 2023 15:47:43 +0200 Subject: [PATCH 130/144] Nuke: auto apply all settings after template build --- openpype/hosts/nuke/api/workfile_template_builder.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index 2384e8eca1..a19cb9dfea 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -25,7 +25,8 @@ from .lib import ( select_nodes, duplicate_node, node_tempfile, - get_main_window + get_main_window, + WorkfileSettings, ) PLACEHOLDER_SET = "PLACEHOLDERS_SET" @@ -955,6 +956,9 @@ def build_workfile_template(*args, **kwargs): builder = NukeTemplateBuilder(registered_host()) builder.build_template(*args, **kwargs) + # set all settings to shot context default + WorkfileSettings().set_context_settings() + def update_workfile_template(*args): builder = NukeTemplateBuilder(registered_host()) From 892758b4d02eaf80ea9cc0918a7920d31e0f4961 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jul 2023 17:05:24 +0200 Subject: [PATCH 131/144] Added missed colorspace injection for image sequences Develop contains this information, was missed during shuffling things around. --- .../plugins/publish/submit_publish_job.py | 7 ++++--- .../publish/create_publish_royalrender_job.py | 7 +++++-- openpype/pipeline/farm/pyblish_functions.py | 15 ++++++++++++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index ea9c296732..1bc7ea0161 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -15,7 +15,7 @@ from openpype.client import ( from openpype.pipeline import ( legacy_io, ) -from openpype.pipeline.publish import OpenPypePyblishPluginMixin +from openpype.pipeline import publish from openpype.lib import EnumDef from openpype.tests.lib import is_in_tests from openpype.lib import is_running_from_build @@ -57,7 +57,7 @@ def get_resource_files(resources, frame_range=None): class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, - OpenPypePyblishPluginMixin): + publish.ColormanagedPyblishPluginMixin): """Process Job submitted on farm. These jobs are dependent on a deadline or muster job @@ -396,7 +396,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, anatomy, self.aov_filter, self.skip_integration_repre_list, - do_not_add_review + do_not_add_review, + self ) if "representations" not in instance_skeleton_data.keys(): diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 6eb8f2649e..5f75bbc3f1 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -23,9 +23,11 @@ from openpype.pipeline.farm.pyblish_functions import ( prepare_representations, create_metadata_path ) +from openpype.pipeline import publish -class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin): +class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin, + publish.ColormanagedPyblishPluginMixin): """Creates job which publishes rendered files to publish area. Job waits until all rendering jobs are finished, triggers `publish` command @@ -107,7 +109,8 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin): self.anatomy, self.aov_filter, self.skip_integration_repre_list, - do_not_add_review + do_not_add_review, + self ) if "representations" not in instance_skeleton_data.keys(): diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 0ace02edb9..3834524faa 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -243,7 +243,8 @@ def create_skeleton_instance( "jobBatchName": data.get("jobBatchName", ""), "useSequenceForReview": data.get("useSequenceForReview", True), # map inputVersions `ObjectId` -> `str` so json supports it - "inputVersions": list(map(str, data.get("inputVersions", []))) + "inputVersions": list(map(str, data.get("inputVersions", []))), + "colorspace": data.get("colorspace") } # skip locking version if we are creating v01 @@ -291,7 +292,8 @@ def _add_review_families(families): def prepare_representations(instance, exp_files, anatomy, aov_filter, skip_integration_repre_list, - do_not_add_review): + do_not_add_review, + color_managed_plugin): """Create representations for file sequences. This will return representations of expected files if they are not @@ -306,7 +308,7 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, aov_filter (dict): add review for specific aov names skip_integration_repre_list (list): exclude specific extensions, do_not_add_review (bool): explicitly skip review - + color_managed_plugin (publish.ColormanagedPyblishPluginMixin) Returns: list of representations @@ -433,6 +435,13 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, if not already_there: representations.append(rep) + for rep in representations: + # inject colorspace data + color_managed_plugin.set_representation_colorspace( + rep, instance.context, + colorspace=instance.data["colorspace"] + ) + return representations From 48f06ba414f33f028d7cfbab6eef4fdf261137df Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jul 2023 18:01:50 +0200 Subject: [PATCH 132/144] Fix - removed wrong argument Probably incorrectly merged from develop where it is already fixed. --- openpype/modules/deadline/abstract_submit_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index d9d250fe9e..3fa427204b 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -433,7 +433,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin, file_path = None if self.use_published: if not self.import_reference: - file_path = self.from_published_scene(context) + file_path = self.from_published_scene() else: self.log.info("use the scene with imported reference for rendering") # noqa file_path = context.data["currentFile"] From abc6a1eb27d0e574c9f6d934d25f67ad7be681db Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 Jul 2023 18:06:17 +0200 Subject: [PATCH 133/144] Fix - context must be passe to set_representation_colorspace --- .../plugins/publish/submit_publish_job.py | 2 ++ .../publish/create_publish_royalrender_job.py | 1 + openpype/pipeline/farm/pyblish_functions.py | 35 ++++++++++--------- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 9e3ad20b44..457ebfd0fe 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -57,6 +57,7 @@ def get_resource_files(resources, frame_range=None): class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, + publish.OpenPypePyblishPluginMixin, publish.ColormanagedPyblishPluginMixin): """Process Job submitted on farm. @@ -396,6 +397,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, self.aov_filter, self.skip_integration_repre_list, do_not_add_review, + instance.context, self ) diff --git a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py index 5f75bbc3f1..3eb49a39ee 100644 --- a/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py +++ b/openpype/modules/royalrender/plugins/publish/create_publish_royalrender_job.py @@ -110,6 +110,7 @@ class CreatePublishRoyalRenderJob(pyblish.api.InstancePlugin, self.aov_filter, self.skip_integration_repre_list, do_not_add_review, + instance.context, self ) diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 76ee5832d6..2df8269d79 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -290,9 +290,10 @@ def _add_review_families(families): return families -def prepare_representations(instance, exp_files, anatomy, aov_filter, +def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter, skip_integration_repre_list, do_not_add_review, + context, color_managed_plugin): """Create representations for file sequences. @@ -301,7 +302,7 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, most cases, but if not - we create representation from each of them. Arguments: - instance (dict): instance data for which we are + skeleton_data (dict): instance data for which we are setting representations exp_files (list): list of expected files anatomy (Anatomy): @@ -328,9 +329,9 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, # expected files contains more explicitly and from what # should be review made. # - "review" tag is never added when is set to 'False' - if instance["useSequenceForReview"]: + if skeleton_data["useSequenceForReview"]: # toggle preview on if multipart is on - if instance.get("multipartExr", False): + if skeleton_data.get("multipartExr", False): log.debug( "Adding preview tag because its multipartExr" ) @@ -355,8 +356,8 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, " This may cause issues on farm." ).format(staging)) - frame_start = int(instance.get("frameStartHandle")) - if instance.get("slate"): + frame_start = int(skeleton_data.get("frameStartHandle")) + if skeleton_data.get("slate"): frame_start -= 1 # explicitly disable review by user @@ -366,10 +367,10 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, "ext": ext, "files": [os.path.basename(f) for f in list(collection)], "frameStart": frame_start, - "frameEnd": int(instance.get("frameEndHandle")), + "frameEnd": int(skeleton_data.get("frameEndHandle")), # If expectedFile are absolute, we need only filenames "stagingDir": staging, - "fps": instance.get("fps"), + "fps": skeleton_data.get("fps"), "tags": ["review"] if preview else [], } @@ -377,18 +378,19 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, if ext in skip_integration_repre_list: rep["tags"].append("delete") - if instance.get("multipartExr", False): + if skeleton_data.get("multipartExr", False): rep["tags"].append("multipartExr") # support conversion from tiled to scanline - if instance.get("convertToScanline"): + if skeleton_data.get("convertToScanline"): log.info("Adding scanline conversion.") rep["tags"].append("toScanline") representations.append(rep) if preview: - instance["families"] = _add_review_families(instance["families"]) + skeleton_data["families"] = _add_review_families( + skeleton_data["families"]) # add remainders as representations for remainder in remainders: @@ -419,13 +421,14 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, preview = preview and not do_not_add_review if preview: rep.update({ - "fps": instance.get("fps"), + "fps": skeleton_data.get("fps"), "tags": ["review"] }) - instance["families"] = _add_review_families(instance["families"]) + skeleton_data["families"] = \ + _add_review_families(skeleton_data["families"]) already_there = False - for repre in instance.get("representations", []): + for repre in skeleton_data.get("representations", []): # might be added explicitly before by publish_on_farm already_there = repre.get("files") == rep["files"] if already_there: @@ -438,8 +441,8 @@ def prepare_representations(instance, exp_files, anatomy, aov_filter, for rep in representations: # inject colorspace data color_managed_plugin.set_representation_colorspace( - rep, instance.context, - colorspace=instance.data["colorspace"] + rep, context, + colorspace=skeleton_data["colorspace"] ) return representations From 3d21df1fc5e24e4009d949f5c7172c26660fb5eb Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 13 Jul 2023 13:41:17 +0100 Subject: [PATCH 134/144] Avoid remove and update to delete new data This commit avoids that when updating or removing an asset, all new data, such as animations (actions), are removed. --- .../hosts/blender/plugins/load/load_blend.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 53933a1934..a0ccd65f29 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -23,7 +23,7 @@ class BlendLoader(plugin.AssetLoader): families = ["model", "rig", "layout", "camera"] representations = ["blend"] - label = "Load Blend" + label = "Append Blend" icon = "code-fork" color = "orange" @@ -70,10 +70,13 @@ class BlendLoader(plugin.AssetLoader): for attr in dir(data_to): setattr(data_to, attr, getattr(data_from, attr)) + members = [] + # Rename the object to add the asset name for attr in dir(data_to): for data in getattr(data_to, attr): data.name = f"{group_name}:{data.name}" + members.append(data) container = self._get_asset_container(data_to.objects) assert container, "No asset group found" @@ -92,7 +95,7 @@ class BlendLoader(plugin.AssetLoader): library = bpy.data.libraries.get(bpy.path.basename(libpath)) bpy.data.libraries.remove(library) - return container + return container, members def process_asset( self, context: dict, name: str, namespace: Optional[str] = None, @@ -126,7 +129,7 @@ class BlendLoader(plugin.AssetLoader): avalon_container = bpy.data.collections.new(name=AVALON_CONTAINERS) bpy.context.scene.collection.children.link(avalon_container) - container = self._process_data(libpath, group_name) + container, members = self._process_data(libpath, group_name) if family == "layout": self._post_process_layout(container, asset, representation) @@ -144,7 +147,8 @@ class BlendLoader(plugin.AssetLoader): "asset_name": asset_name, "parent": str(context["representation"]["parent"]), "family": context["representation"]["context"]["family"], - "objectName": group_name + "objectName": group_name, + "members": members, } container[AVALON_PROPERTY] = data @@ -175,7 +179,7 @@ class BlendLoader(plugin.AssetLoader): self.exec_remove(container) - asset_group = self._process_data(libpath, group_name) + asset_group, members = self._process_data(libpath, group_name) avalon_container = bpy.data.collections.get(AVALON_CONTAINERS) avalon_container.objects.link(asset_group) @@ -183,12 +187,17 @@ class BlendLoader(plugin.AssetLoader): asset_group.matrix_basis = transform asset_group.parent = parent + # Restore the old data, but reset memebers, as they don't exist anymore + # This avoids a crash, because the memory addresses of those members + # are not valid anymore + old_data["members"] = [] asset_group[AVALON_PROPERTY] = old_data new_data = { "libpath": libpath, "representation": str(representation["_id"]), "parent": str(representation["parent"]), + "members": members, } imprint(asset_group, new_data) @@ -210,7 +219,10 @@ class BlendLoader(plugin.AssetLoader): for attr in attrs: for data in getattr(bpy.data, attr): - if data.name.startswith(f"{group_name}:"): + if data in asset_group.get(AVALON_PROPERTY).get("members", []): + # Skip the asset group + if data == asset_group: + continue getattr(bpy.data, attr).remove(data) bpy.data.objects.remove(asset_group) From a80685c7f0713fb59e2737581b9e0864a7ea220a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 13 Jul 2023 16:43:57 +0100 Subject: [PATCH 135/144] Update the members of parent containers as well --- .../hosts/blender/plugins/load/load_blend.py | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index a0ccd65f29..ffd84a4b11 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -37,6 +37,17 @@ class BlendLoader(plugin.AssetLoader): return None + @staticmethod + def get_all_container_parents(asset_group): + parent_containers = [] + parent = asset_group.parent + while parent: + if parent.get(AVALON_PROPERTY): + parent_containers.append(parent) + parent = parent.parent + + return parent_containers + def _post_process_layout(self, container, asset, representation): rigs = [ obj for obj in container.children_recursive @@ -202,6 +213,13 @@ class BlendLoader(plugin.AssetLoader): imprint(asset_group, new_data) + # We need to update all the parent container members + parent_containers = self.get_all_container_parents(asset_group) + + for parent_container in parent_containers: + parent_members = parent_container[AVALON_PROPERTY]["members"] + parent_container[AVALON_PROPERTY]["members"] = parent_members + members + def exec_remove(self, container: Dict) -> bool: """ Remove an existing container from a Blender scene. @@ -217,9 +235,19 @@ class BlendLoader(plugin.AssetLoader): ) ] + members = asset_group.get(AVALON_PROPERTY).get("members", []) + + # We need to update all the parent container members + parent_containers = self.get_all_container_parents(asset_group) + + for parent in parent_containers: + parent.get(AVALON_PROPERTY)["members"] = list(filter( + lambda i: i not in members, + parent.get(AVALON_PROPERTY)["members"])) + for attr in attrs: for data in getattr(bpy.data, attr): - if data in asset_group.get(AVALON_PROPERTY).get("members", []): + if data in members: # Skip the asset group if data == asset_group: continue From 4faeea5b30a3eacf5040ec30e727f220e612f11b Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 13 Jul 2023 16:53:09 +0100 Subject: [PATCH 136/144] Hound fixes --- openpype/hosts/blender/plugins/load/load_blend.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index ffd84a4b11..db7663e0ed 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -218,7 +218,8 @@ class BlendLoader(plugin.AssetLoader): for parent_container in parent_containers: parent_members = parent_container[AVALON_PROPERTY]["members"] - parent_container[AVALON_PROPERTY]["members"] = parent_members + members + parent_container[AVALON_PROPERTY]["members"] = ( + parent_members + members) def exec_remove(self, container: Dict) -> bool: """ From 18324615d5c486c39d83e3f53da2172ec1cf6270 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 14 Jul 2023 10:14:57 +0100 Subject: [PATCH 137/144] Fix parenting if creating an instance while selecting a whole hierarchy --- openpype/hosts/blender/plugins/create/create_rig.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/create/create_rig.py b/openpype/hosts/blender/plugins/create/create_rig.py index 2e04fb71c1..08cc46ee3e 100644 --- a/openpype/hosts/blender/plugins/create/create_rig.py +++ b/openpype/hosts/blender/plugins/create/create_rig.py @@ -42,7 +42,9 @@ class CreateRig(plugin.Creator): bpy.context.view_layer.objects.active = asset_group selected = lib.get_selection() for obj in selected: - obj.select_set(True) + if obj.parent in selected: + obj.select_set(False) + continue selected.append(asset_group) bpy.ops.object.parent_set(keep_transform=True) From 585df831357dc859947b294d1a9af2c4a73b76c5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 14 Jul 2023 11:28:36 +0100 Subject: [PATCH 138/144] Apply parenting fix to other families as well --- openpype/hosts/blender/plugins/create/create_camera.py | 4 +++- openpype/hosts/blender/plugins/create/create_layout.py | 4 +++- openpype/hosts/blender/plugins/create/create_model.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_camera.py b/openpype/hosts/blender/plugins/create/create_camera.py index 6defe02fe5..7a770a3e77 100644 --- a/openpype/hosts/blender/plugins/create/create_camera.py +++ b/openpype/hosts/blender/plugins/create/create_camera.py @@ -43,7 +43,9 @@ class CreateCamera(plugin.Creator): bpy.context.view_layer.objects.active = asset_group selected = lib.get_selection() for obj in selected: - obj.select_set(True) + if obj.parent in selected: + obj.select_set(False) + continue selected.append(asset_group) bpy.ops.object.parent_set(keep_transform=True) else: diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index 68cfaa41ac..73ed683256 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -42,7 +42,9 @@ class CreateLayout(plugin.Creator): bpy.context.view_layer.objects.active = asset_group selected = lib.get_selection() for obj in selected: - obj.select_set(True) + if obj.parent in selected: + obj.select_set(False) + continue selected.append(asset_group) bpy.ops.object.parent_set(keep_transform=True) diff --git a/openpype/hosts/blender/plugins/create/create_model.py b/openpype/hosts/blender/plugins/create/create_model.py index e5204b5b53..51fc6683f6 100644 --- a/openpype/hosts/blender/plugins/create/create_model.py +++ b/openpype/hosts/blender/plugins/create/create_model.py @@ -42,7 +42,9 @@ class CreateModel(plugin.Creator): bpy.context.view_layer.objects.active = asset_group selected = lib.get_selection() for obj in selected: - obj.select_set(True) + if obj.parent in selected: + obj.select_set(False) + continue selected.append(asset_group) bpy.ops.object.parent_set(keep_transform=True) From aaafb9ccf2a271aa7571df7be15baceffe6e48c9 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 14 Jul 2023 12:05:49 +0100 Subject: [PATCH 139/144] Added blendScene family --- .../plugins/create/create_blendScene.py | 51 +++++++++++++++++++ .../hosts/blender/plugins/load/load_blend.py | 2 +- .../blender/plugins/publish/extract_blend.py | 2 +- openpype/plugins/publish/integrate.py | 3 +- .../defaults/project_settings/blender.json | 3 +- 5 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 openpype/hosts/blender/plugins/create/create_blendScene.py diff --git a/openpype/hosts/blender/plugins/create/create_blendScene.py b/openpype/hosts/blender/plugins/create/create_blendScene.py new file mode 100644 index 0000000000..63bcf212ff --- /dev/null +++ b/openpype/hosts/blender/plugins/create/create_blendScene.py @@ -0,0 +1,51 @@ +"""Create a Blender scene asset.""" + +import bpy + +from openpype.pipeline import get_current_task_name +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES + + +class CreateBlendScene(plugin.Creator): + """Generic group of assets""" + + name = "blendScene" + label = "Blender Scene" + family = "blendScene" + icon = "cubes" + + def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + + def _process(self): + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object + asset = self.data["asset"] + subset = self.data["subset"] + name = plugin.asset_name(asset, subset) + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) + self.data['task'] = get_current_task_name() + lib.imprint(asset_group, self.data) + + # Add selected objects to instance + if (self.options or {}).get("useSelection"): + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + if obj.parent in selected: + obj.select_set(False) + continue + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) + + return asset_group diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index db7663e0ed..99f291a5a7 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -20,7 +20,7 @@ from openpype.hosts.blender.api.pipeline import ( class BlendLoader(plugin.AssetLoader): """Load assets from a .blend file.""" - families = ["model", "rig", "layout", "camera"] + families = ["model", "rig", "layout", "camera", "blendScene"] representations = ["blend"] label = "Append Blend" diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index 6a001b6f65..d4f26b4f3c 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -10,7 +10,7 @@ class ExtractBlend(publish.Extractor): label = "Extract Blend" hosts = ["blender"] - families = ["model", "camera", "rig", "action", "layout"] + families = ["model", "camera", "rig", "action", "layout", "blendScene"] optional = True def process(self, instance): diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index e76f9ce9c4..ffb9acf4a7 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -137,7 +137,8 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "mvUsdOverride", "simpleUnrealTexture", "online", - "uasset" + "uasset", + "blendScene" ] default_template_name = "publish" diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index eae5b239c8..29e61fe233 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -54,7 +54,8 @@ "camera", "rig", "action", - "layout" + "layout", + "blendScene" ] }, "ExtractFBX": { From 266f2308efba8bc6aa892cb473448d085fd922ac Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Fri, 14 Jul 2023 15:46:46 +0100 Subject: [PATCH 140/144] Increment workfile version when publishing blendScene --- .../hosts/blender/plugins/publish/increment_workfile_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 963ca1398f..27fa4baf28 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -9,7 +9,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): label = "Increment Workfile Version" optional = True hosts = ["blender"] - families = ["animation", "model", "rig", "action", "layout"] + families = ["animation", "model", "rig", "action", "layout", "blendScene"] def process(self, context): From 9d702a93d2c48840123140fbe01653aa62fe2379 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 15 Jul 2023 03:32:33 +0000 Subject: [PATCH 141/144] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f402f4541d..c0f74b5bc5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -36,6 +36,7 @@ body: description: What version are you running? Look to OpenPype Tray options: - 3.16.0 + - 3.16.0-nightly.2 - 3.16.0-nightly.1 - 3.15.12 - 3.15.12-nightly.4 @@ -134,7 +135,6 @@ body: - 3.14.6-nightly.1 - 3.14.5 - 3.14.5-nightly.3 - - 3.14.5-nightly.2 validations: required: true - type: dropdown From afa1d3cb031b536976c0b9a98d4512d03e1db21a Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Mon, 17 Jul 2023 13:06:59 +0200 Subject: [PATCH 142/144] fix args for workfile conversion util (#5308) --- openpype/client/server/conversion_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/client/server/conversion_utils.py b/openpype/client/server/conversion_utils.py index dc95bbeda5..24d4678095 100644 --- a/openpype/client/server/conversion_utils.py +++ b/openpype/client/server/conversion_utils.py @@ -1320,7 +1320,9 @@ def convert_update_representation_to_v4( return flat_data -def convert_update_workfile_info_to_v4(update_data): +def convert_update_workfile_info_to_v4( + project_name, workfile_id, update_data, con +): return { key: value for key, value in update_data.items() From 2be5b33510a869d560bb95e0fb3e33ed93400f03 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 17 Jul 2023 13:14:18 +0200 Subject: [PATCH 143/144] Fix wrong merge --- .../plugins/publish/validate_primitive_hierarchy_paths.py | 8 -------- openpype/hosts/max/api/plugin.py | 2 -- 2 files changed, 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index 3da5665f58..0d84aa7db8 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -70,14 +70,6 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): cls.log.debug("Checking for attribute: %s", path_attr) - if not hasattr(output_node, "geometry"): - # In the case someone has explicitly set an Object - # node instead of a SOP node in Geometry context - # then for now we ignore - this allows us to also - # export object transforms. - cls.log.warning("No geometry output node found, skipping check..") - return - if not hasattr(output_node, "geometry"): # In the case someone has explicitly set an Object # node instead of a SOP node in Geometry context diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index 36b4ea32d4..d8db716e6d 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -78,9 +78,7 @@ MS_CUSTOM_ATTRIB = """attributes "openPypeData" if idx do ( continue ) - name = c as string - append temp_arr handle_name append i_node_arr node_ref append sel_list name From b90d1c9e653cf9badfbce7de7f5442f55cb6eeef Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 17 Jul 2023 13:22:04 +0200 Subject: [PATCH 144/144] Fix wrong merge --- openpype/hosts/maya/api/fbx.py | 4 ++-- openpype/settings/defaults/project_settings/shotgrid.json | 2 +- poetry.lock | 2 +- pyproject.toml | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index e9cf8af491..260241f5fc 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -2,7 +2,7 @@ """Tools to work with FBX.""" import logging -import pyblish.api +from pyblish.api import Instance from maya import cmds # noqa import maya.mel as mel # noqa @@ -141,7 +141,7 @@ class FBXExtractor: return options def set_options_from_instance(self, instance): - # type: (pyblish.api.Instance) -> None + # type: (Instance) -> None """Sets FBX export options from data in the instance. Args: diff --git a/openpype/settings/defaults/project_settings/shotgrid.json b/openpype/settings/defaults/project_settings/shotgrid.json index 0dcffed28a..83b6f69074 100644 --- a/openpype/settings/defaults/project_settings/shotgrid.json +++ b/openpype/settings/defaults/project_settings/shotgrid.json @@ -1,6 +1,6 @@ { "shotgrid_project_id": 0, - "shotgrid_server": [], + "shotgrid_server": "", "event": { "enabled": false }, diff --git a/poetry.lock b/poetry.lock index 50f8150638..5621d39988 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "acre" diff --git a/pyproject.toml b/pyproject.toml index 5b58257310..fe5477fb00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,6 @@ wheel = "*" enlighten = "*" # cool terminal progress bars toml = "^0.10.2" # for parsing pyproject.toml pre-commit = "*" -mypy = "*" # for better types [tool.poetry.urls] "Bug Tracker" = "https://github.com/pypeclub/openpype/issues"