From 735a35010fb9e667d3191a54cee5e01a24f2ab6a Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 2 Feb 2023 09:55:45 +0000 Subject: [PATCH 01/47] Fix assembly job submission --- .../plugins/publish/submit_maya_deadline.py | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 070d4eab18..230dc52618 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -410,8 +410,10 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): assembly_job_info.Name += " - Tile Assembly Job" assembly_job_info.Frames = 1 assembly_job_info.MachineLimit = 1 - assembly_job_info.Priority = instance.data.get("tile_priority", - self.tile_priority) + assembly_job_info.Priority = instance.data.get( + "tile_priority", self.tile_priority + ) + assembly_job_info.TileJob = False assembly_plugin_info = { "CleanupTiles": 1, @@ -438,13 +440,11 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): frame_assembly_job_info.JobDependencies = tile_job_id # write assembly job config files - now = datetime.now() - config_file = os.path.join( output_dir, "{}_config_{}.txt".format( os.path.splitext(file)[0], - now.strftime("%Y_%m_%d_%H_%M_%S") + datetime.now().strftime("%Y_%m_%d_%H_%M_%S") ) ) try: @@ -455,6 +455,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): self.log.warning("Path is unreachable: " "`{}`".format(output_dir)) + assembly_plugin_info["ConfigFile"] = config_file + with open(config_file, "w") as cf: print("TileCount={}".format(tiles_count), file=cf) print("ImageFileName={}".format(file), file=cf) @@ -463,6 +465,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): print("ImageHeight={}".format( instance.data.get("resolutionHeight")), file=cf) + with open(config_file, "a") as cf: tiles = _format_tiles( file, 0, instance.data.get("tilesX"), @@ -474,14 +477,12 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): for k, v in sorted(tiles.items()): print("{}={}".format(k, v), file=cf) - payload = self.assemble_payload( - job_info=frame_assembly_job_info, - plugin_info=assembly_plugin_info.copy(), - # todo: aux file transfers don't work with deadline webservice - # add config file as job auxFile - # aux_files=[config_file] + assembly_payloads.append( + self.assemble_payload( + job_info=frame_assembly_job_info, + plugin_info=assembly_plugin_info.copy(), + ) ) - assembly_payloads.append(payload) # Submit assembly jobs assembly_job_ids = [] @@ -491,6 +492,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): "submitting assembly job {} of {}".format(i + 1, num_assemblies) ) + self.log.info(payload) assembly_job_id = self.submit(payload) assembly_job_ids.append(assembly_job_id) From 0223d94552dda376548e8446bcea88b33de9dd19 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 2 Feb 2023 12:50:52 +0000 Subject: [PATCH 02/47] Fix OpenPypeTileAssembler --- .../OpenPypeTileAssembler.py | 92 +------------------ 1 file changed, 5 insertions(+), 87 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index 625a3f1a28..674938e641 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -320,12 +320,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): output_file = data["ImageFileName"] output_file = RepositoryUtils.CheckPathMapping(output_file) output_file = self.process_path(output_file) - """ - _, ext = os.path.splitext(output_file) - if "exr" not in ext: - self.FailRender( - "[{}] Only EXR format is supported for now.".format(ext)) - """ + tile_info = [] for tile in range(int(data["TileCount"])): tile_info.append({ @@ -336,11 +331,6 @@ class OpenPypeTileAssembler(DeadlinePlugin): "width": int(data["Tile{}Width".format(tile)]) }) - # FFMpeg doesn't support tile coordinates at the moment. - # arguments = self.tile_completer_ffmpeg_args( - # int(data["ImageWidth"]), int(data["ImageHeight"]), - # tile_info, output_file) - arguments = self.tile_oiio_args( int(data["ImageWidth"]), int(data["ImageHeight"]), tile_info, output_file) @@ -362,20 +352,20 @@ class OpenPypeTileAssembler(DeadlinePlugin): def pre_render_tasks(self): """Load config file and do remapping.""" self.LogInfo("OpenPype Tile Assembler starting...") - scene_filename = self.GetDataFilename() + config_file = self.GetPluginInfoEntry("ConfigFile") temp_scene_directory = self.CreateTempDirectory( "thread" + str(self.GetThreadNumber())) - temp_scene_filename = Path.GetFileName(scene_filename) + temp_scene_filename = Path.GetFileName(config_file) self.config_file = Path.Combine( temp_scene_directory, temp_scene_filename) if SystemUtils.IsRunningOnWindows(): RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( - scene_filename, self.config_file, "/", "\\") + config_file, self.config_file, "/", "\\") else: RepositoryUtils.CheckPathMappingInFileAndReplaceSeparator( - scene_filename, self.config_file, "\\", "/") + config_file, self.config_file, "\\", "/") os.chmod(self.config_file, os.stat(self.config_file).st_mode) def post_render_tasks(self): @@ -459,75 +449,3 @@ class OpenPypeTileAssembler(DeadlinePlugin): args.append(output_path) return args - - def tile_completer_ffmpeg_args( - self, output_width, output_height, tiles_info, output_path): - """Generate ffmpeg arguments for tile assembly. - - Expected inputs are tiled images. - - Args: - output_width (int): Width of output image. - output_height (int): Height of output image. - tiles_info (list): List of tile items, each item must be - dictionary with `filepath`, `pos_x` and `pos_y` keys - representing path to file and x, y coordinates on output - image where top-left point of tile item should start. - output_path (str): Path to file where should be output stored. - - Returns: - (list): ffmpeg arguments. - - """ - previous_name = "base" - ffmpeg_args = [] - filter_complex_strs = [] - - filter_complex_strs.append("nullsrc=size={}x{}[{}]".format( - output_width, output_height, previous_name - )) - - new_tiles_info = {} - for idx, tile_info in enumerate(tiles_info): - # Add input and store input index - filepath = tile_info["filepath"] - ffmpeg_args.append("-i \"{}\"".format(filepath.replace("\\", "/"))) - - # Prepare initial filter complex arguments - index_name = "input{}".format(idx) - filter_complex_strs.append( - "[{}]setpts=PTS-STARTPTS[{}]".format(idx, index_name) - ) - tile_info["index"] = idx - new_tiles_info[index_name] = tile_info - - # Set frames to 1 - ffmpeg_args.append("-frames 1") - - # Concatenation filter complex arguments - global_index = 1 - total_index = len(new_tiles_info) - for index_name, tile_info in new_tiles_info.items(): - item_str = ( - "[{previous_name}][{index_name}]overlay={pos_x}:{pos_y}" - ).format( - previous_name=previous_name, - index_name=index_name, - pos_x=tile_info["pos_x"], - pos_y=tile_info["pos_y"] - ) - new_previous = "tmp{}".format(global_index) - if global_index != total_index: - item_str += "[{}]".format(new_previous) - filter_complex_strs.append(item_str) - previous_name = new_previous - global_index += 1 - - joined_parts = ";".join(filter_complex_strs) - filter_complex_str = "-filter_complex \"{}\"".format(joined_parts) - - ffmpeg_args.append(filter_complex_str) - ffmpeg_args.append("-y") - ffmpeg_args.append("\"{}\"".format(output_path)) - - return ffmpeg_args From ce44ceb1fdd65be4451922b1e8ca0096923dd29f Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 2 Feb 2023 12:51:09 +0000 Subject: [PATCH 03/47] Fix y tiles ordering. --- .../plugins/publish/submit_maya_deadline.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 230dc52618..6261a69706 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -466,13 +466,16 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): instance.data.get("resolutionHeight")), file=cf) with open(config_file, "a") as cf: + # Need to reverse the order of the y tiles, because image + # coordinates are calculated from bottom left corner. tiles = _format_tiles( file, 0, instance.data.get("tilesX"), instance.data.get("tilesY"), instance.data.get("resolutionWidth"), instance.data.get("resolutionHeight"), - payload_plugin_info["OutputFilePrefix"] + payload_plugin_info["OutputFilePrefix"], + reversed_y=True )[1] for k, v in sorted(tiles.items()): print("{}={}".format(k, v), file=cf) @@ -752,8 +755,15 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): def _format_tiles( - filename, index, tiles_x, tiles_y, - width, height, prefix): + filename, + index, + tiles_x, + tiles_y, + width, + height, + prefix, + reversed_y=False +): """Generate tile entries for Deadline tile job. Returns two dictionaries - one that can be directly used in Deadline @@ -790,6 +800,7 @@ def _format_tiles( width (int): Width resolution of final image. height (int): Height resolution of final image. prefix (str): Image prefix. + reversed_y (bool): Reverses the order of the y tiles. Returns: (dict, dict): Tuple of two dictionaries - first can be used to @@ -812,12 +823,16 @@ def _format_tiles( cfg["TilesCropped"] = "False" tile = 0 + range_y = range(1, tiles_y + 1) + reversed_y_range = list(reversed(range_y)) for tile_x in range(1, tiles_x + 1): - for tile_y in reversed(range(1, tiles_y + 1)): + for i, tile_y in enumerate(range_y): + tile_y_index = tile_y + if reversed_y: + tile_y_index = reversed_y_range[i] + tile_prefix = "_tile_{}x{}_{}x{}_".format( - tile_x, tile_y, - tiles_x, - tiles_y + tile_x, tile_y_index, tiles_x, tiles_y ) new_filename = "{}/{}{}".format( @@ -832,11 +847,14 @@ def _format_tiles( right = (tile_x * w_space) - 1 # Job info - out["JobInfo"]["OutputFilename{}Tile{}".format(index, tile)] = new_filename # noqa: E501 + key = "OutputFilename{}Tile{}".format(index, tile) + out["JobInfo"][key] = new_filename # Plugin Info - out["PluginInfo"]["RegionPrefix{}".format(str(tile))] = \ - "/{}".format(tile_prefix).join(prefix.rsplit("/", 1)) + key = "RegionPrefix{}".format(str(tile)) + out["PluginInfo"][key] = "/{}".format( + tile_prefix + ).join(prefix.rsplit("/", 1)) out["PluginInfo"]["RegionTop{}".format(tile)] = top out["PluginInfo"]["RegionBottom{}".format(tile)] = bottom out["PluginInfo"]["RegionLeft{}".format(tile)] = left From 5b961887a392f3f99a829562634701d2b8961fe4 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 2 Feb 2023 18:08:09 +0000 Subject: [PATCH 04/47] Add plugin version. --- .../plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index 674938e641..f9c5db1800 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -16,6 +16,10 @@ from Deadline.Scripting import ( FileUtils, RepositoryUtils, SystemUtils) +version_major = 1 +version_minor = 0 +version_patch = 0 +version_string = ".".join([version_major, version_minor, version_patch]) STRING_TAGS = { "format" } @@ -264,6 +268,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): def initialize_process(self): """Initialization.""" + print("Plugin version: {}".format(version_string)) self.SingleFramesOnly = True self.StdoutHandling = True self.renderer = self.GetPluginInfoEntryWithDefault( From 97ff180d039c22dce44f5f53e0ecf6177363705e Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 2 Feb 2023 18:20:05 +0000 Subject: [PATCH 05/47] Fix plugin version --- .../plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py index f9c5db1800..ef76937b5a 100644 --- a/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py +++ b/openpype/modules/deadline/repository/custom/plugins/OpenPypeTileAssembler/OpenPypeTileAssembler.py @@ -19,7 +19,7 @@ from Deadline.Scripting import ( version_major = 1 version_minor = 0 version_patch = 0 -version_string = ".".join([version_major, version_minor, version_patch]) +version_string = "{}.{}.{}".format(version_major, version_minor, version_patch) STRING_TAGS = { "format" } @@ -268,7 +268,7 @@ class OpenPypeTileAssembler(DeadlinePlugin): def initialize_process(self): """Initialization.""" - print("Plugin version: {}".format(version_string)) + self.LogInfo("Plugin version: {}".format(version_string)) self.SingleFramesOnly = True self.StdoutHandling = True self.renderer = self.GetPluginInfoEntryWithDefault( From 9d8eb7383581b3dc48d378db7ef65986a24118e2 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 3 Feb 2023 12:16:47 +0000 Subject: [PATCH 06/47] Documentation --- website/docs/artist_hosts_maya.md | 16 ++++++++++++++++ website/docs/module_deadline.md | 28 ++++++++++++++++------------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/website/docs/artist_hosts_maya.md b/website/docs/artist_hosts_maya.md index 5cd8efa153..81164eefad 100644 --- a/website/docs/artist_hosts_maya.md +++ b/website/docs/artist_hosts_maya.md @@ -504,6 +504,22 @@ In the scene from where you want to publish your model create *Render subset*. P model subset (Maya set node) under corresponding `LAYER_` set under *Render instance*. During publish, it will submit this render to farm and after it is rendered, it will be attached to your model subset. +### Tile Rendering +:::note Deadline +This feature is only supported when using Deadline. See [here](module_deadline#openpypetileassembler-plugin) for setup. +::: +On the render instance objectset you'll find: + +* `Tile Rendering` - for enabling tile rendering. +* `Tile X` - number of tiles in the X axis. +* `Tile Y` - number of tiles in the Y axis. + +When submittig to Deadline, you'll get: + +- for each frame a tile rendering job, to render each from Maya. +- for each frame a tile assembly job, to assemble the rendered tiles. +- job to publish the assembled frames. + ## Render Setups ### Publishing Render Setups diff --git a/website/docs/module_deadline.md b/website/docs/module_deadline.md index c96da91909..2c02530b79 100644 --- a/website/docs/module_deadline.md +++ b/website/docs/module_deadline.md @@ -28,16 +28,16 @@ For [AWS Thinkbox Deadline](https://www.awsthinkbox.com/deadline) support you ne OpenPype integration for Deadline consists of two parts: - The `OpenPype` Deadline Plug-in -- A `GlobalJobPreLoad` Deadline Script (this gets triggered for each deadline job) +- A `GlobalJobPreLoad` Deadline Script (this gets triggered for each deadline job) The `GlobalJobPreLoad` handles populating render and publish jobs with proper environment variables using settings from the `OpenPype` Deadline Plug-in. -The `OpenPype` Deadline Plug-in must be configured to point to a valid OpenPype executable location. The executable need to be installed to +The `OpenPype` Deadline Plug-in must be configured to point to a valid OpenPype executable location. The executable need to be installed to destinations accessible by DL process. Check permissions (must be executable and accessible by Deadline process) - Enable `Tools > Super User Mode` in Deadline Monitor -- Go to `Tools > Configure Plugins...`, find `OpenPype` in the list on the left side, find location of OpenPype +- Go to `Tools > Configure Plugins...`, find `OpenPype` in the list on the left side, find location of OpenPype executable. It is recommended to use the `openpype_console` executable as it provides a bit more logging. - In case of multi OS farms, provide multiple locations, each Deadline Worker goes through the list and tries to find the first accessible @@ -45,12 +45,16 @@ executable. It is recommended to use the `openpype_console` executable as it pro ![Configure plugin](assets/deadline_configure_plugin.png) +### OpenPypeTileAssembler Plugin +To setup tile rendering copy the `OpenPypeTileAssembler` plugin to the repository; +`[OpenPype]\openpype\modules\deadline\repository\custom\plugins\OpenPypeTileAssembler` > `[DeadlineRepository]\custom\plugins\OpenPypeTileAssembler` + ## Troubleshooting #### Publishing jobs fail directly in DCCs - Double check that all previously described steps were finished -- Check that `deadlinewebservice` is running on DL server +- Check that `deadlinewebservice` is running on DL server - Check that user's machine has access to deadline server on configured port #### Jobs are failing on DL side @@ -61,40 +65,40 @@ Each publishing from OpenPype consists of 2 jobs, first one is rendering, second - Jobs are failing with `OpenPype executable was not found` error - Check if OpenPype is installed on the Worker handling this job and ensure `OpenPype` Deadline Plug-in is properly [configured](#configuration) + Check if OpenPype is installed on the Worker handling this job and ensure `OpenPype` Deadline Plug-in is properly [configured](#configuration) - Publishing job is failing with `ffmpeg not installed` error - + OpenPype executable has to have access to `ffmpeg` executable, check OpenPype `Setting > General` ![FFmpeg setting](assets/ffmpeg_path.png) - Both jobs finished successfully, but there is no review on Ftrack - Make sure that you correctly set published family to be send to Ftrack. + Make sure that you correctly set published family to be send to Ftrack. ![Ftrack Family](assets/ftrack/ftrack-collect-main.png) Example: I want send to Ftrack review of rendered images from Harmony : - `Host names`: "harmony" - - `Families`: "render" + - `Families`: "render" - `Add Ftrack Family` to "Enabled" - + Make sure that you actually configured to create review for published subset in `project_settings/ftrack/publish/CollectFtrackFamily` ![Ftrack Family](assets/deadline_review.png) - Example: I want to create review for all reviewable subsets in Harmony : + Example: I want to create review for all reviewable subsets in Harmony : - Add "harmony" as a new key an ".*" as a value. - Rendering jobs are stuck in 'Queued' state or failing Make sure that your Deadline is not limiting specific jobs to be run only on specific machines. (Eg. only some machines have installed particular application.) - + Check `project_settings/deadline` - + ![Deadline group](assets/deadline_group.png) Example: I have separated machines with "Harmony" installed into "harmony" group on Deadline. I want rendering jobs published from Harmony to run only on those machines. From b5a4fb59a578309b9f94914546acf97606c271f4 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 3 Feb 2023 12:42:27 +0000 Subject: [PATCH 07/47] Change default settings to Draft Tile Assembler. --- openpype/settings/defaults/project_settings/deadline.json | 4 ++-- .../schemas/projects_schema/schema_project_deadline.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index ceb0b2e39a..c4c3d413d1 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -23,7 +23,7 @@ "enabled": true, "optional": false, "active": true, - "tile_assembler_plugin": "OpenPypeTileAssembler", + "tile_assembler_plugin": "DraftTileAssembler", "use_published": true, "import_reference": false, "asset_dependencies": true, @@ -106,4 +106,4 @@ } } } -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 08a505bd47..87b375bc9f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -121,7 +121,7 @@ "DraftTileAssembler": "Draft Tile Assembler" }, { - "OpenPypeTileAssembler": "Open Image IO" + "OpenPypeTileAssembler": "OpenPype Tile Assembler" } ] }, From 25b4907b35894641930bbb86d9a583fcf82d5dbe Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 3 Feb 2023 15:56:45 +0000 Subject: [PATCH 08/47] Fix for Draft Tile Assembler --- .../modules/deadline/plugins/publish/submit_maya_deadline.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 6261a69706..972e1810be 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -484,6 +484,9 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): self.assemble_payload( job_info=frame_assembly_job_info, plugin_info=assembly_plugin_info.copy(), + # This would fail if the client machine and webserice are + # using different storage paths. + aux_files=[config_file] ) ) From da183c2199f6eadb567c0bf4db400124a2583674 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 16 Feb 2023 16:30:16 +0000 Subject: [PATCH 09/47] Saving with default settings properly. --- openpype/settings/defaults/project_settings/deadline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index c563531b39..e55f33bb41 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -107,4 +107,4 @@ } } } -} +} \ No newline at end of file From 63eee639f7a3f01ad15d6e9b8bc5b7608e21084b Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 16 Feb 2023 18:35:23 +0000 Subject: [PATCH 10/47] Fix frames issues. --- .../deadline/plugins/publish/submit_maya_deadline.py | 1 + .../publish/validate_expected_and_rendered_files.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index cb47c854a8..19e8a258f4 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -442,6 +442,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): frame_assembly_job_info.ExtraInfo[0] = file_hash frame_assembly_job_info.ExtraInfo[1] = file frame_assembly_job_info.JobDependencies = tile_job_id + frame_assembly_job_info.Frames = frame # write assembly job config files config_file = os.path.join( diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index f0a3ddd246..9311a9d64a 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -68,8 +68,15 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # files to be in the folder that we might not want to use. missing = expected_files - existing_files if missing: - raise RuntimeError("Missing expected files: {}".format( - sorted(missing))) + raise RuntimeError( + "Missing expected files: {}\n" + "Expected files: {}\n" + "Existing files: {}".format( + sorted(missing), + sorted(expected_files), + sorted(existing_files) + ) + ) def _get_frame_list(self, original_job_id): """Returns list of frame ranges from all render job. From 1914a0dd0212473beba1ee7e4715c3d7441be057 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 14 Mar 2023 10:07:00 +0100 Subject: [PATCH 11/47] Remove unused imports --- openpype/hosts/maya/plugins/publish/extract_look.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index efeddcfbe4..bfa26d158f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -1,12 +1,10 @@ # -*- coding: utf-8 -*- """Maya look extractor.""" import os -import sys import json import tempfile import platform import contextlib -import subprocess from collections import OrderedDict from maya import cmds # noqa From e250d8935f45490ee991b9ac11bc8ec758f3c32d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 14 Mar 2023 10:14:09 +0100 Subject: [PATCH 12/47] Don't require to `arnold` plug-in to be available, don't force load it if we don't need it. --- openpype/hosts/maya/plugins/publish/extract_look.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index bfa26d158f..1893155291 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -21,6 +21,15 @@ COPY = 1 HARDLINK = 2 +def _has_arnold(): + """Return whether the arnold package is available and can be imported.""" + try: + import arnold + return True + except (ImportError, ModuleNotFoundError): + return False + + def escape_space(path): """Ensure path is enclosed by quotes to allow paths with spaces""" return '"{}"'.format(path) if " " in path else path @@ -546,7 +555,7 @@ class ExtractLook(publish.Extractor): color_space = cmds.getAttr(color_space_attr) except ValueError: # node doesn't have color space attribute - if cmds.loadPlugin("mtoa", quiet=True): + if _has_arnold(): img_info = image_info(filepath) color_space = guess_colorspace(img_info) else: @@ -558,7 +567,7 @@ class ExtractLook(publish.Extractor): render_colorspace]) else: - if cmds.loadPlugin("mtoa", quiet=True): + if _has_arnold(): img_info = image_info(filepath) color_space = guess_colorspace(img_info) if color_space == "sRGB": From f0a76b5cc56b9b6577f768f1390d06468a6cec4e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 14 Mar 2023 11:01:45 +0100 Subject: [PATCH 13/47] Shush hound --- openpype/hosts/maya/plugins/publish/extract_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 1893155291..bc506b7feb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -24,7 +24,7 @@ HARDLINK = 2 def _has_arnold(): """Return whether the arnold package is available and can be imported.""" try: - import arnold + import arnold # noqa: F401 return True except (ImportError, ModuleNotFoundError): return False From f1b39494be1027d757b359336dda4a7869c8764c Mon Sep 17 00:00:00 2001 From: Toke Jepsen Date: Tue, 14 Mar 2023 14:50:15 +0000 Subject: [PATCH 14/47] Update openpype/modules/deadline/plugins/publish/submit_maya_deadline.py Co-authored-by: Kayla Man <64118225+moonyuet@users.noreply.github.com> --- .../modules/deadline/plugins/publish/submit_maya_deadline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index f7fc608f30..53ecb9063b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -860,7 +860,7 @@ def _format_tiles( right = (tile_x * w_space) - 1 # Job info - key = "OutputFilename{}Tile{}".format(index, tile) + key = "OutputFilename{}".format(index) out["JobInfo"][key] = new_filename # Plugin Info From f050a024aa5c97cb61c2f780349e89dbce5c0f82 Mon Sep 17 00:00:00 2001 From: Thomas Fricard Date: Thu, 16 Mar 2023 16:52:06 +0100 Subject: [PATCH 15/47] add an include parent hierarchy option in animation creator plugin of maya --- openpype/hosts/maya/plugins/create/create_animation.py | 4 +++- openpype/settings/defaults/project_settings/maya.json | 1 + .../schemas/projects_schema/schemas/schema_maya_create.json | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index a4b6e86598..2ea1b22bcb 100644 --- a/openpype/hosts/maya/plugins/create/create_animation.py +++ b/openpype/hosts/maya/plugins/create/create_animation.py @@ -13,6 +13,7 @@ class CreateAnimation(plugin.Creator): icon = "male" write_color_sets = False write_face_sets = False + include_parent_hierarchy = False include_user_defined_attributes = False def __init__(self, *args, **kwargs): @@ -37,7 +38,8 @@ class CreateAnimation(plugin.Creator): self.data["visibleOnly"] = False # Include the groups above the out_SET content - self.data["includeParentHierarchy"] = False # Include parent groups + # Include parent groups + self.data["includeParentHierarchy"] = self.include_parent_hierarchy # Default to exporting world-space self.data["worldSpace"] = True diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 63ba4542f3..2aa95fd1be 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -147,6 +147,7 @@ "enabled": true, "write_color_sets": false, "write_face_sets": false, + "include_parent_hierarchy": false, "include_user_defined_attributes": false, "defaults": [ "Main" diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index 1598f90643..d6e6c97b8c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -132,6 +132,11 @@ "key": "write_face_sets", "label": "Write Face Sets" }, + { + "type": "boolean", + "key": "include_parent_hierarchy", + "label": "Include Parent Hierarchy" + }, { "type": "boolean", "key": "include_user_defined_attributes", From 3eb8833596bf5b0780ebf4eca381e937e62c29b1 Mon Sep 17 00:00:00 2001 From: Thomas Fricard <51854004+friquette@users.noreply.github.com> Date: Fri, 17 Mar 2023 09:06:45 +0100 Subject: [PATCH 16/47] Update openpype/hosts/maya/plugins/create/create_animation.py Co-authored-by: Roy Nieterau --- openpype/hosts/maya/plugins/create/create_animation.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_animation.py b/openpype/hosts/maya/plugins/create/create_animation.py index 2ea1b22bcb..f992ff2c1a 100644 --- a/openpype/hosts/maya/plugins/create/create_animation.py +++ b/openpype/hosts/maya/plugins/create/create_animation.py @@ -38,7 +38,6 @@ class CreateAnimation(plugin.Creator): self.data["visibleOnly"] = False # Include the groups above the out_SET content - # Include parent groups self.data["includeParentHierarchy"] = self.include_parent_hierarchy # Default to exporting world-space From ecdc8966d05b9cd5256b664dd635b7004ccfb860 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 13 Jan 2023 12:36:20 +0100 Subject: [PATCH 17/47] Implement Maya image file node loader --- .../hosts/maya/plugins/load/load_image.py | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 openpype/hosts/maya/plugins/load/load_image.py diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py new file mode 100644 index 0000000000..cdd895ff4b --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -0,0 +1,176 @@ +from openpype.pipeline import ( + load, + get_representation_path +) +from openpype.lib import EnumDef +from openpype.hosts.maya.api.pipeline import containerise +from openpype.hosts.maya.api.lib import ( + unique_namespace, + namespaced +) + +from maya import cmds + + +def create_texture(): + """Create place2dTexture with file node with uv connections + + Mimics Maya "file [Texture]" creation. + """ + + place = cmds.shadingNode("place2dTexture", asUtility=True, name="place2d") + file = cmds.shadingNode("file", asTexture=True, name="file") + + connections = ["coverage", "translateFrame", "rotateFrame", "rotateUV", + "mirrorU", "mirrorV", "stagger", "wrapV", "wrapU", + "repeatUV", "offset", "noiseUV", "vertexUvThree", + "vertexUvTwo", "vertexUvOne", "vertexCameraOne"] + for attr in connections: + src = "{}.{}".format(place, attr) + dest = "{}.{}".format(file, attr) + cmds.connectAttr(src, dest) + + cmds.connectAttr(place + '.outUV', file + '.uvCoord') + cmds.connectAttr(place + '.outUvFilterSize', file + '.uvFilterSize') + + return file, place + + +def create_projection(): + """Create texture with place3dTexture and projection + + Mimics Maya "file [Projection]" creation. + """ + + file, place = create_texture() + projection = cmds.shadingNode("projection", asTexture=True, + name="projection") + place3d = cmds.shadingNode("place3dTexture", asUtility=True, + name="place3d") + + cmds.connectAttr(place3d + '.worldInverseMatrix[0]', + projection + ".placementMatrix") + cmds.connectAttr(file + '.outColor', projection + ".image") + + return file, place, projection, place3d + + +def create_stencil(): + """Create texture with extra place2dTexture offset and stencil + + Mimics Maya "file [Stencil]" creation. + """ + + file, place = create_texture() + + place_stencil = cmds.shadingNode("place2dTexture", asUtility=True, + name="place2d_stencil") + stencil = cmds.shadingNode("stencil", asTexture=True, name="stencil") + + for src_attr, dest_attr in [ + ("outUV", "uvCoord"), + ("outUvFilterSize", "uvFilterSize") + ]: + src_plug = "{}.{}".format(place_stencil, src_attr) + cmds.connectAttr(src_plug, "{}.{}".format(place, dest_attr)) + cmds.connectAttr(src_plug, "{}.{}".format(stencil, dest_attr)) + + return file, place, stencil, place_stencil + + +class FileNodeLoader(load.LoaderPlugin): + """File node loader.""" + # TODO: Implement color space manamagent OCIO (set correct color space) + + families = ["image", "plate", "render"] + label = "Load file node" + representations = ["exr", "tif", "png", "jpg"] + icon = "image" + color = "orange" + order = 2 + + options = [ + EnumDef( + "mode", + items={ + "texture": "Texture", + "projection": "Projection", + "stencil": "Stencil" + }, + default="texture", + label="Texture Mode" + ) + ] + + def load(self, context, name, namespace, data): + + path = self.fname + asset = context['asset']['name'] + namespace = namespace or unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + with namespaced(namespace, new=True) as namespace: + # Create the nodes within the namespace + nodes = { + "texture": create_texture, + "projection": create_projection, + "stencil": create_stencil + }[data.get("mode", "texture")]() + + # Set the file node attributes + file_node = cmds.ls(nodes, type="file")[0] + cmds.setAttr(file_node + ".fileTextureName", path, type="string") + + # Set UV tiling mode if UDIM tiles + # TODO: Detect UDIM tiles and set accordingly (also on update) + cmds.setAttr(file_node + ".uvTilingMode", 3) # UDIM-tiles + + # Enable sequence if publish has `startFrame` and `endFrame` and + # `startFrame != endFrame` + # TODO: Detect sequences (also on update) + # cmds.setAttr(file_node + ".useFrameExtension", True) + + # For ease of access for the user select all the nodes and select + # the file node last so that UI shows its attributes by default + cmds.select(list(nodes) + [file_node], replace=True) + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__ + ) + + def update(self, container, representation): + + path = get_representation_path(representation) + members = cmds.sets(container['objectName'], query=True) + + file_node = cmds.ls(members, type="file")[0] + cmds.setAttr(file_node + ".fileTextureName", path, type="string") + + # Update representation + cmds.setAttr( + container["objectName"] + ".representation", + str(representation["_id"]), + type="string" + ) + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + members = cmds.sets(container['objectName'], query=True) + cmds.lockNode(members, lock=False) + cmds.delete([container['objectName']] + members) + + # Clean up the namespace + try: + cmds.namespace(removeNamespace=container['namespace'], + deleteNamespaceContent=True) + except RuntimeError: + pass From c6b583e85f96acd48d2951b38e89666007075e95 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Mar 2023 11:36:51 +0100 Subject: [PATCH 18/47] Detect udim and frame sequences --- .../hosts/maya/plugins/load/load_image.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index cdd895ff4b..117d1a7d0f 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -112,6 +112,10 @@ class FileNodeLoader(load.LoaderPlugin): suffix="_", ) + repre_context = context["representation"]["context"] + has_frames = repre_context.get("frame") is not None + has_udim = repre_context.get("udim") is not None + with namespaced(namespace, new=True) as namespace: # Create the nodes within the namespace nodes = { @@ -125,13 +129,15 @@ class FileNodeLoader(load.LoaderPlugin): cmds.setAttr(file_node + ".fileTextureName", path, type="string") # Set UV tiling mode if UDIM tiles - # TODO: Detect UDIM tiles and set accordingly (also on update) - cmds.setAttr(file_node + ".uvTilingMode", 3) # UDIM-tiles + if has_udim: + cmds.setAttr(file_node + ".uvTilingMode", 3) # UDIM-tiles # Enable sequence if publish has `startFrame` and `endFrame` and # `startFrame != endFrame` - # TODO: Detect sequences (also on update) - # cmds.setAttr(file_node + ".useFrameExtension", True) + if has_frames: + is_sequence = self._is_sequence(context) + if is_sequence: + cmds.setAttr(file_node + ".useFrameExtension", True) # For ease of access for the user select all the nodes and select # the file node last so that UI shows its attributes by default @@ -174,3 +180,28 @@ class FileNodeLoader(load.LoaderPlugin): deleteNamespaceContent=True) except RuntimeError: pass + + def _is_sequence(self, context): + """Check whether frameStart and frameEnd are not the same.""" + version = context.get("version", {}) + representation = context.get("representation", {}) + + print(version) + print(representation) + + for doc in [representation, version]: + # Frame range can be set on version or representation. When set on + # representation it overrides data on subset + data = doc.get("data", {}) + start = data.get("frameStartHandle", data.get("frameStart", None)) + end = data.get("frameEndHandle", data.get("frameEnd", None)) + + if start is None or end is None: + continue + + if start != end: + return True + else: + return False + + return False From a48e638798da6eadd87904cf6652424b33759d56 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Mar 2023 12:33:54 +0100 Subject: [PATCH 19/47] Explicitly set the frame and udim token based on template. --- .../hosts/maya/plugins/load/load_image.py | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index 117d1a7d0f..b7b0b5d4c8 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -1,6 +1,7 @@ +import copy from openpype.pipeline import ( load, - get_representation_path + get_representation_context ) from openpype.lib import EnumDef from openpype.hosts.maya.api.pipeline import containerise @@ -8,6 +9,8 @@ from openpype.hosts.maya.api.lib import ( unique_namespace, namespaced ) +from openpype.pipeline.load.utils import get_representation_path_from_context + from maya import cmds @@ -104,7 +107,7 @@ class FileNodeLoader(load.LoaderPlugin): def load(self, context, name, namespace, data): - path = self.fname + path = self._format_path(context) asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", @@ -123,10 +126,7 @@ class FileNodeLoader(load.LoaderPlugin): "projection": create_projection, "stencil": create_stencil }[data.get("mode", "texture")]() - - # Set the file node attributes file_node = cmds.ls(nodes, type="file")[0] - cmds.setAttr(file_node + ".fileTextureName", path, type="string") # Set UV tiling mode if UDIM tiles if has_udim: @@ -137,8 +137,17 @@ class FileNodeLoader(load.LoaderPlugin): if has_frames: is_sequence = self._is_sequence(context) if is_sequence: + # When enabling useFrameExtension maya automatically + # connects an expression to .frameExtension to set + # the current frame. However, this expression is generated + # with some delay and thus it'll show a warning if frame 0 + # doesn't exist because we're explicitly setting the + # token. cmds.setAttr(file_node + ".useFrameExtension", True) + # Set the file node path attribute + cmds.setAttr(file_node + ".fileTextureName", path, type="string") + # For ease of access for the user select all the nodes and select # the file node last so that UI shows its attributes by default cmds.select(list(nodes) + [file_node], replace=True) @@ -153,7 +162,8 @@ class FileNodeLoader(load.LoaderPlugin): def update(self, container, representation): - path = get_representation_path(representation) + context = get_representation_context(representation) + path = self._format_path(context) members = cmds.sets(container['objectName'], query=True) file_node = cmds.ls(members, type="file")[0] @@ -186,9 +196,6 @@ class FileNodeLoader(load.LoaderPlugin): version = context.get("version", {}) representation = context.get("representation", {}) - print(version) - print(representation) - for doc in [representation, version]: # Frame range can be set on version or representation. When set on # representation it overrides data on subset @@ -205,3 +212,45 @@ class FileNodeLoader(load.LoaderPlugin): return False return False + + def _format_path(self, context): + """Format the path with correct tokens for frames and udim tiles.""" + + context = copy.deepcopy(context) + representation = context["representation"] + template = representation.get("data", {}).get("template") + if not template: + # No template to find token locations for + return get_representation_path_from_context(context) + + def _placeholder(key): + # Substitute with a long placeholder value so that potential + # custom formatting with padding doesn't find its way into + # our formatting, so that wouldn't be padded as 0 + return "___{}___".format(key) + + # We want to format UDIM and Frame numbers with the specific tokens + # so we in-place change the representation context so it's formatted + # with the tokens as we'd want them. So we explicitly change those + # tokens around with what we'd need. + tokens = { + "frame": "", + "udim": "" + } + has_tokens = False + repre_context = representation["context"] + for key, token in tokens.items(): + if key in repre_context: + repre_context[key] = _placeholder(key) + has_tokens = True + + # Replace with our custom template that has the tokens set + representation["data"]["template"] = template + path = get_representation_path_from_context(context) + + if has_tokens: + for key, token in tokens.items(): + if key in repre_context: + path = path.replace(_placeholder(key), token) + + return path From 1c5b82168853236b532828a0c4daf8187f0f00a8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Mar 2023 12:34:58 +0100 Subject: [PATCH 20/47] Cosmetics --- openpype/hosts/maya/plugins/load/load_image.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index b7b0b5d4c8..c08724dca0 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -239,7 +239,7 @@ class FileNodeLoader(load.LoaderPlugin): } has_tokens = False repre_context = representation["context"] - for key, token in tokens.items(): + for key, _token in tokens.items(): if key in repre_context: repre_context[key] = _placeholder(key) has_tokens = True From 4ac950aa604b5107d68f314df94472148148f2f5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Mar 2023 15:26:55 +0100 Subject: [PATCH 21/47] Set color space from publish using representation color space data or falling back to imageio settings file rules in OP settings --- .../hosts/maya/plugins/load/load_image.py | 142 ++++++++++++++---- 1 file changed, 110 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index c08724dca0..0e535f1692 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -1,16 +1,24 @@ +import os import copy + +from openpype.lib import EnumDef from openpype.pipeline import ( load, get_representation_context ) -from openpype.lib import EnumDef +from openpype.pipeline.load.utils import get_representation_path_from_context +from openpype.pipeline.colorspace import ( + get_imageio_colorspace_from_filepath, + get_imageio_config, + get_imageio_file_rules +) +from openpype.settings import get_project_settings + from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import ( unique_namespace, namespaced ) -from openpype.pipeline.load.utils import get_representation_path_from_context - from maya import cmds @@ -83,7 +91,6 @@ def create_stencil(): class FileNodeLoader(load.LoaderPlugin): """File node loader.""" - # TODO: Implement color space manamagent OCIO (set correct color space) families = ["image", "plate", "render"] label = "Load file node" @@ -107,7 +114,6 @@ class FileNodeLoader(load.LoaderPlugin): def load(self, context, name, namespace, data): - path = self._format_path(context) asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", @@ -115,10 +121,6 @@ class FileNodeLoader(load.LoaderPlugin): suffix="_", ) - repre_context = context["representation"]["context"] - has_frames = repre_context.get("frame") is not None - has_udim = repre_context.get("udim") is not None - with namespaced(namespace, new=True) as namespace: # Create the nodes within the namespace nodes = { @@ -126,31 +128,14 @@ class FileNodeLoader(load.LoaderPlugin): "projection": create_projection, "stencil": create_stencil }[data.get("mode", "texture")]() - file_node = cmds.ls(nodes, type="file")[0] - # Set UV tiling mode if UDIM tiles - if has_udim: - cmds.setAttr(file_node + ".uvTilingMode", 3) # UDIM-tiles + file_node = cmds.ls(nodes, type="file")[0] - # Enable sequence if publish has `startFrame` and `endFrame` and - # `startFrame != endFrame` - if has_frames: - is_sequence = self._is_sequence(context) - if is_sequence: - # When enabling useFrameExtension maya automatically - # connects an expression to .frameExtension to set - # the current frame. However, this expression is generated - # with some delay and thus it'll show a warning if frame 0 - # doesn't exist because we're explicitly setting the - # token. - cmds.setAttr(file_node + ".useFrameExtension", True) + self._apply_representation_context(context, file_node) - # Set the file node path attribute - cmds.setAttr(file_node + ".fileTextureName", path, type="string") - - # For ease of access for the user select all the nodes and select - # the file node last so that UI shows its attributes by default - cmds.select(list(nodes) + [file_node], replace=True) + # For ease of access for the user select all the nodes and select + # the file node last so that UI shows its attributes by default + cmds.select(list(nodes) + [file_node], replace=True) return containerise( name=name, @@ -167,7 +152,7 @@ class FileNodeLoader(load.LoaderPlugin): members = cmds.sets(container['objectName'], query=True) file_node = cmds.ls(members, type="file")[0] - cmds.setAttr(file_node + ".fileTextureName", path, type="string") + self._apply_representation_context(context, file_node) # Update representation cmds.setAttr( @@ -191,6 +176,51 @@ class FileNodeLoader(load.LoaderPlugin): except RuntimeError: pass + def _apply_representation_context(self, context, file_node): + """Update the file node to match the context. + + This sets the file node's attributes for: + - file path + - udim tiling mode (if it is an udim tile) + - use frame extension (if it is a sequence) + - colorspace + + """ + + repre_context = context["representation"]["context"] + has_frames = repre_context.get("frame") is not None + has_udim = repre_context.get("udim") is not None + + # Set UV tiling mode if UDIM tiles + if has_udim: + cmds.setAttr(file_node + ".uvTilingMode", 3) # UDIM-tiles + else: + cmds.setAttr(file_node + ".uvTilingMode", 0) # off + + # Enable sequence if publish has `startFrame` and `endFrame` and + # `startFrame != endFrame` + if has_frames and self._is_sequence(context): + # When enabling useFrameExtension maya automatically + # connects an expression to .frameExtension to set + # the current frame. However, this expression is generated + # with some delay and thus it'll show a warning if frame 0 + # doesn't exist because we're explicitly setting the + # token. + cmds.setAttr(file_node + ".useFrameExtension", True) + else: + cmds.setAttr(file_node + ".useFrameExtension", False) + + # Set the file node path attribute + path = self._format_path(context) + cmds.setAttr(file_node + ".fileTextureName", path, type="string") + + # Set colorspace + colorspace = self._get_colorspace(context) + if colorspace: + cmds.setAttr(file_node + ".colorSpace", colorspace, type="string") + else: + self.log.debug("Unknown colorspace - setting colorspace skipped.") + def _is_sequence(self, context): """Check whether frameStart and frameEnd are not the same.""" version = context.get("version", {}) @@ -213,6 +243,54 @@ class FileNodeLoader(load.LoaderPlugin): return False + def _get_colorspace(self, context): + """Return colorspace of the file to load. + + Retrieves the explicit colorspace from the publish. If no colorspace + data is stored with published content then project imageio settings + are used to make an assumption of the colorspace based on the file + rules. If no file rules match then None is returned. + + Returns: + str or None: The colorspace of the file or None if not detected. + + """ + + # We can't apply color spaces if management is not enabled + if not cmds.colorManagementPrefs(query=True, cmEnabled=True): + return + + representation = context["representation"] + colorspace_data = representation.get("data", {}).get("colorspaceData") + if colorspace_data: + return colorspace_data["colorspace"] + + # Assume colorspace from filepath based on project settings + project_name = context["project"]["name"] + host_name = os.environ.get("AVALON_APP") + project_settings = get_project_settings(project_name) + + config_data = get_imageio_config( + project_name, host_name, + project_settings=project_settings + ) + file_rules = get_imageio_file_rules( + project_name, host_name, + project_settings=project_settings + ) + + path = get_representation_path_from_context(context) + colorspace = get_imageio_colorspace_from_filepath( + path=path, + host_name=host_name, + project_name=project_name, + config_data=config_data, + file_rules=file_rules, + project_settings=project_settings + ) + + return colorspace + def _format_path(self, context): """Format the path with correct tokens for frames and udim tiles.""" From 5a7e90daa8df073b4af735799d6b53ba2e911921 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Mar 2023 15:27:42 +0100 Subject: [PATCH 22/47] Cleanup --- openpype/hosts/maya/plugins/load/load_image.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index 0e535f1692..e975fb8b61 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -147,11 +147,10 @@ class FileNodeLoader(load.LoaderPlugin): def update(self, container, representation): - context = get_representation_context(representation) - path = self._format_path(context) members = cmds.sets(container['objectName'], query=True) - file_node = cmds.ls(members, type="file")[0] + + context = get_representation_context(representation) self._apply_representation_context(context, file_node) # Update representation From 91b99cafd42b69a00dc714f1a71089de60467771 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Mar 2023 15:48:43 +0100 Subject: [PATCH 23/47] Correctly set the UDIM token using capitals + cleanup comment --- openpype/hosts/maya/plugins/load/load_image.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index e975fb8b61..3cf2394525 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -306,13 +306,12 @@ class FileNodeLoader(load.LoaderPlugin): # our formatting, so that wouldn't be padded as 0 return "___{}___".format(key) - # We want to format UDIM and Frame numbers with the specific tokens - # so we in-place change the representation context so it's formatted - # with the tokens as we'd want them. So we explicitly change those - # tokens around with what we'd need. + # We format UDIM and Frame numbers with their specific tokens. To do so + # we in-place change the representation context data to format the path + # with our own data tokens = { "frame": "", - "udim": "" + "udim": "" } has_tokens = False repre_context = representation["context"] From b47722b35823d557e64377e74130185b5cb143a7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 6 Mar 2023 15:52:47 +0100 Subject: [PATCH 24/47] Fix comment --- openpype/hosts/maya/plugins/load/load_image.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index 3cf2394525..b464c268fc 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -226,8 +226,8 @@ class FileNodeLoader(load.LoaderPlugin): representation = context.get("representation", {}) for doc in [representation, version]: - # Frame range can be set on version or representation. When set on - # representation it overrides data on subset + # Frame range can be set on version or representation. + # When set on representation it overrides version data. data = doc.get("data", {}) start = data.get("frameStartHandle", data.get("frameStart", None)) end = data.get("frameEndHandle", data.get("frameEnd", None)) From 812fa065cfa5068eba12e83c5bf428e9dfd68255 Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Wed, 15 Mar 2023 16:08:07 +0000 Subject: [PATCH 25/47] Multiple Values - support multiple values for render attributes. - support repairing the render attributes. --- .../publish/validate_rendersettings.py | 80 ++++++++++++------- .../schemas/schema_maya_publish.json | 12 ++- 2 files changed, 60 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 94e2633593..3b2bd1a84a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -242,10 +242,6 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.DEFAULT_PADDING, "0" * cls.DEFAULT_PADDING)) # load validation definitions from settings - validation_settings = ( - instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( # noqa: E501 - "{}_render_attributes".format(renderer)) or [] - ) settings_lights_flag = instance.context.data["project_settings"].get( "maya", {}).get( "RenderSettings", {}).get( @@ -253,15 +249,54 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): instance_lights_flag = instance.data.get("renderSetupIncludeLights") if settings_lights_flag != instance_lights_flag: - cls.log.warning('Instance flag for "Render Setup Include Lights" is set to {0} and Settings flag is set to {1}'.format(instance_lights_flag, settings_lights_flag)) # noqa + cls.log.warning( + "Instance flag for \"Render Setup Include Lights\" is set to " + "{} and Settings flag is set to {}".format( + instance_lights_flag, settings_lights_flag + ) + ) # go through definitions and test if such node.attribute exists. # if so, compare its value from the one required. - for attr, value in OrderedDict(validation_settings).items(): - cls.log.debug("{}: {}".format(attr, value)) + for attribute, data in cls.get_nodes(instance, renderer).items(): + for node in data["nodes"]: + try: + render_value = cmds.getAttr( + "{}.{}".format(node, attribute) + ) + except RuntimeError: + invalid = True + cls.log.error( + "Cannot get value of {}.{}".format(node, attribute) + ) + else: + if str(render_value) not in data["values"]: + invalid = True + cls.log.error( + "Invalid value {} set on {}.{}. Expecting " + "{}".format( + render_value, node, attribute, data["values"] + ) + ) + + return invalid + + @classmethod + def get_nodes(cls, instance, renderer): + maya_settings = instance.context.data["project_settings"]["maya"] + validation_settings = ( + maya_settings["publish"]["ValidateRenderSettings"].get( + "{}_render_attributes".format(renderer) + ) or [] + ) + result = {} + for attr, values in OrderedDict(validation_settings).items(): + cls.log.debug("{}: {}".format(attr, values)) if "." not in attr: - cls.log.warning("Skipping invalid attribute defined in " - "validation settings: '{}'".format(attr)) + cls.log.warning( + "Skipping invalid attribute defined in validation " + "settings: \"{}\"".format(attr) + ) continue node_type, attribute_name = attr.split(".", 1) @@ -271,28 +306,13 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): if not nodes: cls.log.warning( - "No nodes of type '{}' found.".format(node_type)) + "No nodes of type \"{}\" found.".format(node_type) + ) continue - for node in nodes: - try: - render_value = cmds.getAttr( - "{}.{}".format(node, attribute_name)) - except RuntimeError: - invalid = True - cls.log.error( - "Cannot get value of {}.{}".format( - node, attribute_name)) - else: - if str(value) != str(render_value): - invalid = True - cls.log.error( - ("Invalid value {} set on {}.{}. " - "Expecting {}").format( - render_value, node, attribute_name, value) - ) + result[attribute_name] = {"nodes": nodes, "values": values} - return invalid + return result @classmethod def repair(cls, instance): @@ -305,6 +325,10 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): "{aov_separator}", instance.data.get("aovSeparator", "_") ) + for attribute, data in cls.get_nodes(instance, renderer).items(): + for node in data["nodes"]: + lib.set_attribute(attribute, data["values"][0], node) + with lib.renderlayer(layer_node): default = lib.RENDER_ATTRS['default'] render_attrs = lib.RENDER_ATTRS.get(renderer, default) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 3484f42f6b..5a66f8a513 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -369,7 +369,8 @@ "label": "Arnold Render Attributes", "use_label_wrap": true, "object_type": { - "type": "text" + "type": "list", + "object_type": "text" } }, { @@ -379,7 +380,8 @@ "label": "Vray Render Attributes", "use_label_wrap": true, "object_type": { - "type": "text" + "type": "list", + "object_type": "text" } }, { @@ -389,7 +391,8 @@ "label": "Redshift Render Attributes", "use_label_wrap": true, "object_type": { - "type": "text" + "type": "list", + "object_type": "text" } }, { @@ -399,7 +402,8 @@ "label": "Renderman Render Attributes", "use_label_wrap": true, "object_type": { - "type": "text" + "type": "list", + "object_type": "text" } } ] From 51fa89bef31b6b8f5ed7927bbadd31a64195977e Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Wed, 15 Mar 2023 16:08:14 +0000 Subject: [PATCH 26/47] Docs cosmetics. --- website/docs/admin_hosts_maya.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index ae0cf76f53..685213c206 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -6,13 +6,13 @@ sidebar_label: Maya ## Publish Plugins -### Render Settings Validator +### Render Settings Validator `ValidateRenderSettings` Render Settings Validator is here to make sure artists will submit renders -we correct settings. Some of these settings are needed by OpenPype but some -can be defined by TD using [OpenPype Settings UI](admin_settings.md). +with the correct settings. Some of these settings are needed by OpenPype but some +can be defined by the admin using [OpenPype Settings UI](admin_settings.md). OpenPype enforced settings include: @@ -51,7 +51,7 @@ just one instance of this node type but if that is not so, validator will go thr instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman** it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. -### Model Name Validator +### Model Name Validator `ValidateRenderSettings` @@ -95,7 +95,7 @@ You can set various aspects of scene submission to farm with per-project setting - **Optional** will mark sumission plugin optional - **Active** will enable/disable plugin - - **Tile Assembler Plugin** will set what should be used to assemble tiles on Deadline. Either **Open Image IO** will be used + - **Tile Assembler Plugin** will set what should be used to assemble tiles on Deadline. Either **Open Image IO** will be used or Deadlines **Draft Tile Assembler**. - **Use Published scene** enable to render from published scene instead of scene in work area. Rendering from published files is much safer. - **Use Asset dependencies** will mark job pending on farm until asset dependencies are fulfilled - for example Deadline will wait for scene file to be synced to cloud, etc. @@ -169,5 +169,3 @@ Fill in the necessary fields (the optional fields are regex filters) - Build your workfile ![maya build template](assets/maya-build_workfile_from_template.png) - - From 8bd4598e0907a07635b0a9aff3b0eabffa29685c Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Wed, 15 Mar 2023 16:26:48 +0000 Subject: [PATCH 27/47] Update docs --- website/docs/admin_hosts_maya.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 685213c206..82109f0e0c 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -36,10 +36,9 @@ For **Renderman**: For **Arnold**: - there shouldn't be `` token when merge AOVs option is turned on - Additional check can be added via Settings - **Project Settings > Maya > Publish plugin > ValidateRenderSettings**. You can add as many options as you want for every supported renderer. In first field put node type and attribute -and in the second required value. +and in the second required value. You can create multiple values for an attribute, but when repairing it'll be the first value in the list that get selected. ![Settings example](assets/maya-admin_render_settings_validator.png) From b8633c42793ccd0c9417ad29b639c9b322591d3c Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Thu, 16 Mar 2023 16:59:56 +0000 Subject: [PATCH 28/47] Ensure attributes values from settings are correct. --- .../publish/validate_rendersettings.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 3b2bd1a84a..53f340cd2c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -13,6 +13,22 @@ from openpype.pipeline.publish import ( from openpype.hosts.maya.api import lib +def convert_to_int_or_float(string_value): + # Order of types are important here since float can convert string + # representation of integer. + types = [int, float] + for t in types: + try: + result = t(string_value) + except ValueError: + continue + else: + return result + + # Neither integer or float. + return string_value + + def get_redshift_image_format_labels(): """Return nice labels for Redshift image formats.""" var = "$g_redshiftImageFormatLabels" @@ -259,6 +275,15 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): # go through definitions and test if such node.attribute exists. # if so, compare its value from the one required. for attribute, data in cls.get_nodes(instance, renderer).items(): + # Validate the settings has values. + if not data["values"]: + cls.log.error( + "Settings for {}.{} is missing values.".format( + node, attribute + ) + ) + continue + for node in data["nodes"]: try: render_value = cmds.getAttr( @@ -270,7 +295,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): "Cannot get value of {}.{}".format(node, attribute) ) else: - if str(render_value) not in data["values"]: + if render_value not in data["values"]: invalid = True cls.log.error( "Invalid value {} set on {}.{}. Expecting " @@ -299,6 +324,8 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): ) continue + values = [convert_to_int_or_float(v) for v in values] + node_type, attribute_name = attr.split(".", 1) # first get node of that type @@ -326,6 +353,8 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): ) for attribute, data in cls.get_nodes(instance, renderer).items(): + if not data["values"]: + continue for node in data["nodes"]: lib.set_attribute(attribute, data["values"][0], node) From 917a1f4d0a20852614f32818ef13caff425c2cad Mon Sep 17 00:00:00 2001 From: Toke Stuart Jepsen Date: Fri, 17 Mar 2023 10:26:56 +0000 Subject: [PATCH 29/47] Update docs image --- .../maya-admin_render_settings_validator.png | Bin 11220 -> 11855 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/website/docs/assets/maya-admin_render_settings_validator.png b/website/docs/assets/maya-admin_render_settings_validator.png index 8687b538b124c6857534d9033a46c70deb875fde..8ee11f16f6fb275901e1b11243d5a39d38d7b02c 100644 GIT binary patch literal 11855 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 literal 11220 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 Date: Fri, 17 Mar 2023 15:58:59 +0000 Subject: [PATCH 30/47] Info on attribute collection --- website/docs/admin_hosts_maya.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md index 82109f0e0c..23cacb4193 100644 --- a/website/docs/admin_hosts_maya.md +++ b/website/docs/admin_hosts_maya.md @@ -50,6 +50,10 @@ just one instance of this node type but if that is not so, validator will go thr instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman** it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. +:::info getting attribute values +If you do not know what an attributes value is supposed to be, for example for dropdown menu (enum), try changing the attribute and look in the script editor where it should log what the attribute was set to. +::: + ### Model Name Validator `ValidateRenderSettings` From 739b2db0f099286acf7f0f194403652fd23f18b1 Mon Sep 17 00:00:00 2001 From: moonyuet Date: Tue, 14 Mar 2023 09:48:24 +0100 Subject: [PATCH 31/47] MaxScene Family introduction --- .../max/plugins/create/create_maxScene.py | 26 ++++++++++ .../hosts/max/plugins/load/load_max_scene.py | 3 +- .../plugins/publish/extract_max_scene_raw.py | 3 +- openpype/plugins/publish/integrate.py | 1 + openpype/plugins/publish/integrate_legacy.py | 1 + tools/build.ps1 | 49 +++++++++---------- 6 files changed, 56 insertions(+), 27 deletions(-) create mode 100644 openpype/hosts/max/plugins/create/create_maxScene.py diff --git a/openpype/hosts/max/plugins/create/create_maxScene.py b/openpype/hosts/max/plugins/create/create_maxScene.py new file mode 100644 index 0000000000..52da615be4 --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_maxScene.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating raw max scene.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import CreatedInstance + + +class CreateMaxScene(plugin.MaxCreator): + identifier = "io.openpype.creators.max.maxScene" + label = "Max Scene(Raw)" + family = "maxScene" + 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(CreateMaxScene, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + container = rt.getNodeByName(instance.data.get("instance_node")) + # 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")) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index b863b9363f..fa8b6b2894 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -9,7 +9,8 @@ from openpype.hosts.max.api import lib class MaxSceneLoader(load.LoaderPlugin): """Max Scene Loader""" - families = ["camera"] + families = ["camera", + "maxScene"] representations = ["max"] order = -8 icon = "code-fork" diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py index cacc84c591..4e567ab76e 100644 --- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -19,7 +19,8 @@ class ExtractMaxSceneRaw(publish.Extractor, order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Max Scene (Raw)" - hosts = ["max"] + hosts = ["max", + "maxScene"] families = ["camera"] optional = True diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index b117006871..69b7734c70 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -84,6 +84,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): "camera", "animation", "model", + "maxScene", "mayaAscii", "mayaScene", "setdress", diff --git a/openpype/plugins/publish/integrate_legacy.py b/openpype/plugins/publish/integrate_legacy.py index b93abab1d8..66c2c9be51 100644 --- a/openpype/plugins/publish/integrate_legacy.py +++ b/openpype/plugins/publish/integrate_legacy.py @@ -80,6 +80,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "camera", "animation", "model", + "maxScene", "mayaAscii", "mayaScene", "setdress", diff --git a/tools/build.ps1 b/tools/build.ps1 index 195b2dc75e..f60cb2d0c4 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -22,10 +22,10 @@ https://openpype.io/docs #> -$arguments=$ARGS -$disable_submodule_update="" -if($arguments -eq "--no-submodule-update") { - $disable_submodule_update=$true +$arguments = $ARGS +$disable_submodule_update = "" +if ($arguments -eq "--no-submodule-update") { + $disable_submodule_update = $true } $current_dir = Get-Location @@ -45,13 +45,11 @@ function Start-Progress { # $origpos.Y -= 1 - while (($job.State -eq "Running") -and ($job.State -ne "NotStarted")) - { + while (($job.State -eq "Running") -and ($job.State -ne "NotStarted")) { $host.UI.RawUI.CursorPosition = $origpos Write-Host $scroll[$idx] -NoNewline $idx++ - if ($idx -ge $scroll.Length) - { + if ($idx -ge $scroll.Length) { $idx = 0 } Start-Sleep -Milliseconds 100 @@ -59,7 +57,7 @@ function Start-Progress { # It's over - clear the activity indicator. $host.UI.RawUI.CursorPosition = $origpos Write-Host ' ' - <# + <# .SYNOPSIS Display spinner for running job .PARAMETER code @@ -69,17 +67,17 @@ function Start-Progress { function Exit-WithCode($exitcode) { - # Only exit this host process if it's a child of another PowerShell parent process... - $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId - $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name - if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } + # Only exit this host process if it's a child of another PowerShell parent process... + $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId + $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name + if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } - exit $exitcode + exit $exitcode } function Show-PSWarning() { if ($PSVersionTable.PSVersion.Major -lt 7) { - Write-Color -Text "!!! ", "You are using old version of PowerShell - ", "$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" -Color Red, Yellow, White + Write-Color -Text "!!! ", "You are using old version of PowerShell - ", "$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" -Color Red, Yellow, White Write-Color -Text " Please update to at least 7.0 - ", "https://github.com/PowerShell/PowerShell/releases" -Color Yellow, White Exit-WithCode 1 } @@ -87,7 +85,7 @@ function Show-PSWarning() { function Install-Poetry() { Write-Color -Text ">>> ", "Installing Poetry ... " -Color Green, Gray - $env:POETRY_HOME="$openpype_root\.poetry" + $env:POETRY_HOME = "$openpype_root\.poetry" (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | python - } @@ -126,8 +124,8 @@ $version_file = Get-Content -Path "$($openpype_root)\openpype\version.py" $result = [regex]::Matches($version_file, '__version__ = "(?\d+\.\d+.\d+.*)"') $openpype_version = $result[0].Groups['version'].Value if (-not $openpype_version) { - Write-Color -Text "!!! ", "Cannot determine OpenPype version." -Color Yellow, Gray - Exit-WithCode 1 + Write-Color -Text "!!! ", "Cannot determine OpenPype version." -Color Yellow, Gray + Exit-WithCode 1 } # Create build directory if not exist @@ -147,7 +145,8 @@ catch { if (-not $disable_submodule_update) { Write-Color -Text ">>> ", "Making sure submodules are up-to-date ..." -Color Green, Gray & git submodule update --init --recursive -} else { +} +else { Write-Color -Text "*** ", "Not updating submodules ..." -Color Green, Gray } @@ -158,7 +157,8 @@ if (-not (Test-Path -PathType Container -Path "$($env:POETRY_HOME)\bin")) { Write-Color -Text "NOT FOUND" -Color Yellow Write-Color -Text "*** ", "We need to install Poetry create virtual env first ..." -Color Yellow, Gray & "$openpype_root\tools\create_env.ps1" -} else { +} +else { Write-Color -Text "OK" -Color Green } @@ -173,8 +173,7 @@ $startTime = [int][double]::Parse((Get-Date -UFormat %s)) $out = & "$($env:POETRY_HOME)\bin\poetry" run python setup.py build 2>&1 Set-Content -Path "$($openpype_root)\build\build.log" -Value $out -if ($LASTEXITCODE -ne 0) -{ +if ($LASTEXITCODE -ne 0) { Write-Color -Text "------------------------------------------" -Color Red Get-Content "$($openpype_root)\build\build.log" Write-Color -Text "------------------------------------------" -Color Yellow @@ -189,8 +188,8 @@ Write-Color -Text ">>> ", "Restoring current directory" -Color Green, Gray Set-Location -Path $current_dir $endTime = [int][double]::Parse((Get-Date -UFormat %s)) -try -{ +try { New-BurntToastNotification -AppLogo "$openpype_root/openpype/resources/icons/openpype_icon.png" -Text "OpenPype build complete!", "All done in $( $endTime - $startTime ) secs. You will find OpenPype and build log in build directory." -} catch {} +} +catch {} Write-Color -Text "*** ", "All done in ", $($endTime - $startTime), " secs. You will find OpenPype and build log in ", "'.\build'", " directory." -Color Green, Gray, White, Gray, White, Gray From 6a93cb199033dbd144f30c73571828b3657f88fb Mon Sep 17 00:00:00 2001 From: moonyuet Date: Tue, 14 Mar 2023 09:52:14 +0100 Subject: [PATCH 32/47] rename creator --- openpype/hosts/max/plugins/create/create_maxScene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/create/create_maxScene.py b/openpype/hosts/max/plugins/create/create_maxScene.py index 52da615be4..7900336f32 100644 --- a/openpype/hosts/max/plugins/create/create_maxScene.py +++ b/openpype/hosts/max/plugins/create/create_maxScene.py @@ -6,7 +6,7 @@ from openpype.pipeline import CreatedInstance class CreateMaxScene(plugin.MaxCreator): identifier = "io.openpype.creators.max.maxScene" - label = "Max Scene(Raw)" + label = "Max Scene" family = "maxScene" icon = "gear" From 5895b4f376714dce66f5d8a40552cda71b92368c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 15 Mar 2023 21:42:15 +0800 Subject: [PATCH 33/47] putting maxScene into correct families --- .../plugins/publish/extract_max_scene_raw.py | 6 +-- tools/build.ps1 | 49 ++++++++++--------- 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py index 4e567ab76e..969f87be48 100644 --- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -19,9 +19,9 @@ class ExtractMaxSceneRaw(publish.Extractor, order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Max Scene (Raw)" - hosts = ["max", - "maxScene"] - families = ["camera"] + hosts = ["max"] + families = ["camera", + "maxScene"] optional = True def process(self, instance): diff --git a/tools/build.ps1 b/tools/build.ps1 index f60cb2d0c4..195b2dc75e 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -22,10 +22,10 @@ https://openpype.io/docs #> -$arguments = $ARGS -$disable_submodule_update = "" -if ($arguments -eq "--no-submodule-update") { - $disable_submodule_update = $true +$arguments=$ARGS +$disable_submodule_update="" +if($arguments -eq "--no-submodule-update") { + $disable_submodule_update=$true } $current_dir = Get-Location @@ -45,11 +45,13 @@ function Start-Progress { # $origpos.Y -= 1 - while (($job.State -eq "Running") -and ($job.State -ne "NotStarted")) { + while (($job.State -eq "Running") -and ($job.State -ne "NotStarted")) + { $host.UI.RawUI.CursorPosition = $origpos Write-Host $scroll[$idx] -NoNewline $idx++ - if ($idx -ge $scroll.Length) { + if ($idx -ge $scroll.Length) + { $idx = 0 } Start-Sleep -Milliseconds 100 @@ -57,7 +59,7 @@ function Start-Progress { # It's over - clear the activity indicator. $host.UI.RawUI.CursorPosition = $origpos Write-Host ' ' - <# + <# .SYNOPSIS Display spinner for running job .PARAMETER code @@ -67,17 +69,17 @@ function Start-Progress { function Exit-WithCode($exitcode) { - # Only exit this host process if it's a child of another PowerShell parent process... - $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId - $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name - if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } + # Only exit this host process if it's a child of another PowerShell parent process... + $parentPID = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$PID" | Select-Object -Property ParentProcessId).ParentProcessId + $parentProcName = (Get-CimInstance -ClassName Win32_Process -Filter "ProcessId=$parentPID" | Select-Object -Property Name).Name + if ('powershell.exe' -eq $parentProcName) { $host.SetShouldExit($exitcode) } - exit $exitcode + exit $exitcode } function Show-PSWarning() { if ($PSVersionTable.PSVersion.Major -lt 7) { - Write-Color -Text "!!! ", "You are using old version of PowerShell - ", "$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" -Color Red, Yellow, White + Write-Color -Text "!!! ", "You are using old version of PowerShell - ", "$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" -Color Red, Yellow, White Write-Color -Text " Please update to at least 7.0 - ", "https://github.com/PowerShell/PowerShell/releases" -Color Yellow, White Exit-WithCode 1 } @@ -85,7 +87,7 @@ function Show-PSWarning() { function Install-Poetry() { Write-Color -Text ">>> ", "Installing Poetry ... " -Color Green, Gray - $env:POETRY_HOME = "$openpype_root\.poetry" + $env:POETRY_HOME="$openpype_root\.poetry" (Invoke-WebRequest -Uri https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py -UseBasicParsing).Content | python - } @@ -124,8 +126,8 @@ $version_file = Get-Content -Path "$($openpype_root)\openpype\version.py" $result = [regex]::Matches($version_file, '__version__ = "(?\d+\.\d+.\d+.*)"') $openpype_version = $result[0].Groups['version'].Value if (-not $openpype_version) { - Write-Color -Text "!!! ", "Cannot determine OpenPype version." -Color Yellow, Gray - Exit-WithCode 1 + Write-Color -Text "!!! ", "Cannot determine OpenPype version." -Color Yellow, Gray + Exit-WithCode 1 } # Create build directory if not exist @@ -145,8 +147,7 @@ catch { if (-not $disable_submodule_update) { Write-Color -Text ">>> ", "Making sure submodules are up-to-date ..." -Color Green, Gray & git submodule update --init --recursive -} -else { +} else { Write-Color -Text "*** ", "Not updating submodules ..." -Color Green, Gray } @@ -157,8 +158,7 @@ if (-not (Test-Path -PathType Container -Path "$($env:POETRY_HOME)\bin")) { Write-Color -Text "NOT FOUND" -Color Yellow Write-Color -Text "*** ", "We need to install Poetry create virtual env first ..." -Color Yellow, Gray & "$openpype_root\tools\create_env.ps1" -} -else { +} else { Write-Color -Text "OK" -Color Green } @@ -173,7 +173,8 @@ $startTime = [int][double]::Parse((Get-Date -UFormat %s)) $out = & "$($env:POETRY_HOME)\bin\poetry" run python setup.py build 2>&1 Set-Content -Path "$($openpype_root)\build\build.log" -Value $out -if ($LASTEXITCODE -ne 0) { +if ($LASTEXITCODE -ne 0) +{ Write-Color -Text "------------------------------------------" -Color Red Get-Content "$($openpype_root)\build\build.log" Write-Color -Text "------------------------------------------" -Color Yellow @@ -188,8 +189,8 @@ Write-Color -Text ">>> ", "Restoring current directory" -Color Green, Gray Set-Location -Path $current_dir $endTime = [int][double]::Parse((Get-Date -UFormat %s)) -try { +try +{ New-BurntToastNotification -AppLogo "$openpype_root/openpype/resources/icons/openpype_icon.png" -Text "OpenPype build complete!", "All done in $( $endTime - $startTime ) secs. You will find OpenPype and build log in build directory." -} -catch {} +} catch {} Write-Color -Text "*** ", "All done in ", $($endTime - $startTime), " secs. You will find OpenPype and build log in ", "'.\build'", " directory." -Color Green, Gray, White, Gray, White, Gray From b2be8462c4ce95b17084e96f949377612681b8a1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 15 Mar 2023 23:32:54 +0800 Subject: [PATCH 34/47] updating the loader so that the version will work in scene inventory --- .../hosts/max/plugins/load/load_max_scene.py | 3 +- .../publish/validate_no_max_content.py | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/validate_no_max_content.py diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index fa8b6b2894..460f4822a6 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -47,8 +47,7 @@ class MaxSceneLoader(load.LoaderPlugin): path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) - - max_objects = self.get_container_children(node) + max_objects = node.Children for max_object in max_objects: max_object.source = path diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py new file mode 100644 index 0000000000..0cf3b53044 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt + + +class ValidateMaxContents(pyblish.api.InstancePlugin): + """Validates Max contents. + + Check if MaxScene container includes any contents underneath. + """ + + order = pyblish.api.ValidatorOrder + families = ["maxScene"] + hosts = ["max"] + label = "Max Scene Contents" + + def process(self, instance): + invalid = self.get_invalid(instance) + if invalid: + raise PublishValidationError("No content found in the container") + + def get_invalid(self, instance): + invalid = [] + container = rt.getNodeByName(instance.data["instance_node"]) + if not container.Children: + invalid.append(container) + + return invalid From e35f7e0bb5d805b80f46aa9fdb1e40a18667fa2a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 16 Mar 2023 00:06:29 +0800 Subject: [PATCH 35/47] update the validator which errors out with the empty container --- .../plugins/publish/validate_no_max_content.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_no_max_content.py b/openpype/hosts/max/plugins/publish/validate_no_max_content.py index 0cf3b53044..c20a1968ed 100644 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -11,19 +11,13 @@ class ValidateMaxContents(pyblish.api.InstancePlugin): """ order = pyblish.api.ValidatorOrder - families = ["maxScene"] + families = ["camera", + "maxScene", + "maxrender"] hosts = ["max"] label = "Max Scene Contents" def process(self, instance): - invalid = self.get_invalid(instance) - if invalid: - raise PublishValidationError("No content found in the container") - - def get_invalid(self, instance): - invalid = [] container = rt.getNodeByName(instance.data["instance_node"]) - if not container.Children: - invalid.append(container) - - return invalid + if not list(container.Children): + raise PublishValidationError("No content found in the container") From 99ed91d0adb162a61005ce689d485d6f25619808 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 16 Mar 2023 14:03:25 +0800 Subject: [PATCH 36/47] fix the bug of submitting the frames for published job --- openpype/hosts/max/api/lib_renderproducts.py | 48 ++++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/max/api/lib_renderproducts.py b/openpype/hosts/max/api/lib_renderproducts.py index a74a6a7426..350eb97661 100644 --- a/openpype/hosts/max/api/lib_renderproducts.py +++ b/openpype/hosts/max/api/lib_renderproducts.py @@ -8,6 +8,7 @@ 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 @@ -34,14 +35,20 @@ class RenderProducts(object): filename, 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"] # noqa - full_render_list = [] - beauty = self.beauty_render_product(output_file, img_fmt) - full_render_list.append(beauty) + 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 @@ -54,6 +61,8 @@ class RenderProducts(object): "Quicksilver_Hardware_Renderer", ]: render_elem_list = self.render_elements_product(output_file, + startFrame, + endFrame, img_fmt) if render_elem_list: full_render_list.extend(iter(render_elem_list)) @@ -61,18 +70,24 @@ class RenderProducts(object): if renderer == "Arnold": aov_list = self.arnold_render_product(output_file, + startFrame, + endFrame, img_fmt) if aov_list: full_render_list.extend(iter(aov_list)) return full_render_list - def beauty_render_product(self, folder, fmt): - beauty_output = f"{folder}.####.{fmt}" - beauty_output = beauty_output.replace("\\", "/") - return beauty_output + def beauty_render_product(self, folder, startFrame, endFrame, fmt): + beauty_frame_range = [] + for f in range(startFrame, endFrame): + beauty_output = f"{folder}.{f}.{fmt}" + beauty_output = beauty_output.replace("\\", "/") + beauty_frame_range.append(beauty_output) + + return beauty_frame_range # TODO: Get the arnold render product - def arnold_render_product(self, folder, fmt): + def arnold_render_product(self, folder, startFrame, endFrame, fmt): """Get all the Arnold AOVs""" aovs = [] @@ -85,15 +100,17 @@ class RenderProducts(object): for i in range(aov_group_num): # get the specific AOV group for aov in aov_mgr.drivers[i].aov_list: - render_element = f"{folder}_{aov.name}.####.{fmt}" - render_element = render_element.replace("\\", "/") - aovs.append(render_element) + for f in range(startFrame, endFrame): + render_element = f"{folder}_{aov.name}.{f}.{fmt}" + render_element = render_element.replace("\\", "/") + aovs.append(render_element) + # close the AOVs manager window amw.close() return aovs - def render_elements_product(self, folder, fmt): + def render_elements_product(self, folder, startFrame, endFrame, fmt): """Get all the render element output files. """ render_dirname = [] @@ -104,9 +121,10 @@ class RenderProducts(object): renderlayer_name = render_elem.GetRenderElement(i) target, renderpass = str(renderlayer_name).split(":") if renderlayer_name.enabled: - render_element = f"{folder}_{renderpass}.####.{fmt}" - render_element = render_element.replace("\\", "/") - render_dirname.append(render_element) + for f in range(startFrame, endFrame): + render_element = f"{folder}_{renderpass}.{f}.{fmt}" + render_element = render_element.replace("\\", "/") + render_dirname.append(render_element) return render_dirname From fa69594c7079b8b482f649cf62f49a240da3be4a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 27 Feb 2023 20:09:03 +0800 Subject: [PATCH 37/47] creator, validator and extractor for point cloud from tyFlow --- .../max/plugins/create/create_pointcloud.py | 26 +++ .../max/plugins/publish/extract_pointcloud.py | 170 ++++++++++++++++++ .../plugins/publish/validate_pointcloud.py | 74 ++++++++ openpype/plugins/publish/integrate.py | 1 + openpype/plugins/publish/integrate_legacy.py | 1 + .../defaults/project_settings/max.json | 15 ++ .../projects_schema/schema_project_max.json | 24 ++- 7 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/max/plugins/create/create_pointcloud.py create mode 100644 openpype/hosts/max/plugins/publish/extract_pointcloud.py create mode 100644 openpype/hosts/max/plugins/publish/validate_pointcloud.py diff --git a/openpype/hosts/max/plugins/create/create_pointcloud.py b/openpype/hosts/max/plugins/create/create_pointcloud.py new file mode 100644 index 0000000000..c83acac3df --- /dev/null +++ b/openpype/hosts/max/plugins/create/create_pointcloud.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""Creator plugin for creating point cloud.""" +from openpype.hosts.max.api import plugin +from openpype.pipeline import CreatedInstance + + +class CreatePointCloud(plugin.MaxCreator): + identifier = "io.openpype.creators.max.pointcloud" + label = "Point Cloud" + family = "pointcloud" + 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(CreatePointCloud, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + container = rt.getNodeByName(instance.data.get("instance_node")) + # 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")) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py new file mode 100644 index 0000000000..9c471bc09e --- /dev/null +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -0,0 +1,170 @@ +import os +import pyblish.api +from openpype.pipeline import publish +from pymxs import runtime as rt +from openpype.hosts.max.api import ( + maintained_selection +) +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io + + +def get_setting(project_setting=None): + project_setting = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + return (project_setting["max"]["PointCloud"]) + + +class ExtractPointCloud(publish.Extractor): + """ + Extract PTF format with tyFlow operators + """ + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract Point Cloud" + hosts = ["max"] + families = ["pointcloud"] + partition_start = 1 + partition_count = 100 + + + def process(self, instance): + start = str(instance.data.get("frameStartHandle", 1)) + end = str(instance.data.get("frameEndHandle", 1)) + container = instance.data["instance_node"] + + self.log.info("Extracting PRT...") + + stagingdir = self.staging_dir(instance) + filename = "{name}.prt".format(**instance.data) + path = os.path.join(stagingdir, filename) + + with maintained_selection(): + job_args = self.export_particle(container, + start, + end, + path) + for job in job_args: + rt.execute(job) + + self.log.info("Performing Extraction ...") + if "representations" not in instance.data: + instance.data["representations"] = [] + + self.log.info("Writing PRT with TyFlow Plugin...") + filenames = self.get_files(path, start, end) + self.log.info("filename: {0}".format(filenames)) + representation = { + 'name': 'prt', + 'ext': 'prt', + 'files': filenames if len(filenames) > 1 else filenames[0], + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + self.log.info("Extracted instance '%s' to: %s" % (instance.name, + path)) + + def export_particle(self, + container, + start, + end, + filepath): + job_args = [] + opt_list = self.get_operators(container) + for operator in opt_list: + export_mode = "{0}.exportMode=2".format(operator) + job_args.append(export_mode) + start_frame = "{0}.frameStart={1}".format(operator, + start) + job_args.append(start_frame) + end_frame = "{0}.frameEnd={1}".format(operator, + end) + job_args.append(end_frame) + filepath = filepath.replace("\\", "/") + prt_filename = '{0}.PRTFilename="{1}"'.format(operator, + filepath) + + job_args.append(prt_filename) + # Partition + mode = "{0}.PRTPartitionsMode=2".format(operator) + job_args.append(mode) + + additional_args = self.get_custom_attr(operator) + for args in additional_args: + job_args.append(args) + + prt_export = "{0}.exportPRT()".format(operator) + job_args.append(prt_export) + + return job_args + + def get_operators(self, container): + """Get Export Particles Operator""" + + opt_list = [] + node = rt.getNodebyName(container) + selection_list = list(node.Children) + for sel in selection_list: + obj = sel.baseobject + # TODO: to see if it can be used maxscript instead + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + sub_anim = rt.getsubanim(obj, anim_name) + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + opt_list.append(opt) + + return opt_list + + def get_custom_attr(self, operator): + """Get Custom Attributes""" + + custom_attr_list = [] + attr_settings = get_setting()["attribute"] + for key, value in attr_settings.items(): + custom_attr = "{0}.PRTChannels_{1}=True".format(operator, + value) + self.log.debug( + "{0} will be added as custom attribute".format(key) + ) + custom_attr_list.append(custom_attr) + + return custom_attr_list + + def get_files(self, + path, + start_frame, + end_frame): + """ + Note: + Set the filenames accordingly to the tyFlow file + naming extension for the publishing purpose + + Actual File Output from tyFlow: + __partof..prt + e.g. tyFlow_cloth_CCCS_blobbyFill_001__part1of1_00004.prt + Renamed Output: + ..prt + e.g. pointcloudMain.0001.prt + """ + filenames = [] + filename = os.path.basename(path) + orig_name, ext = os.path.splitext(filename) + partition_start = str(self.partition_start) + partition_count = str(self.partition_count) + for frame in range(int(start_frame), int(end_frame) + 1): + actual_name = "{}__part{:03}of{}_{:05}".format(orig_name, + partition_start, + partition_count, + frame) + actual_filename = path.replace(orig_name, actual_name) + new_name = "{}.{:04}".format(orig_name, frame) + renamed_filename = path.replace(orig_name, new_name) + os.rename(actual_filename, renamed_filename) + filenames.append(os.path.basename(renamed_filename)) + + return filenames diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py new file mode 100644 index 0000000000..c6725d6126 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -0,0 +1,74 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError +from pymxs import runtime as rt + + +class ValidatePointCloud(pyblish.api.InstancePlugin): + """Validate that workfile was saved.""" + + order = pyblish.api.ValidatorOrder + families = ["pointcloud"] + hosts = ["max"] + label = "Validate Point Cloud" + + def process(self, instance): + """ + Notes: + + 1. Validate the container only include tyFlow objects + 2. Validate if tyFlow operator Export Particle exists + + """ + invalid = self.get_tyFlow_object(instance) + if invalid: + raise PublishValidationError("Non tyFlow object " + "found: {}".format(invalid)) + invalid = self.get_tyFlow_operator(instance) + if invalid: + raise PublishValidationError("tyFlow ExportParticle operator " + "not found: {}".format(invalid)) + + def get_tyFlow_object(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow container " + "for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + sel_tmp = str(sel) + if rt.classOf(sel) in [rt.tyFlow, + rt.Editable_Mesh]: + if "tyFlow" not in sel_tmp: + invalid.append(sel) + else: + invalid.append(sel) + + return invalid + + def get_tyFlow_operator(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow object " + "for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + bool_list = [] + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + bool_list.append(str(boolean)) + # if the export_particles property is not there + # it means there is not a "Export Particle" operator + if "True" not in bool_list: + self.log.error("Operator 'Export Particles' not found!") + invalid.append(sel) + + return invalid diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 69b7734c70..6a0327ec84 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -80,6 +80,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder families = ["workfile", "pointcache", + "pointcloud", "proxyAbc", "camera", "animation", diff --git a/openpype/plugins/publish/integrate_legacy.py b/openpype/plugins/publish/integrate_legacy.py index 66c2c9be51..1d0177f151 100644 --- a/openpype/plugins/publish/integrate_legacy.py +++ b/openpype/plugins/publish/integrate_legacy.py @@ -76,6 +76,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder + 0.00001 families = ["workfile", "pointcache", + "pointcloud", "proxyAbc", "camera", "animation", diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index 667b42411d..d59cdf8c4a 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -4,5 +4,20 @@ "aov_separator": "underscore", "image_format": "exr", "multipass": true + }, + "PointCloud":{ + "attribute":{ + "Age": "age", + "Radius": "radius", + "Position": "position", + "Rotation": "rotation", + "Scale": "scale", + "Velocity": "velocity", + "Color": "color", + "TextureCoordinate": "texcoord", + "MaterialID": "matid", + "custFloats": "custFloats", + "custVecs": "custVecs" + } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json index 8a283c1acc..4fba9aff0a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json @@ -51,6 +51,28 @@ "label": "multipass" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "PointCloud", + "label": "Point Cloud", + "children": [ + { + "type": "label", + "label": "Define the channel attribute names before exporting as PRT" + }, + { + "type": "dict-modifiable", + "collapsible": true, + "key": "attribute", + "label": "Channel Attribute", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + } + ] } ] -} \ No newline at end of file +} From 7df1b215c7831c33de0c2ebf55fc8e7d39ec9fff Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 27 Feb 2023 20:18:19 +0800 Subject: [PATCH 38/47] hound fix --- openpype/hosts/max/plugins/publish/extract_pointcloud.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 9c471bc09e..1ef127b73b 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -28,7 +28,6 @@ class ExtractPointCloud(publish.Extractor): partition_start = 1 partition_count = 100 - def process(self, instance): start = str(instance.data.get("frameStartHandle", 1)) end = str(instance.data.get("frameEndHandle", 1)) From 399600769483ea7b3062ec02fd4bf70680caa0ec Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 27 Feb 2023 22:05:11 +0800 Subject: [PATCH 39/47] update validators --- .../max/plugins/publish/extract_pointcloud.py | 6 +- .../plugins/publish/validate_pointcloud.py | 121 +++++++++++++++++- 2 files changed, 121 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 1ef127b73b..4436c06643 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -72,8 +72,6 @@ class ExtractPointCloud(publish.Extractor): job_args = [] opt_list = self.get_operators(container) for operator in opt_list: - export_mode = "{0}.exportMode=2".format(operator) - job_args.append(export_mode) start_frame = "{0}.frameStart={1}".format(operator, start) job_args.append(start_frame) @@ -153,8 +151,8 @@ class ExtractPointCloud(publish.Extractor): filenames = [] filename = os.path.basename(path) orig_name, ext = os.path.splitext(filename) - partition_start = str(self.partition_start) - partition_count = str(self.partition_count) + partition_start = self.partition_start + partition_count = self.partition_count for frame in range(int(start_frame), int(end_frame) + 1): actual_name = "{}__part{:03}of{}_{:05}".format(orig_name, partition_start, diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index c6725d6126..ba9f2834a6 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -1,6 +1,15 @@ import pyblish.api from openpype.pipeline import PublishValidationError from pymxs import runtime as rt +from openpype.settings import get_project_settings +from openpype.pipeline import legacy_io + + +def get_setting(project_setting=None): + project_setting = get_project_settings( + legacy_io.Session["AVALON_PROJECT"] + ) + return (project_setting["max"]["PointCloud"]) class ValidatePointCloud(pyblish.api.InstancePlugin): @@ -15,8 +24,14 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): """ Notes: - 1. Validate the container only include tyFlow objects - 2. Validate if tyFlow operator Export Particle exists + 1. Validate the container only include tyFlow objects + 2. Validate if tyFlow operator Export Particle exists + 3. Validate if the export mode of Export Particle is at PRT format + 4. Validate the partition count and range set as default value + Partition Count : 100 + Partition Range : 1 to 1 + 5. Validate if the custom attribute(s) exist as parameter(s) + of export_particle operator """ invalid = self.get_tyFlow_object(instance) @@ -28,6 +43,19 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): raise PublishValidationError("tyFlow ExportParticle operator " "not found: {}".format(invalid)) + invalid = self.validate_export_mode(instance) + if invalid: + raise PublishValidationError("The export mode is not at PRT") + + invalid = self.validate_partition_value(instance) + if invalid: + raise PublishValidationError("tyFlow Partition setting is " + "not at the default value") + invalid = self.validate_custom_attribute(instance) + if invalid: + raise PublishValidationError("Custom Attribute not found " + ":{}".format(invalid)) + def get_tyFlow_object(self, instance): invalid = [] container = instance.data["instance_node"] @@ -72,3 +100,92 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): invalid.append(sel) return invalid + + def validate_custom_attribute(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow custom " + "attributes for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + attributes = get_setting()["attribute"] + for key, value in attributes.items(): + custom_attr = "{0}.PRTChannels_{1}".format(opt, + value) + try: + rt.execute(custom_attr) + except RuntimeError: + invalid.add(key) + + return invalid + + def validate_partition_value(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow partition " + "value for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + count = rt.execute(f'{opt}.PRTPartitionsCount') + if count != 100: + invalid.append(count) + start = rt.execute(f'{opt}.PRTPartitionsFrom') + if start != 1: + invalid.append(start) + end = rt.execute(f'{opt}.PRTPartitionsTo') + if end != 1: + invalid.append(end) + + return invalid + + def validate_export_mode(self, instance): + invalid = [] + container = instance.data["instance_node"] + self.log.info("Validating tyFlow partition " + "value for {}".format(container)) + + con = rt.getNodeByName(container) + selection_list = list(con.Children) + for sel in selection_list: + obj = sel.baseobject + anim_names = rt.getsubanimnames(obj) + for anim_name in anim_names: + # get all the names of the related tyFlow nodes + sub_anim = rt.getsubanim(obj, anim_name) + # check if there is export particle operator + boolean = rt.isProperty(sub_anim, "Export_Particles") + event_name = sub_anim.name + if boolean: + opt = "${0}.{1}.export_particles".format(sel.name, + event_name) + export_mode = rt.execute(f'{opt}.exportMode') + if export_mode != 2: + invalid.append(export_mode) + + return invalid From 29138974b49da0d03eaf2bbbcedd3243d6420bfe Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Mon, 27 Feb 2023 22:24:25 +0800 Subject: [PATCH 40/47] update validators --- openpype/hosts/max/plugins/publish/validate_pointcloud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index ba9f2834a6..34310eac7a 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -167,8 +167,8 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): def validate_export_mode(self, instance): invalid = [] container = instance.data["instance_node"] - self.log.info("Validating tyFlow partition " - "value for {}".format(container)) + self.log.info("Validating tyFlow export " + "mode for {}".format(container)) con = rt.getNodeByName(container) selection_list = list(con.Children) From a4c36b9e4f1a52107010515aa28358e174658833 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 28 Feb 2023 18:14:25 +0800 Subject: [PATCH 41/47] add loaders --- .../hosts/max/plugins/load/load_pointcloud.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 openpype/hosts/max/plugins/load/load_pointcloud.py diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py new file mode 100644 index 0000000000..dfa63e23f8 --- /dev/null +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -0,0 +1,51 @@ +import os +from openpype.pipeline import ( + load, get_representation_path +) +from openpype.hosts.max.api.pipeline import containerise +from openpype.hosts.max.api import lib + + +class PointCloudLoader(load.LoaderPlugin): + """Point Cloud Loader""" + + families = ["pointcloud"] + representations = ["prt"] + order = -8 + icon = "code-fork" + color = "green" + + def load(self, context, name=None, namespace=None, data=None): + """load point cloud by tyCache""" + + from pymxs import runtime as rt + filepath = os.path.normpath(self.fname) + obj = rt.tyCache() + obj.filename = filepath + + prt_container = rt.getNodeByName(f"{obj.name}") + + return containerise( + name, [prt_container], context, loader=self.__class__.__name__) + + def update(self, container, representation): + """update the container""" + + from pymxs import runtime as rt + path = get_representation_path(representation) + node = rt.getNodeByName(container["instance_node"]) + + prt_objects = self.get_container_children(node) + for prt_object in prt_objects: + prt_object.source = path + + lib.imprint(container["instance_node"], { + "representation": str(representation["_id"]) + }) + + def remove(self, container): + """remove the container""" + from pymxs import runtime as rt + + node = rt.getNodeByName(container["instance_node"]) + rt.delete(node) From f95f8f6ebe4a1d32a97ca3daa14d061a31befce9 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Tue, 28 Feb 2023 18:31:53 +0800 Subject: [PATCH 42/47] add loader --- openpype/hosts/max/plugins/load/load_pointcloud.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index dfa63e23f8..27bc88b4f3 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -17,8 +17,8 @@ class PointCloudLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): """load point cloud by tyCache""" - from pymxs import runtime as rt + filepath = os.path.normpath(self.fname) obj = rt.tyCache() obj.filename = filepath @@ -30,8 +30,8 @@ class PointCloudLoader(load.LoaderPlugin): def update(self, container, representation): """update the container""" - from pymxs import runtime as rt + path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) From 416b7d9b942fc34901fe03fedcfe4fb3bcd3b730 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 1 Mar 2023 15:37:30 +0800 Subject: [PATCH 43/47] add partition naming format into the extractor --- .../max/plugins/publish/extract_pointcloud.py | 68 +++++++++++++++---- 1 file changed, 54 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 4436c06643..db83bf71fc 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -18,15 +18,31 @@ def get_setting(project_setting=None): class ExtractPointCloud(publish.Extractor): """ - Extract PTF format with tyFlow operators + Extract PRT format with tyFlow operators + + Notes: + Currently only works for the default partition setting + + Args: + export_particle(): sets up all job arguments for attributes + to be exported in MAXscript + + get_operators(): get the export_particle operator + + get_custom_attr(): get all custom channel attributes from the Openpype + setting and sets it as job arguments before exporting + + get_files(): get the files with tyFlow naming convention before publishing + + partition_output_name(): get the naming with partition settings. + get_partition(): get partition value + """ order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Point Cloud" hosts = ["max"] families = ["pointcloud"] - partition_start = 1 - partition_count = 100 def process(self, instance): start = str(instance.data.get("frameStartHandle", 1)) @@ -52,13 +68,17 @@ class ExtractPointCloud(publish.Extractor): instance.data["representations"] = [] self.log.info("Writing PRT with TyFlow Plugin...") - filenames = self.get_files(path, start, end) - self.log.info("filename: {0}".format(filenames)) + filenames = self.get_files(container, path, start, end) + self.log.debug("filenames: {0}".format(filenames)) + + partition = self.partition_output_name(container) + representation = { 'name': 'prt', 'ext': 'prt', 'files': filenames if len(filenames) > 1 else filenames[0], "stagingDir": stagingdir, + "outputName": partition # partition value } instance.data["representations"].append(representation) self.log.info("Extracted instance '%s' to: %s" % (instance.name, @@ -133,6 +153,7 @@ class ExtractPointCloud(publish.Extractor): return custom_attr_list def get_files(self, + container, path, start_frame, end_frame): @@ -144,24 +165,43 @@ class ExtractPointCloud(publish.Extractor): Actual File Output from tyFlow: __partof..prt e.g. tyFlow_cloth_CCCS_blobbyFill_001__part1of1_00004.prt - Renamed Output: - ..prt - e.g. pointcloudMain.0001.prt """ filenames = [] filename = os.path.basename(path) orig_name, ext = os.path.splitext(filename) - partition_start = self.partition_start - partition_count = self.partition_count + partition_count, partition_start = self.get_partition(container) for frame in range(int(start_frame), int(end_frame) + 1): actual_name = "{}__part{:03}of{}_{:05}".format(orig_name, partition_start, partition_count, frame) actual_filename = path.replace(orig_name, actual_name) - new_name = "{}.{:04}".format(orig_name, frame) - renamed_filename = path.replace(orig_name, new_name) - os.rename(actual_filename, renamed_filename) - filenames.append(os.path.basename(renamed_filename)) + filenames.append(os.path.basename(actual_filename)) return filenames + + def partition_output_name(self, container): + """ + Notes: + Partition output name set for mapping + the published file output + + todo: + Customizes the setting for the output + """ + partition_count, partition_start = self.get_partition(container) + partition = "_part{:03}of{}".format(partition_start, + partition_count) + + return partition + + def get_partition(self, container): + """ + Get Partition Value + """ + opt_list = self.get_operators(container) + for operator in opt_list: + count = rt.execute(f'{operator}.PRTPartitionsCount') + start = rt.execute(f'{operator}.PRTPartitionsFrom') + + return count, start From b9ab6f634e8219c0af436895f9cc7d38f56ebca2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Wed, 1 Mar 2023 15:39:25 +0800 Subject: [PATCH 44/47] hound fix --- openpype/hosts/max/plugins/publish/extract_pointcloud.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index db83bf71fc..0e4f4621ea 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -29,10 +29,11 @@ class ExtractPointCloud(publish.Extractor): get_operators(): get the export_particle operator - get_custom_attr(): get all custom channel attributes from the Openpype + get_custom_attr(): get all custom channel attributes from Openpype setting and sets it as job arguments before exporting - get_files(): get the files with tyFlow naming convention before publishing + get_files(): get the files with tyFlow naming convention + before publishing partition_output_name(): get the naming with partition settings. get_partition(): get partition value @@ -78,7 +79,7 @@ class ExtractPointCloud(publish.Extractor): 'ext': 'prt', 'files': filenames if len(filenames) > 1 else filenames[0], "stagingDir": stagingdir, - "outputName": partition # partition value + "outputName": partition # partition value } instance.data["representations"].append(representation) self.log.info("Extracted instance '%s' to: %s" % (instance.name, From b1f3e114058276b65e7c21da477642422f9c49b3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Mar 2023 00:05:54 +0800 Subject: [PATCH 45/47] fix the validator check on exportMode --- openpype/hosts/max/plugins/publish/validate_pointcloud.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index 34310eac7a..f654058648 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -185,7 +185,7 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): opt = "${0}.{1}.export_particles".format(sel.name, event_name) export_mode = rt.execute(f'{opt}.exportMode') - if export_mode != 2: + if export_mode != 1: invalid.append(export_mode) return invalid From 341ef34e353a52644a5bd92a02992adc3c0830d5 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Thu, 2 Mar 2023 23:18:33 +0800 Subject: [PATCH 46/47] update frame range --- openpype/hosts/max/plugins/publish/extract_pointcloud.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 0e4f4621ea..e8d58ab713 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -46,10 +46,9 @@ class ExtractPointCloud(publish.Extractor): families = ["pointcloud"] def process(self, instance): - start = str(instance.data.get("frameStartHandle", 1)) - end = str(instance.data.get("frameEndHandle", 1)) + start = int(instance.context.data.get("frameStart")) + end = int(instance.context.data.get("frameEnd")) container = instance.data["instance_node"] - self.log.info("Extracting PRT...") stagingdir = self.staging_dir(instance) From f537020b97bfbf6584cfac373ee5dc264509d7e1 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 18 Mar 2023 03:26:38 +0000 Subject: [PATCH 47/47] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 39a7dc9344..339c17dc70 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.3-nightly.1" +__version__ = "3.15.3-nightly.2"