From 95aff1808fdb27d77b647f5b373c80e27eee56a1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 8 Feb 2023 22:03:05 +0800 Subject: [PATCH] setting up deadline for 3dsmax --- openpype/hosts/max/api/lib.py | 33 +++++ openpype/hosts/max/api/lib_renderproducts.py | 102 +++++++++++++ openpype/hosts/max/api/lib_rendersettings.py | 125 ++++++++++++++++ .../hosts/max/plugins/create/create_render.py | 33 +++++ .../max/plugins/publish/collect_render.py | 72 +++++++++ .../maya/plugins/publish/collect_render.py | 2 - .../plugins/publish/submit_3dmax_deadline.py | 137 ++++++++++++++++++ .../defaults/project_settings/max.json | 7 + .../schemas/projects_schema/schema_main.json | 4 + .../projects_schema/schema_project_max.json | 52 +++++++ 10 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/max/api/lib_renderproducts.py create mode 100644 openpype/hosts/max/api/lib_rendersettings.py create mode 100644 openpype/hosts/max/plugins/create/create_render.py create mode 100644 openpype/hosts/max/plugins/publish/collect_render.py create mode 100644 openpype/modules/deadline/plugins/publish/submit_3dmax_deadline.py create mode 100644 openpype/settings/defaults/project_settings/max.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_max.json diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 9256ca9ac1..8c421b2f9b 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -120,3 +120,36 @@ def get_all_children(parent, node_type=None): return ([x for x in child_list if rt.superClassOf(x) == node_type] if node_type else child_list) + + +def get_current_renderer(): + """get current renderer""" + return rt.renderers.production + + +def get_default_render_folder(project_setting=None): + return (project_setting["max"] + ["RenderSettings"] + ["default_render_image_folder"] + ) + + +def set_framerange(startFrame, endFrame): + """Get/set the type of time range to be rendered. + + Possible values are: + + 1 -Single frame. + + 2 -Active time segment ( animationRange ). + + 3 -User specified Range. + + 4 -User specified Frame pickup string (for example "1,3,5-12"). + """ + # hard-code, there should be a custom setting for this + rt.rendTimeType = 4 + if startFrame is not None and endFrame is not None: + frameRange = "{0}-{1}".format(startFrame, endFrame) + rt.rendPickupFrames = frameRange + diff --git a/openpype/hosts/max/api/lib_renderproducts.py b/openpype/hosts/max/api/lib_renderproducts.py new file mode 100644 index 0000000000..f3bb8bdad1 --- /dev/null +++ b/openpype/hosts/max/api/lib_renderproducts.py @@ -0,0 +1,102 @@ +# Render Element Example : For scanline render, VRay +# https://help.autodesk.com/view/MAXDEV/2022/ENU/?guid=GUID-E8F75D47-B998-4800-A3A5-610E22913CFC +# arnold +# https://help.autodesk.com/view/ARNOL/ENU/?guid=arnold_for_3ds_max_ax_maxscript_commands_ax_renderview_commands_html +import os +from pymxs import runtime as rt +from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_default_render_folder +) +from openpype.pipeline.context_tools import get_current_project_asset +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io + + +class RenderProducts(object): + + @classmethod + def __init__(self, project_settings=None): + self._project_settings = project_settings + if not self._project_settings: + self._project_settings = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + + def render_product(self, container): + folder = rt.maxFilePath + folder = folder.replace("\\", "/") + setting = self._project_settings + render_folder = get_default_render_folder(setting) + + output_file = os.path.join(folder, render_folder, container) + context = get_current_project_asset() + startFrame = context["data"].get("frameStart") + endFrame = context["data"].get("frameEnd") + 1 + + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] + full_render_list = self.beauty_render_product(output_file, + startFrame, + endFrame, + img_fmt) + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + + if renderer == "VUE_File_Renderer": + return full_render_list + + if ( + renderer == "ART_Renderer" or + renderer == "Redshift Renderer" or + renderer == "V_Ray_6_Hotfix_3" or + renderer == "V_Ray_GPU_6_Hotfix_3" or + renderer == "Default_Scanline_Renderer" or + renderer == "Quicksilver_Hardware_Renderer" + ): + render_elem_list = self.render_elements_product(output_file, + startFrame, + endFrame, + img_fmt) + for render_elem in render_elem_list: + full_render_list.append(render_elem) + return full_render_list + + if renderer == "Arnold": + return full_render_list + + + def beauty_render_product(self, folder, startFrame, endFrame, fmt): + # get the beauty + beauty_frame_range = list() + + for f in range(startFrame, endFrame): + beauty = "{0}.{1}.{2}".format(folder, str(f), fmt) + beauty = beauty.replace("\\", "/") + beauty_frame_range.append(beauty) + + return beauty_frame_range + + # TODO: Get the arnold render product + def render_elements_product(self, folder, startFrame, endFrame, fmt): + """Get all the render element output files. """ + render_dirname = list() + + render_elem = rt.maxOps.GetCurRenderElementMgr() + render_elem_num = render_elem.NumRenderElements() + # get render elements from the renders + for i in range(render_elem_num): + renderlayer_name = render_elem.GetRenderElement(i) + target, renderpass = str(renderlayer_name).split(":") + + render_dir = os.path.join(folder, renderpass) + if renderlayer_name.enabled: + for f in range(startFrame, endFrame): + render_element = "{0}.{1}.{2}".format(render_dir, str(f), fmt) + render_element = render_element.replace("\\", "/") + render_dirname.append(render_element) + + return render_dirname + + def image_format(self): + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] + return img_fmt diff --git a/openpype/hosts/max/api/lib_rendersettings.py b/openpype/hosts/max/api/lib_rendersettings.py new file mode 100644 index 0000000000..8c8a82ae66 --- /dev/null +++ b/openpype/hosts/max/api/lib_rendersettings.py @@ -0,0 +1,125 @@ +import os +from pymxs import runtime as rt +from openpype.lib import Logger +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io +from openpype.pipeline.context_tools import get_current_project_asset + +from openpype.hosts.max.api.lib import ( + set_framerange, + get_current_renderer, + get_default_render_folder +) + + +class RenderSettings(object): + + log = Logger.get_logger("RenderSettings") + + _aov_chars = { + "dot": ".", + "dash": "-", + "underscore": "_" + } + + @classmethod + def __init__(self, project_settings=None): + self._project_settings = project_settings + if not self._project_settings: + self._project_settings = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + + def set_render_camera(self, selection): + for sel in selection: + # to avoid Attribute Error from pymxs wrapper + found = False + if rt.classOf(sel) in rt.Camera.classes: + found = True + rt.viewport.setCamera(sel) + break + if not found: + raise RuntimeError("Camera not found") + + + def set_renderoutput(self, container): + folder = rt.maxFilePath + # hard-coded, should be customized in the setting + folder = folder.replace("\\", "/") + # hard-coded, set the renderoutput path + setting = self._project_settings + render_folder = get_default_render_folder(setting) + output_dir = os.path.join(folder, render_folder) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + # hard-coded, should be customized in the setting + context = get_current_project_asset() + + # get project reoslution + width = context["data"].get("resolutionWidth") + height = context["data"].get("resolutionHeight") + # Set Frame Range + startFrame = context["data"].get("frameStart") + endFrame = context["data"].get("frameEnd") + set_framerange(startFrame, endFrame) + # get the production render + renderer_class = get_current_renderer() + renderer = str(renderer_class).split(":")[0] + + img_fmt = self._project_settings["max"]["RenderSettings"]["image_format"] + output = os.path.join(output_dir, container) + try: + aov_separator = self._aov_chars[( + self._project_settings["maya"] + ["RenderSettings"] + ["aov_separator"] + )] + except KeyError: + aov_separator = "." + outputFilename = "{0}.{1}".format(output, img_fmt) + outputFilename = outputFilename.replace("{aov_separator}", aov_separator) + rt.rendOutputFilename = outputFilename + if renderer == "VUE_File_Renderer": + return + # TODO: Finish the arnold render setup + if renderer == "Arnold": + return + + if ( + renderer == "ART_Renderer" or + renderer == "Redshift Renderer" or + renderer == "V_Ray_6_Hotfix_3" or + renderer == "V_Ray_GPU_6_Hotfix_3" or + renderer == "Default_Scanline_Renderer" or + renderer == "Quicksilver_Hardware_Renderer" + ): + self.render_element_layer(output, width, height, img_fmt) + + rt.rendSaveFile= True + + + def render_element_layer(self, dir, width, height, ext): + """For Renderers with render elements""" + rt.renderWidth = width + rt.renderHeight = height + render_elem = rt.maxOps.GetCurRenderElementMgr() + render_elem_num = render_elem.NumRenderElements() + if render_elem_num < 0: + return + + for i in range(render_elem_num): + renderlayer_name = render_elem.GetRenderElement(i) + target, renderpass = str(renderlayer_name).split(":") + render_element = os.path.join(dir, renderpass) + aov_name = "{0}.{1}".format(render_element, ext) + try: + aov_separator = self._aov_chars[( + self._project_settings["maya"] + ["RenderSettings"] + ["aov_separator"] + )] + except KeyError: + aov_separator = "." + + aov_name = aov_name.replace("{aov_separator}", aov_separator) + render_elem.SetRenderElementFileName(i, aov_name) diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py new file mode 100644 index 0000000000..76c10ca4a9 --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating camera.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import CreatedInstance +from openpype.hosts.max.api.lib_rendersettings import RenderSettings + + +class CreateRender(plugin.MaxCreator): + identifier = "io.openpype.creators.max.render" + label = "Render" + family = "maxrender" + icon = "gear" + + def create(self, subset_name, instance_data, pre_create_data): + from pymxs import runtime as rt + sel_obj = list(rt.selection) + instance = super(CreateRender, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + container_name = instance.data.get("instance_node") + container = rt.getNodeByName(container_name) + # TODO: Disable "Add to Containers?" Panel + # parent the selected cameras into the container + for obj in sel_obj: + obj.parent = container + # for additional work on the node: + # instance_node = rt.getNodeByName(instance.get("instance_node")) + + # set viewport camera for rendering(mandatory for deadline) + RenderSettings().set_render_camera(sel_obj) + # set output paths for rendering(mandatory for deadline) + RenderSettings().set_renderoutput(container_name) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py new file mode 100644 index 0000000000..fc44c01206 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""Collect Render""" +import os +import pyblish.api + +from pymxs import runtime as rt +from openpype.pipeline import legacy_io +from openpype.hosts.max.api.lib import get_current_renderer +from openpype.hosts.max.api.lib_renderproducts import RenderProducts + + +class CollectRender(pyblish.api.InstancePlugin): + """Collect Render for Deadline""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect 3dmax Render Layers" + hosts = ['max'] + families = ["maxrender"] + + def process(self, instance): + context = instance.context + folder = rt.maxFilePath + file = rt.maxFileName + current_file = os.path.join(folder, file) + filepath = current_file.replace("\\", "/") + + context.data['currentFile'] = current_file + asset = legacy_io.Session["AVALON_ASSET"] + + render_layer_files = RenderProducts().render_product(instance.name) + folder = folder.replace("\\", "/") + + imgFormat = RenderProducts().image_format() + renderer_class = get_current_renderer() + renderer_name = str(renderer_class).split(":")[0] + # setup the plugin as 3dsmax for the internal renderer + if ( + renderer_name == "ART_Renderer" or + renderer_name == "Default_Scanline_Renderer" or + renderer_name == "Quicksilver_Hardware_Renderer" + ): + plugin = "3dsmax" + + if ( + renderer_name == "V_Ray_6_Hotfix_3" or + renderer_name == "V_Ray_GPU_6_Hotfix_3" + ): + plugin = "Vray" + + if renderer_name == "Redshift Renderer": + plugin = "redshift" + + if renderer_name == "Arnold": + plugin = "arnold" + + # https://forums.autodesk.com/t5/3ds-max-programming/pymxs-quickrender-animation-range/td-p/11216183 + + data = { + "subset": instance.name, + "asset": asset, + "publish": True, + "imageFormat": imgFormat, + "family": 'maxrender', + "families": ['maxrender'], + "source": filepath, + "files": render_layer_files, + "plugin": plugin, + "frameStart": context.data['frameStart'], + "frameEnd": context.data['frameEnd'] + } + self.log.info("data: {0}".format(data)) + instance.data.update(data) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index b1ad3ca58e..c5fce219fa 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -184,7 +184,6 @@ class CollectMayaRender(pyblish.api.ContextPlugin): self.log.info("multipart: {}".format( multipart)) assert exp_files, "no file names were generated, this is bug" - self.log.info(exp_files) # if we want to attach render to subset, check if we have AOV's # in expectedFiles. If so, raise error as we cannot attach AOV @@ -320,7 +319,6 @@ class CollectMayaRender(pyblish.api.ContextPlugin): "renderSetupIncludeLights" ) } - # Collect Deadline url if Deadline module is enabled deadline_settings = ( context.data["system_settings"]["modules"]["deadline"] diff --git a/openpype/modules/deadline/plugins/publish/submit_3dmax_deadline.py b/openpype/modules/deadline/plugins/publish/submit_3dmax_deadline.py new file mode 100644 index 0000000000..7e7173e4ce --- /dev/null +++ b/openpype/modules/deadline/plugins/publish/submit_3dmax_deadline.py @@ -0,0 +1,137 @@ +import os +import json +import getpass + +import requests +import pyblish.api + + +from openpype.pipeline import legacy_io + + +class MaxSubmitRenderDeadline(pyblish.api.InstancePlugin): + """ + 3DMax File Submit Render Deadline + + """ + + label = "Submit 3DsMax Render to Deadline" + order = pyblish.api.IntegratorOrder + hosts = ["max"] + families = ["maxrender"] + targets = ["local"] + + def process(self, instance): + context = instance.context + filepath = context.data["currentFile"] + filename = os.path.basename(filepath) + comment = context.data.get("comment", "") + deadline_user = context.data.get("deadlineUser", getpass.getuser()) + jobname ="{0} - {1}".format(filename, instance.name) + + # StartFrame to EndFrame + frames = "{start}-{end}".format( + start=int(instance.data["frameStart"]), + end=int(instance.data["frameEnd"]) + ) + + payload = { + "JobInfo": { + # Top-level group name + "BatchName": filename, + + # Job name, as seen in Monitor + "Name": jobname, + + # Arbitrary username, for visualisation in Monitor + "UserName": deadline_user, + + "Plugin": instance.data["plugin"], + "Pool": instance.data.get("primaryPool"), + "secondaryPool": instance.data.get("secondaryPool"), + "Frames": frames, + "ChunkSize" : instance.data.get("chunkSize", 10), + "Comment": comment + }, + "PluginInfo": { + # Input + "SceneFile": instance.data["source"], + "Version": "2023", + "SaveFile" : True, + # Mandatory for Deadline + # Houdini version without patch number + + "IgnoreInputs": True + }, + + # Mandatory for Deadline, may be empty + "AuxFiles": [] + } + # Include critical environment variables with submission + api.Session + keys = [ + # Submit along the current Avalon tool setup that we launched + # this application with so the Render Slave can build its own + # similar environment using it, e.g. "maya2018;vray4.x;yeti3.1.9" + "AVALON_TOOLS", + "OPENPYPE_VERSION" + ] + # Add mongo url if it's enabled + if context.data.get("deadlinePassMongoUrl"): + keys.append("OPENPYPE_MONGO") + + environment = dict({key: os.environ[key] for key in keys + if key in os.environ}, **legacy_io.Session) + + payload["JobInfo"].update({ + "EnvironmentKeyValue%d" % index: "{key}={value}".format( + key=key, + value=environment[key] + ) for index, key in enumerate(environment) + }) + + # Include OutputFilename entries + # The first entry also enables double-click to preview rendered + # frames from Deadline Monitor + output_data = {} + # need to be fixed + for i, filepath in enumerate(instance.data["files"]): + dirname = os.path.dirname(filepath) + fname = os.path.basename(filepath) + output_data["OutputDirectory%d" % i] = dirname.replace("\\", "/") + output_data["OutputFilename%d" % i] = fname + + if not os.path.exists(dirname): + self.log.info("Ensuring output directory exists: %s" % + dirname) + os.makedirs(dirname) + + payload["JobInfo"].update(output_data) + + self.submit(instance, payload) + + def submit(self, instance, payload): + + context = instance.context + deadline_url = context.data.get("defaultDeadline") + deadline_url = instance.data.get( + "deadlineUrl", deadline_url) + + assert deadline_url, "Requires Deadline Webservice URL" + + plugin = payload["JobInfo"]["Plugin"] + self.log.info("Using Render Plugin : {}".format(plugin)) + + self.log.info("Submitting..") + self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) + + # E.g. http://192.168.0.1:8082/api/jobs + url = "{}/api/jobs".format(deadline_url) + response = requests.post(url, json=payload, verify=False) + if not response.ok: + raise Exception(response.text) + # Store output dir for unified publisher (filesequence) + expected_files = instance.data["files"] + self.log.info("exp:{}".format(expected_files)) + output_dir = os.path.dirname(expected_files[0]) + instance.data["outputDir"] = output_dir + instance.data["deadlineSubmissionJob"] = response.json() diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json new file mode 100644 index 0000000000..651a074a08 --- /dev/null +++ b/openpype/settings/defaults/project_settings/max.json @@ -0,0 +1,7 @@ +{ + "RenderSettings": { + "default_render_image_folder": "renders/max", + "aov_separator": "underscore", + "image_format": "exr" + } +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 0b9fbf7470..ebe59c7942 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -82,6 +82,10 @@ "type": "schema", "name": "schema_project_slack" }, + { + "type": "schema", + "name": "schema_project_max" + }, { "type": "schema", "name": "schema_project_maya" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json new file mode 100644 index 0000000000..3d4cd5c54a --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json @@ -0,0 +1,52 @@ +{ + "type": "dict", + "collapsible": true, + "key": "max", + "label": "Max", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "RenderSettings", + "label": "Render Settings", + "children": [ + { + "type": "text", + "key": "default_render_image_folder", + "label": "Default render image folder" + }, + { + "key": "aov_separator", + "label": "AOV Separator character", + "type": "enum", + "multiselection": false, + "default": "underscore", + "enum_items": [ + {"dash": "- (dash)"}, + {"underscore": "_ (underscore)"}, + {"dot": ". (dot)"} + ] + }, + { + "key": "image_format", + "label": "Output Image Format", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"avi": "avi"}, + {"bmp": "bmp"}, + {"exr": "exr"}, + {"tif": "tif"}, + {"tiff": "tiff"}, + {"jpg": "jpg"}, + {"png": "png"}, + {"tga": "tga"}, + {"dds": "dds"} + ] + } + ] + } + ] +} \ No newline at end of file