diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index aa5b8decdc..203ac1df23 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,13 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.15.11-nightly.4 + - 3.15.11-nightly.3 + - 3.15.11-nightly.2 + - 3.15.11-nightly.1 + - 3.15.10 + - 3.15.10-nightly.2 + - 3.15.10-nightly.1 - 3.15.9 - 3.15.9-nightly.2 - 3.15.9-nightly.1 @@ -128,13 +135,6 @@ body: - 3.14.3 - 3.14.3-nightly.7 - 3.14.3-nightly.6 - - 3.14.3-nightly.5 - - 3.14.3-nightly.4 - - 3.14.3-nightly.3 - - 3.14.3-nightly.2 - - 3.14.3-nightly.1 - - 3.14.2 - - 3.14.2-nightly.5 validations: required: true - type: dropdown diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6544e659..882620f26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,399 @@ # Changelog +## [3.15.10](https://github.com/ynput/OpenPype/tree/3.15.10) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.9...3.15.10) + +### **🆕 New features** + + +
+ImageIO: Adding ImageIO activation toggle to all hosts #4700 + +Colorspace management can now be enabled at the project level, although it is disabled by default. Once enabled, all hosts will use the OCIO config file defined in the settings. If settings are disabled, the system switches to DCC's native color space management, and we do not store colorspace information at the representative level. + + +___ + +
+ + +
+Redshift Proxy Support in 3dsMax #4625 + +Redshift Proxy Support for 3dsMax. +- [x] Creator +- [x] Loader +- [x] Extractor +- [x] Validator +- [x] Add documentation + + +___ + +
+ + +
+Houdini farm publishing and rendering #4825 + +Deadline Farm publishing and Rendering for Houdini +- [x] Mantra +- [x] Karma(including usd renders) +- [x] Arnold +- [x] Elaborate Redshift ROP for deadline submission +- [x] fix the existing bug in Redshift ROP +- [x] Vray +- [x] add docs + + +___ + +
+ + +
+Feature: Blender hook to execute python scripts at launch #4905 + +Hook to allow hooks to add path to a python script that will be executed when Blender starts. + + +___ + +
+ + +
+Feature: Resolve: Open last workfile on launch through .scriptlib #5047 + +Added implementation to Resolve integration to open last workfile on launch. + + +___ + +
+ + +
+General: Remove default windowFlags from publisher #5089 + +The default windowFlags is making the publisher window (in Linux at least) only show the close button and it's frustrating as many times you just want to minimize the window and get back to the validation after. Removing that line I get what I'd expect.**Before:****After:** + + +___ + +
+ + +
+General: Show user who created the workfile on the details pane of workfile manager #5093 + +New PR for https://github.com/ynput/OpenPype/pull/5087, which was closed after merging `next-minor` branch and then realizing we don't need to target it as it was decided it's not required to support windows. More info on that PR discussion.Small addition to add name of the `user` who created the workfile on the details pane of the workfile manager: + + +___ + +
+ + +
+Loader: Hide inactive versions in UI #5100 + +Hide versions with `active` set to `False` in Loader UI. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Maya: Repair RenderPass token when merging AOVs. #5055 + +Validator was flagging that `` was in the image prefix, but did not repair the issue. + + +___ + +
+ + +
+Maya: Improve error feedback when no renderable cameras exist for ASS family. #5092 + +When collecting cameras for `ass` family, this improves the error message when no cameras are renderable. + + +___ + +
+ + +
+Nuke: Custom script to set frame range of read nodes #5039 + +Adding option to set frame range specifically for the read nodes in Openpype Panel. User can set up their preferred frame range with the frame range dialog, which can be showed after clicking `Set Frame Range (Read Node)` in Openpype Tools + + +___ + +
+ + +
+Update extract review letterbox docs #5074 + +Update Extract Review - Letter Box section in Docs. Letterbox type description is removed. + + +___ + +
+ + +
+Project pack: Documents only skips roots validation #5082 + +Single roots validation is skipped if only documents are extracted. + + +___ + +
+ + +
+Nuke: custom settings for write node without publish #5084 + +Set Render Output and other settings to write nodes for non-publish purposes. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Maya: Deadline servers #5052 + +Fix working with multiple Deadline servers in Maya. +- Pools (primary and secondary) attributes were not recreated correctly. +- Order of collector plugins were wrong, so collected data was not injected into render instances. +- Server attribute was not converted to string so comparing with settings was incorrect. +- Improve debug logging for where the webservice url is getting fetched from. + + +___ + +
+ + +
+Maya: Fix Load Reference. #5091 + +Fix bug introduced with https://github.com/ynput/OpenPype/pull/4751 where `cmds.ls` returns a list. + + +___ + +
+ + +
+3dsmax: Publishing Deadline jobs from RedShift #4960 + +Fix the bug of being uable to publish deadline jobs from RedshiftUse Current File instead of Published Scene for just Redshift. +- add save scene before rendering to ensure the scene is saved after the modification. +- add separated aov files option to allow users to choose to have aovs in render output +- add validator for render publish to aovid overriding the previous renders + + +___ + +
+ + +
+Houdini: Fix missing frame range for pointcache and camera exports #5026 + +Fix missing frame range for pointcache and camera exports on published version. + + +___ + +
+ + +
+Global: collect_frame_fix plugin fix and cleanup #5064 + +Previous implementation https://github.com/ynput/OpenPype/pull/5036 was broken this is fixing the issue where attribute is found in instance data although the settings were disabled for the plugin. + + +___ + +
+ + +
+Hiero: Fix apply settings Clip Load #5073 + +Changed `apply_settings` to classmethod which fixes the issue with settings. + + +___ + +
+ + +
+Resolve: Make sure scripts dir exists #5078 + +Make sure the scripts directory exists before looping over it's content. + + +___ + +
+ + +
+removing info knob from nuke creators #5083 + +- removing instance node if removed via publisher +- removing info knob since it is not needed any more (was there only for the transition phase) + + +___ + +
+ + +
+Tray: Fix restart arguments on update #5085 + +Fix arguments on restart. + + +___ + +
+ + +
+Maya: bug fix on repair action in Arnold Scene Source CBID Validator #5096 + +Fix the bug of not being able to use repair action in Arnold Scene Source CBID Validator + + +___ + +
+ + +
+Nuke: batch of small fixes #5103 + +- default settings for `imageio.requiredNodes` **CreateWriteImage** +- default settings for **LoadImage** representations +- **Create** and **Publish** menu items with `parent=main_window` (version > 14) + + +___ + +
+ + +
+Deadline: make prerender check safer #5104 + +Prerender wasn't correctly recognized and was replaced with just 'render' family.In Nuke it is correctly `prerender.farm` in families, which wasn't handled here. It resulted into using `render` in templates even if `render` and `prerender` templates were split. + + +___ + +
+ + +
+General: Sort launcher actions alphabetically #5106 + +The launcher actions weren't being sorted by its label but its name (which on the case of the apps it's the version number) and thus the order wasn't consistent and we kept getting a different order on every launch. From my debugging session, this was the result of what the `actions` variable held after the `filter_compatible_actions` function before these changes: +``` +(Pdb) for p in actions: print(p.order, p.name) +0 14-02 +0 14-02 +0 14-02 +0 14-02 +0 14-02 +0 19-5-493 +0 2023 +0 3-41 +0 6-01 +```This caused already a couple bugs from our artists thinking they had launched Nuke X and instead launched Nuke and telling us their Nuke was missing nodes**Before:****After:** + + +___ + +
+ + +
+TrayPublisher: Editorial video stream discovery #5120 + +Editorial create plugin in traypublisher does not expect that first stream in input is video. + + +___ + +
+ +### **🔀 Refactored code** + + +
+3dsmax: Move from deprecated interface #5117 + +`INewPublisher` interface is deprecated, this PR is changing the use to `IPublishHost` instead. + + +___ + +
+ +### **Merged pull requests** + + +
+add movalex as a contributor for code #5076 + +Adds @movalex as a contributor for code. + +This was requested by mkolar [in this comment](https://github.com/ynput/OpenPype/pull/4916#issuecomment-1571498425) + +[skip ci] +___ + +
+ + +
+3dsmax: refactor load plugins #5079 + + +___ + +
+ + + + ## [3.15.9](https://github.com/ynput/OpenPype/tree/3.15.9) diff --git a/openpype/client/entities.py b/openpype/client/entities.py index 8004dc3019..adbdd7a47c 100644 --- a/openpype/client/entities.py +++ b/openpype/client/entities.py @@ -855,12 +855,13 @@ def get_output_link_versions(project_name, version_id, fields=None): return conn.find(query_filter, _prepare_fields(fields)) -def get_last_versions(project_name, subset_ids, fields=None): +def get_last_versions(project_name, subset_ids, active=None, fields=None): """Latest versions for entered subset_ids. Args: project_name (str): Name of project where to look for queried entities. subset_ids (Iterable[Union[str, ObjectId]]): List of subset ids. + active (Optional[bool]): If True only active versions are returned. fields (Optional[Iterable[str]]): Fields that should be returned. All fields are returned if 'None' is passed. @@ -899,12 +900,21 @@ def get_last_versions(project_name, subset_ids, fields=None): if name_needed: group_item["name"] = {"$last": "$name"} + aggregate_filter = { + "type": "version", + "parent": {"$in": subset_ids} + } + if active is False: + aggregate_filter["data.active"] = active + elif active is True: + aggregate_filter["$or"] = [ + {"data.active": {"$exists": 0}}, + {"data.active": active}, + ] + aggregation_pipeline = [ # Find all versions of those subsets - {"$match": { - "type": "version", - "parent": {"$in": subset_ids} - }}, + {"$match": aggregate_filter}, # Sorting versions all together {"$sort": {"name": 1}}, # Group them by "parent", but only take the last diff --git a/openpype/client/operations.py b/openpype/client/operations.py index ef48f2a1c4..e8c9d28636 100644 --- a/openpype/client/operations.py +++ b/openpype/client/operations.py @@ -220,7 +220,6 @@ def new_representation_doc( "parent": version_id, "name": name, "data": data, - # Imprint shortcut to context for performance reasons. "context": context } @@ -708,7 +707,11 @@ class OperationsSession(object): return operation -def create_project(project_name, project_code, library_project=False): +def create_project( + project_name, + project_code, + library_project=False, +): """Create project using OpenPype settings. This project creation function is not validating project document on @@ -752,7 +755,7 @@ def create_project(project_name, project_code, library_project=False): "name": project_name, "data": { "code": project_code, - "library_project": library_project + "library_project": library_project, }, "schema": CURRENT_PROJECT_SCHEMA } diff --git a/openpype/hooks/pre_host_set_ocio.py b/openpype/hooks/pre_host_set_ocio.py deleted file mode 100644 index 3620d88db6..0000000000 --- a/openpype/hooks/pre_host_set_ocio.py +++ /dev/null @@ -1,37 +0,0 @@ -from openpype.lib import PreLaunchHook - -from openpype.pipeline.colorspace import get_imageio_config -from openpype.pipeline.template_data import get_template_data - - -class PreLaunchHostSetOCIO(PreLaunchHook): - """Set OCIO environment for the host""" - - order = 0 - app_groups = ["substancepainter"] - - def execute(self): - """Hook entry method.""" - - anatomy_data = get_template_data( - project_doc=self.data["project_doc"], - asset_doc=self.data["asset_doc"], - task_name=self.data["task_name"], - host_name=self.host_name, - system_settings=self.data["system_settings"] - ) - - ocio_config = get_imageio_config( - project_name=self.data["project_doc"]["name"], - host_name=self.host_name, - project_settings=self.data["project_settings"], - anatomy_data=anatomy_data, - anatomy=self.data["anatomy"] - ) - - if ocio_config: - ocio_path = ocio_config["path"] - self.log.info(f"Setting OCIO config path: {ocio_path}") - self.launch_context.env["OCIO"] = ocio_path - else: - self.log.debug("OCIO not set or enabled") diff --git a/openpype/hosts/fusion/hooks/pre_fusion_ocio_hook.py b/openpype/hooks/pre_ocio_hook.py similarity index 52% rename from openpype/hosts/fusion/hooks/pre_fusion_ocio_hook.py rename to openpype/hooks/pre_ocio_hook.py index 6bf0f55081..8f462665bc 100644 --- a/openpype/hosts/fusion/hooks/pre_fusion_ocio_hook.py +++ b/openpype/hooks/pre_ocio_hook.py @@ -1,12 +1,27 @@ from openpype.lib import PreLaunchHook -from openpype.pipeline.colorspace import get_imageio_config +from openpype.pipeline.colorspace import ( + get_imageio_config +) from openpype.pipeline.template_data import get_template_data_with_names -class FusionPreLaunchOCIO(PreLaunchHook): - """Set OCIO environment variable for Fusion""" - app_groups = ["fusion"] +class OCIOEnvHook(PreLaunchHook): + """Set OCIO environment variable for hosts that use OpenColorIO.""" + + order = 0 + hosts = [ + "substancepainter", + "fusion", + "blender", + "aftereffects", + "max", + "houdini", + "maya", + "nuke", + "hiero", + "resolve" + ] def execute(self): """Hook entry method.""" @@ -26,7 +41,13 @@ class FusionPreLaunchOCIO(PreLaunchHook): anatomy_data=template_data, anatomy=self.data["anatomy"] ) - ocio_path = config_data["path"] - self.log.info(f"Setting OCIO config path: {ocio_path}") - self.launch_context.env["OCIO"] = ocio_path + if config_data: + ocio_path = config_data["path"] + + self.log.info( + f"Setting OCIO environment to config path: {ocio_path}") + + self.launch_context.env["OCIO"] = ocio_path + else: + self.log.debug("OCIO not set or enabled") diff --git a/openpype/hosts/blender/api/lib.py b/openpype/hosts/blender/api/lib.py index 6526f1fb87..9bb560c364 100644 --- a/openpype/hosts/blender/api/lib.py +++ b/openpype/hosts/blender/api/lib.py @@ -134,6 +134,27 @@ def append_user_scripts(): traceback.print_exc() +def set_app_templates_path(): + # Blender requires the app templates to be in `BLENDER_USER_SCRIPTS`. + # After running Blender, we set that variable to our custom path, so + # that the user can use their custom app templates. + + # We look among the scripts paths for one of the paths that contains + # the app templates. The path must contain the subfolder + # `startup/bl_app_templates_user`. + paths = os.environ.get("OPENPYPE_BLENDER_USER_SCRIPTS").split(os.pathsep) + + app_templates_path = None + for path in paths: + if os.path.isdir( + os.path.join(path, "startup", "bl_app_templates_user")): + app_templates_path = path + break + + if app_templates_path and os.path.isdir(app_templates_path): + os.environ["BLENDER_USER_SCRIPTS"] = app_templates_path + + def imprint(node: bpy.types.bpy_struct_meta_idprop, data: Dict): r"""Write `data` to `node` as userDefined attributes diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 9cc557c01a..0f756d8cb6 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -60,6 +60,7 @@ def install(): register_creator_plugin_path(str(CREATE_PATH)) lib.append_user_scripts() + lib.set_app_templates_path() register_event_callback("new", on_new) register_event_callback("open", on_open) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index df8c1ac887..3289187fa0 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -10,6 +10,7 @@ from qtpy import QtCore, QtWidgets from openpype import style from openpype.lib import Logger, StringTemplate from openpype.pipeline import LegacyCreator, LoaderPlugin +from openpype.pipeline.colorspace import get_remapped_colorspace_to_native from openpype.settings import get_current_project_settings from . import constants @@ -701,6 +702,7 @@ class ClipLoader(LoaderPlugin): ] _mapping = None + _host_settings = None def apply_settings(cls, project_settings, system_settings): @@ -769,15 +771,26 @@ class ClipLoader(LoaderPlugin): Returns: str: native colorspace name defined in mapping or None """ + # TODO: rewrite to support only pipeline's remapping + if not cls._host_settings: + cls._host_settings = get_current_project_settings()["flame"] + + # [Deprecated] way of remapping if not cls._mapping: - settings = get_current_project_settings()["flame"] - mapping = settings["imageio"]["profilesMapping"]["inputs"] + mapping = ( + cls._host_settings["imageio"]["profilesMapping"]["inputs"]) cls._mapping = { input["ocioName"]: input["flameName"] for input in mapping } - return cls._mapping.get(input_colorspace) + native_name = cls._mapping.get(input_colorspace) + + if not native_name: + native_name = get_remapped_colorspace_to_native( + input_colorspace, "flame", cls._host_settings["imageio"]) + + return native_name class OpenClipSolver(flib.MediaInfoFile): diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index 8034885c47..83110bb6b5 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -47,6 +47,17 @@ class FlamePrelaunch(PreLaunchHook): imageio_flame = project_settings["flame"]["imageio"] + # Check whether 'enabled' key from host imageio settings exists + # so we can tell if host is using the new colormanagement framework. + # If the 'enabled' isn't found we want 'colormanaged' set to True + # because prior to the key existing we always did colormanagement for + # Flame + colormanaged = imageio_flame.get("enabled") + # if key was not found, set to True + # ensuring backward compatibility + if colormanaged is None: + colormanaged = True + # get user name and host name user_name = get_openpype_username() user_name = user_name.replace(".", "_") @@ -68,9 +79,7 @@ class FlamePrelaunch(PreLaunchHook): "FrameWidth": int(width), "FrameHeight": int(height), "AspectRatio": float((width / height) * _db_p_data["pixelAspect"]), - "FrameRate": self._get_flame_fps(fps), - "FrameDepth": str(imageio_flame["project"]["frameDepth"]), - "FieldDominance": str(imageio_flame["project"]["fieldDominance"]) + "FrameRate": self._get_flame_fps(fps) } data_to_script = { @@ -78,7 +87,6 @@ class FlamePrelaunch(PreLaunchHook): "host_name": _env.get("FLAME_WIRETAP_HOSTNAME") or hostname, "volume_name": volume_name, "group_name": _env.get("FLAME_WIRETAP_GROUP"), - "color_policy": str(imageio_flame["project"]["colourPolicy"]), # from project "project_name": project_name, @@ -86,6 +94,16 @@ class FlamePrelaunch(PreLaunchHook): "project_data": project_data } + # add color management data + if colormanaged: + project_data.update({ + "FrameDepth": str(imageio_flame["project"]["frameDepth"]), + "FieldDominance": str( + imageio_flame["project"]["fieldDominance"]) + }) + data_to_script["color_policy"] = str( + imageio_flame["project"]["colourPolicy"]) + self.log.info(pformat(dict(_env))) self.log.info(pformat(data_to_script)) diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index 0d4368529f..fa874f9e9d 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -23,11 +23,17 @@ except ImportError: from openpype.client import get_project from openpype.settings import get_project_settings -from openpype.pipeline import legacy_io, Anatomy +from openpype.pipeline import ( + get_current_project_name, legacy_io, Anatomy +) from openpype.pipeline.load import filter_containers from openpype.lib import Logger from . import tags +from openpype.pipeline.colorspace import ( + get_imageio_config +) + class DeprecatedWarning(DeprecationWarning): pass @@ -1047,6 +1053,18 @@ def apply_colorspace_project(): imageio = get_project_settings(project_name)["hiero"]["imageio"] presets = imageio.get("workfile") + # backward compatibility layer + # TODO: remove this after some time + config_data = get_imageio_config( + project_name=get_current_project_name(), + host_name="hiero" + ) + + if config_data: + presets.update({ + "ocioConfigName": "custom" + }) + # save the workfile as subversion "comment:_colorspaceChange" split_current_file = os.path.splitext(current_file) copy_current_file = current_file diff --git a/openpype/hosts/houdini/api/colorspace.py b/openpype/hosts/houdini/api/colorspace.py new file mode 100644 index 0000000000..7047644225 --- /dev/null +++ b/openpype/hosts/houdini/api/colorspace.py @@ -0,0 +1,56 @@ +import attr +import hou +from openpype.hosts.houdini.api.lib import get_color_management_preferences + + +@attr.s +class LayerMetadata(object): + """Data class for Render Layer metadata.""" + frameStart = attr.ib() + frameEnd = attr.ib() + + +@attr.s +class RenderProduct(object): + """Getting Colorspace as + Specific Render Product Parameter for submitting + publish job. + + """ + colorspace = attr.ib() # colorspace + view = attr.ib() + productName = attr.ib(default=None) + + +class ARenderProduct(object): + + def __init__(self): + """Constructor.""" + # Initialize + self.layer_data = self._get_layer_data() + self.layer_data.products = self.get_colorspace_data() + + def _get_layer_data(self): + return LayerMetadata( + frameStart=int(hou.playbar.frameRange()[0]), + frameEnd=int(hou.playbar.frameRange()[1]), + ) + + def get_colorspace_data(self): + """To be implemented by renderer class. + + This should return a list of RenderProducts. + + Returns: + list: List of RenderProduct + + """ + data = get_color_management_preferences() + colorspace_data = [ + RenderProduct( + colorspace=data["display"], + view=data["view"], + productName="" + ) + ] + return colorspace_data diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 2e58f3dd98..a33ba7aad2 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import sys import os +import re import uuid import logging from contextlib import contextmanager @@ -581,3 +582,74 @@ def splitext(name, allowed_multidot_extensions): return name[:-len(ext)], ext return os.path.splitext(name) + + +def get_top_referenced_parm(parm): + + processed = set() # disallow infinite loop + while True: + if parm.path() in processed: + raise RuntimeError("Parameter references result in cycle.") + + processed.add(parm.path()) + + ref = parm.getReferencedParm() + if ref.path() == parm.path(): + # It returns itself when it doesn't reference + # another parameter + return ref + else: + parm = ref + + +def evalParmNoFrame(node, parm, pad_character="#"): + + parameter = node.parm(parm) + assert parameter, "Parameter does not exist: %s.%s" % (node, parm) + + # If the parameter has a parameter reference, then get that + # parameter instead as otherwise `unexpandedString()` fails. + parameter = get_top_referenced_parm(parameter) + + # Substitute out the frame numbering with padded characters + try: + raw = parameter.unexpandedString() + except hou.Error as exc: + print("Failed: %s" % parameter) + raise RuntimeError(exc) + + def replace(match): + padding = 1 + n = match.group(2) + if n and int(n): + padding = int(n) + return pad_character * padding + + expression = re.sub(r"(\$F([0-9]*))", replace, raw) + + with hou.ScriptEvalContext(parameter): + return hou.expandStringAtFrame(expression, 0) + + +def get_color_management_preferences(): + """Get default OCIO preferences""" + data = { + "config": hou.Color.ocio_configPath() + + } + + # Get default display and view from OCIO + display = hou.Color.ocio_defaultDisplay() + disp_regex = re.compile(r"^(?P.+-)(?P.+)$") + disp_match = disp_regex.match(display) + + view = hou.Color.ocio_defaultView() + view_regex = re.compile(r"^(?P.+- )(?P.+)$") + view_match = view_regex.match(view) + data.update({ + "display": disp_match.group("display"), + "view": view_match.group("view") + + }) + + return data diff --git a/openpype/hosts/houdini/plugins/create/create_arnold_rop.py b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py new file mode 100644 index 0000000000..bddf26dbd5 --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_arnold_rop.py @@ -0,0 +1,71 @@ +from openpype.hosts.houdini.api import plugin +from openpype.lib import EnumDef + + +class CreateArnoldRop(plugin.HoudiniCreator): + """Arnold ROP""" + + identifier = "io.openpype.creators.houdini.arnold_rop" + label = "Arnold ROP" + family = "arnold_rop" + icon = "magic" + defaults = ["master"] + + # Default extension + ext = "exr" + + def create(self, subset_name, instance_data, pre_create_data): + import hou + + # Remove the active, we are checking the bypass flag of the nodes + instance_data.pop("active", None) + instance_data.update({"node_type": "arnold"}) + + # Add chunk size attribute + instance_data["chunkSize"] = 1 + # Submit for job publishing + instance_data["farm"] = True + + instance = super(CreateArnoldRop, self).create( + subset_name, + instance_data, + pre_create_data) # type: plugin.CreatedInstance + + instance_node = hou.node(instance.get("instance_node")) + + ext = pre_create_data.get("image_format") + + filepath = "{renders_dir}{subset_name}/{subset_name}.$F4.{ext}".format( + renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), + subset_name=subset_name, + ext=ext, + ) + parms = { + # Render frame range + "trange": 1, + + # Arnold ROP settings + "ar_picture": filepath, + "ar_exr_half_precision": 1 # half precision + } + + instance_node.setParms(parms) + + # Lock any parameters in this list + to_lock = ["family", "id"] + self.lock_parameters(instance_node, to_lock) + + def get_pre_create_attr_defs(self): + attrs = super(CreateArnoldRop, self).get_pre_create_attr_defs() + + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", + "rad", "rat", "rta", "sgi", "tga", "tif", + ] + + return attrs + [ + EnumDef("image_format", + image_format_enum, + default=self.ext, + label="Image Format Options") + ] diff --git a/openpype/hosts/houdini/plugins/create/create_karma_rop.py b/openpype/hosts/houdini/plugins/create/create_karma_rop.py new file mode 100644 index 0000000000..edfb992e1a --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_karma_rop.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""Creator plugin to create Karma ROP.""" +from openpype.hosts.houdini.api import plugin +from openpype.pipeline import CreatedInstance +from openpype.lib import BoolDef, EnumDef, NumberDef + + +class CreateKarmaROP(plugin.HoudiniCreator): + """Karma ROP""" + identifier = "io.openpype.creators.houdini.karma_rop" + label = "Karma ROP" + family = "karma_rop" + icon = "magic" + defaults = ["master"] + + def create(self, subset_name, instance_data, pre_create_data): + import hou # noqa + + instance_data.pop("active", None) + instance_data.update({"node_type": "karma"}) + # Add chunk size attribute + instance_data["chunkSize"] = 10 + # Submit for job publishing + instance_data["farm"] = True + + instance = super(CreateKarmaROP, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + + instance_node = hou.node(instance.get("instance_node")) + + ext = pre_create_data.get("image_format") + + filepath = "{renders_dir}{subset_name}/{subset_name}.$F4.{ext}".format( + renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), + subset_name=subset_name, + ext=ext, + ) + checkpoint = "{cp_dir}{subset_name}.$F4.checkpoint".format( + cp_dir=hou.text.expandString("$HIP/pyblish/"), + subset_name=subset_name + ) + + usd_directory = "{usd_dir}{subset_name}_$RENDERID".format( + usd_dir=hou.text.expandString("$HIP/pyblish/renders/usd_renders/"), # noqa + subset_name=subset_name + ) + + parms = { + # Render Frame Range + "trange": 1, + # Karma ROP Setting + "picture": filepath, + # Karma Checkpoint Setting + "productName": checkpoint, + # USD Output Directory + "savetodirectory": usd_directory, + } + + res_x = pre_create_data.get("res_x") + res_y = pre_create_data.get("res_y") + + if self.selected_nodes: + # If camera found in selection + # we will use as render camera + camera = None + for node in self.selected_nodes: + if node.type().name() == "cam": + has_camera = pre_create_data.get("cam_res") + if has_camera: + res_x = node.evalParm("resx") + res_y = node.evalParm("resy") + + if not camera: + self.log.warning("No render camera found in selection") + + parms.update({ + "camera": camera or "", + "resolutionx": res_x, + "resolutiony": res_y, + }) + + instance_node.setParms(parms) + + # Lock some Avalon attributes + to_lock = ["family", "id"] + self.lock_parameters(instance_node, to_lock) + + def get_pre_create_attr_defs(self): + attrs = super(CreateKarmaROP, self).get_pre_create_attr_defs() + + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", + "rad", "rat", "rta", "sgi", "tga", "tif", + ] + + return attrs + [ + EnumDef("image_format", + image_format_enum, + default="exr", + label="Image Format Options"), + NumberDef("res_x", + label="width", + default=1920, + decimals=0), + NumberDef("res_y", + label="height", + default=720, + decimals=0), + BoolDef("cam_res", + label="Camera Resolution", + default=False) + ] diff --git a/openpype/hosts/houdini/plugins/create/create_mantra_rop.py b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py new file mode 100644 index 0000000000..5ca53e96de --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_mantra_rop.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""Creator plugin to create Mantra ROP.""" +from openpype.hosts.houdini.api import plugin +from openpype.pipeline import CreatedInstance +from openpype.lib import EnumDef, BoolDef + + +class CreateMantraROP(plugin.HoudiniCreator): + """Mantra ROP""" + identifier = "io.openpype.creators.houdini.mantra_rop" + label = "Mantra ROP" + family = "mantra_rop" + icon = "magic" + defaults = ["master"] + + def create(self, subset_name, instance_data, pre_create_data): + import hou # noqa + + instance_data.pop("active", None) + instance_data.update({"node_type": "ifd"}) + # Add chunk size attribute + instance_data["chunkSize"] = 10 + # Submit for job publishing + instance_data["farm"] = True + + instance = super(CreateMantraROP, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + + instance_node = hou.node(instance.get("instance_node")) + + ext = pre_create_data.get("image_format") + + filepath = "{renders_dir}{subset_name}/{subset_name}.$F4.{ext}".format( + renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), + subset_name=subset_name, + ext=ext, + ) + + parms = { + # Render Frame Range + "trange": 1, + # Mantra ROP Setting + "vm_picture": filepath, + } + + if self.selected_nodes: + # If camera found in selection + # we will use as render camera + camera = None + for node in self.selected_nodes: + if node.type().name() == "cam": + camera = node.path() + + if not camera: + self.log.warning("No render camera found in selection") + + parms.update({"camera": camera or ""}) + + custom_res = pre_create_data.get("override_resolution") + if custom_res: + parms.update({"override_camerares": 1}) + instance_node.setParms(parms) + + # Lock some Avalon attributes + to_lock = ["family", "id"] + self.lock_parameters(instance_node, to_lock) + + def get_pre_create_attr_defs(self): + attrs = super(CreateMantraROP, self).get_pre_create_attr_defs() + + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", + "rad", "rat", "rta", "sgi", "tga", "tif", + ] + + return attrs + [ + EnumDef("image_format", + image_format_enum, + default="exr", + label="Image Format Options"), + BoolDef("override_resolution", + label="Override Camera Resolution", + tooltip="Override the current camera " + "resolution, recommended for IPR.", + default=False) + ] diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py index 2cbe9bfda1..e14ff15bf8 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_rop.py @@ -1,7 +1,10 @@ # -*- coding: utf-8 -*- """Creator plugin to create Redshift ROP.""" +import hou # noqa + from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance +from openpype.lib import EnumDef class CreateRedshiftROP(plugin.HoudiniCreator): @@ -11,20 +14,16 @@ class CreateRedshiftROP(plugin.HoudiniCreator): family = "redshift_rop" icon = "magic" defaults = ["master"] + ext = "exr" def create(self, subset_name, instance_data, pre_create_data): - import hou # noqa instance_data.pop("active", None) instance_data.update({"node_type": "Redshift_ROP"}) # Add chunk size attribute instance_data["chunkSize"] = 10 - - # Clear the family prefix from the subset - subset = subset_name - subset_no_prefix = subset[len(self.family):] - subset_no_prefix = subset_no_prefix[0].lower() + subset_no_prefix[1:] - subset_name = subset_no_prefix + # Submit for job publishing + instance_data["farm"] = True instance = super(CreateRedshiftROP, self).create( subset_name, @@ -34,11 +33,10 @@ class CreateRedshiftROP(plugin.HoudiniCreator): instance_node = hou.node(instance.get("instance_node")) basename = instance_node.name() - instance_node.setName(basename + "_ROP", unique_name=True) # Also create the linked Redshift IPR Rop try: - ipr_rop = self.parent.createNode( + ipr_rop = instance_node.parent().createNode( "Redshift_IPR", node_name=basename + "_IPR" ) except hou.OperationFailed: @@ -50,19 +48,58 @@ class CreateRedshiftROP(plugin.HoudiniCreator): ipr_rop.setPosition(instance_node.position() + hou.Vector2(0, -1)) # Set the linked rop to the Redshift ROP - ipr_rop.parm("linked_rop").set(ipr_rop.relativePathTo(instance)) + ipr_rop.parm("linked_rop").set(instance_node.path()) + + ext = pre_create_data.get("image_format") + filepath = "{renders_dir}{subset_name}/{subset_name}.{fmt}".format( + renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), + subset_name=subset_name, + fmt="${aov}.$F4.{ext}".format(aov="AOV", ext=ext) + ) - prefix = '${HIP}/render/${HIPNAME}/`chs("subset")`.${AOV}.$F4.exr' parms = { # Render frame range "trange": 1, # Redshift ROP settings - "RS_outputFileNamePrefix": prefix, - "RS_outputMultilayerMode": 0, # no multi-layered exr + "RS_outputFileNamePrefix": filepath, + "RS_outputMultilayerMode": "1", # no multi-layered exr "RS_outputBeautyAOVSuffix": "beauty", } + + if self.selected_nodes: + # set up the render camera from the selected node + camera = None + for node in self.selected_nodes: + if node.type().name() == "cam": + camera = node.path() + parms.update({ + "RS_renderCamera": camera or ""}) instance_node.setParms(parms) # Lock some Avalon attributes to_lock = ["family", "id"] self.lock_parameters(instance_node, to_lock) + + def remove_instances(self, instances): + for instance in instances: + node = instance.data.get("instance_node") + + ipr_node = hou.node(f"{node}_IPR") + if ipr_node: + ipr_node.destroy() + + return super(CreateRedshiftROP, self).remove_instances(instances) + + def get_pre_create_attr_defs(self): + attrs = super(CreateRedshiftROP, self).get_pre_create_attr_defs() + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", + "rad", "rat", "rta", "sgi", "tga", "tif", + ] + + return attrs + [ + EnumDef("image_format", + image_format_enum, + default=self.ext, + label="Image Format Options") + ] diff --git a/openpype/hosts/houdini/plugins/create/create_vray_rop.py b/openpype/hosts/houdini/plugins/create/create_vray_rop.py new file mode 100644 index 0000000000..1de9be4ed6 --- /dev/null +++ b/openpype/hosts/houdini/plugins/create/create_vray_rop.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +"""Creator plugin to create VRay ROP.""" +import hou + +from openpype.hosts.houdini.api import plugin +from openpype.pipeline import CreatedInstance +from openpype.lib import EnumDef, BoolDef + + +class CreateVrayROP(plugin.HoudiniCreator): + """VRay ROP""" + + identifier = "io.openpype.creators.houdini.vray_rop" + label = "VRay ROP" + family = "vray_rop" + icon = "magic" + defaults = ["master"] + + ext = "exr" + + def create(self, subset_name, instance_data, pre_create_data): + + instance_data.pop("active", None) + instance_data.update({"node_type": "vray_renderer"}) + # Add chunk size attribute + instance_data["chunkSize"] = 10 + # Submit for job publishing + instance_data["farm"] = True + + instance = super(CreateVrayROP, self).create( + subset_name, + instance_data, + pre_create_data) # type: CreatedInstance + + instance_node = hou.node(instance.get("instance_node")) + + # Add IPR for Vray + basename = instance_node.name() + try: + ipr_rop = instance_node.parent().createNode( + "vray", node_name=basename + "_IPR" + ) + except hou.OperationFailed: + raise plugin.OpenPypeCreatorError( + "Cannot create Vray render node. " + "Make sure Vray installed and enabled!" + ) + + ipr_rop.setPosition(instance_node.position() + hou.Vector2(0, -1)) + ipr_rop.parm("rop").set(instance_node.path()) + + parms = { + "trange": 1, + "SettingsEXR_bits_per_channel": "16" # half precision + } + + if self.selected_nodes: + # set up the render camera from the selected node + camera = None + for node in self.selected_nodes: + if node.type().name() == "cam": + camera = node.path() + parms.update({ + "render_camera": camera or "" + }) + + # Enable render element + ext = pre_create_data.get("image_format") + instance_data["RenderElement"] = pre_create_data.get("render_element_enabled") # noqa + if pre_create_data.get("render_element_enabled", True): + # Vray has its own tag for AOV file output + filepath = "{renders_dir}{subset_name}/{subset_name}.{fmt}".format( + renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), + subset_name=subset_name, + fmt="${aov}.$F4.{ext}".format(aov="AOV", + ext=ext) + ) + filepath = "{}{}".format( + hou.text.expandString("$HIP/pyblish/renders/"), + "{}/{}.${}.$F4.{}".format(subset_name, + subset_name, + "AOV", + ext) + ) + re_rop = instance_node.parent().createNode( + "vray_render_channels", + node_name=basename + "_render_element" + ) + # move the render element node next to the vray renderer node + re_rop.setPosition(instance_node.position() + hou.Vector2(0, 1)) + re_path = re_rop.path() + parms.update({ + "use_render_channels": 1, + "SettingsOutput_img_file_path": filepath, + "render_network_render_channels": re_path + }) + + else: + filepath = "{renders_dir}{subset_name}/{subset_name}.{fmt}".format( + renders_dir=hou.text.expandString("$HIP/pyblish/renders/"), + subset_name=subset_name, + fmt="$F4.{ext}".format(ext=ext) + ) + parms.update({ + "use_render_channels": 0, + "SettingsOutput_img_file_path": filepath + }) + + custom_res = pre_create_data.get("override_resolution") + if custom_res: + parms.update({"override_camerares": 1}) + + instance_node.setParms(parms) + + # lock parameters from AVALON + to_lock = ["family", "id"] + self.lock_parameters(instance_node, to_lock) + + def remove_instances(self, instances): + for instance in instances: + node = instance.data.get("instance_node") + # for the extra render node from the plugins + # such as vray and redshift + ipr_node = hou.node("{}{}".format(node, "_IPR")) + if ipr_node: + ipr_node.destroy() + re_node = hou.node("{}{}".format(node, + "_render_element")) + if re_node: + re_node.destroy() + + return super(CreateVrayROP, self).remove_instances(instances) + + def get_pre_create_attr_defs(self): + attrs = super(CreateVrayROP, self).get_pre_create_attr_defs() + image_format_enum = [ + "bmp", "cin", "exr", "jpg", "pic", "pic.gz", "png", + "rad", "rat", "rta", "sgi", "tga", "tif", + ] + + return attrs + [ + EnumDef("image_format", + image_format_enum, + default=self.ext, + label="Image Format Options"), + BoolDef("override_resolution", + label="Override Camera Resolution", + tooltip="Override the current camera " + "resolution, recommended for IPR.", + default=False), + BoolDef("render_element_enabled", + label="Render Element", + tooltip="Create Render Element Node " + "if enabled", + default=False) + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py new file mode 100644 index 0000000000..614785487f --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -0,0 +1,135 @@ +import os +import re + +import hou +import pyblish.api + +from openpype.hosts.houdini.api import colorspace +from openpype.hosts.houdini.api.lib import ( + evalParmNoFrame, get_color_management_preferences) + + +class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): + """Collect Arnold ROP Render Products + + Collects the instance.data["files"] for the render products. + + Provides: + instance -> files + + """ + + label = "Arnold ROP Render Products" + order = pyblish.api.CollectorOrder + 0.4 + hosts = ["houdini"] + families = ["arnold_rop"] + + def process(self, instance): + + rop = hou.node(instance.data.get("instance_node")) + + # Collect chunkSize + chunk_size_parm = rop.parm("chunkSize") + if chunk_size_parm: + chunk_size = int(chunk_size_parm.eval()) + instance.data["chunkSize"] = chunk_size + self.log.debug("Chunk Size: %s" % chunk_size) + + default_prefix = evalParmNoFrame(rop, "ar_picture") + render_products = [] + + # Default beauty AOV + beauty_product = self.get_render_product_name(prefix=default_prefix, + suffix=None) + render_products.append(beauty_product) + + files_by_aov = { + "": self.generate_expected_files(instance, beauty_product) + } + + num_aovs = rop.evalParm("ar_aovs") + for index in range(1, num_aovs + 1): + # Skip disabled AOVs + if not rop.evalParm("ar_enable_aovP{}".format(index)): + continue + + if rop.evalParm("ar_aov_exr_enable_layer_name{}".format(index)): + label = rop.evalParm("ar_aov_exr_layer_name{}".format(index)) + else: + label = evalParmNoFrame(rop, "ar_aov_label{}".format(index)) + + aov_product = self.get_render_product_name(default_prefix, + suffix=label) + render_products.append(aov_product) + files_by_aov[label] = self.generate_expected_files(instance, + aov_product) + + for product in render_products: + self.log.debug("Found render product: {}".format(product)) + + instance.data["files"] = list(render_products) + instance.data["renderProducts"] = colorspace.ARenderProduct() + + # For now by default do NOT try to publish the rendered output + instance.data["publishJobState"] = "Suspended" + instance.data["attachTo"] = [] # stub required data + + if "expectedFiles" not in instance.data: + instance.data["expectedFiles"] = list() + instance.data["expectedFiles"].append(files_by_aov) + + # update the colorspace data + colorspace_data = get_color_management_preferences() + instance.data["colorspaceConfig"] = colorspace_data["config"] + instance.data["colorspaceDisplay"] = colorspace_data["display"] + instance.data["colorspaceView"] = colorspace_data["view"] + + def get_render_product_name(self, prefix, suffix): + """Return the output filename using the AOV prefix and suffix""" + + # When AOV is explicitly defined in prefix we just swap it out + # directly with the AOV suffix to embed it. + # Note: ${AOV} seems to be evaluated in the parameter as %AOV% + if "%AOV%" in prefix: + # It seems that when some special separator characters are present + # before the %AOV% token that Redshift will secretly remove it if + # there is no suffix for the current product, for example: + # foo_%AOV% -> foo.exr + pattern = "%AOV%" if suffix else "[._-]?%AOV%" + product_name = re.sub(pattern, + suffix, + prefix, + flags=re.IGNORECASE) + else: + if suffix: + # Add ".{suffix}" before the extension + prefix_base, ext = os.path.splitext(prefix) + product_name = prefix_base + "." + suffix + ext + else: + product_name = prefix + + return product_name + + def generate_expected_files(self, instance, path): + """Create expected files in instance data""" + + dir = os.path.dirname(path) + file = os.path.basename(path) + + if "#" in file: + def replace(match): + return "%0{}d".format(len(match.group())) + + file = re.sub("#+", replace, file) + + if "%" not in file: + return path + + expected_files = [] + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): + expected_files.append( + os.path.join(dir, (file % i)).replace("\\", "/")) + + return expected_files diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 059793e3c5..91a3d9d170 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -11,15 +11,13 @@ from openpype.hosts.houdini.api import lib class CollectFrames(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" - order = pyblish.api.CollectorOrder + order = pyblish.api.CollectorOrder + 0.01 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "review"] def process(self, instance): ropnode = hou.node(instance.data["instance_node"]) - frame_data = lib.get_frame_data(ropnode) - instance.data.update(frame_data) start_frame = instance.data.get("frameStart", None) end_frame = instance.data.get("frameEnd", None) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py new file mode 100644 index 0000000000..584343cd64 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_instance_frame_data.py @@ -0,0 +1,56 @@ +import hou + +import pyblish.api + + +class CollectInstanceNodeFrameRange(pyblish.api.InstancePlugin): + """Collect time range frame data for the instance node.""" + + order = pyblish.api.CollectorOrder + 0.001 + label = "Instance Node Frame Range" + hosts = ["houdini"] + + def process(self, instance): + + node_path = instance.data.get("instance_node") + node = hou.node(node_path) if node_path else None + if not node_path or not node: + self.log.debug("No instance node found for instance: " + "{}".format(instance)) + return + + frame_data = self.get_frame_data(node) + if not frame_data: + return + + self.log.info("Collected time data: {}".format(frame_data)) + instance.data.update(frame_data) + + def get_frame_data(self, node): + """Get the frame data: start frame, end frame and steps + Args: + node(hou.Node) + + Returns: + dict + + """ + + data = {} + + if node.parm("trange") is None: + self.log.debug("Node has no 'trange' parameter: " + "{}".format(node.path())) + return data + + if node.evalParm("trange") == 0: + # Ignore 'render current frame' + self.log.debug("Node '{}' has 'Render current frame' set. " + "Time range data ignored.".format(node.path())) + return data + + data["frameStart"] = node.evalParm("f1") + data["frameEnd"] = node.evalParm("f2") + data["byFrameStep"] = node.evalParm("f3") + + return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index 5d5347f96e..3772c9e705 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -70,16 +70,10 @@ class CollectInstances(pyblish.api.ContextPlugin): if "active" in data: data["publish"] = data["active"] - data.update(self.get_frame_data(node)) - # Create nice name if the instance has a frame range. label = data.get("name", node.name()) label += " (%s)" % data["asset"] # include asset in name - if "frameStart" in data and "frameEnd" in data: - frames = "[{frameStart} - {frameEnd}]".format(**data) - label = "{} {}".format(label, frames) - instance = context.create_instance(label) # Include `families` using `family` data @@ -118,6 +112,6 @@ class CollectInstances(pyblish.api.ContextPlugin): data["frameStart"] = node.evalParm("f1") data["frameEnd"] = node.evalParm("f2") - data["steps"] = node.evalParm("f3") + data["byFrameStep"] = node.evalParm("f3") return data diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py new file mode 100644 index 0000000000..eabb1128d8 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -0,0 +1,104 @@ +import re +import os + +import hou +import pyblish.api + +from openpype.hosts.houdini.api.lib import ( + evalParmNoFrame, + get_color_management_preferences +) +from openpype.hosts.houdini.api import ( + colorspace +) + + +class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): + """Collect Karma Render Products + + Collects the instance.data["files"] for the multipart render product. + + Provides: + instance -> files + + """ + + label = "Karma ROP Render Products" + order = pyblish.api.CollectorOrder + 0.4 + hosts = ["houdini"] + families = ["karma_rop"] + + def process(self, instance): + + rop = hou.node(instance.data.get("instance_node")) + + # Collect chunkSize + chunk_size_parm = rop.parm("chunkSize") + if chunk_size_parm: + chunk_size = int(chunk_size_parm.eval()) + instance.data["chunkSize"] = chunk_size + self.log.debug("Chunk Size: %s" % chunk_size) + + default_prefix = evalParmNoFrame(rop, "picture") + render_products = [] + + # Default beauty AOV + beauty_product = self.get_render_product_name( + prefix=default_prefix, suffix=None + ) + render_products.append(beauty_product) + + files_by_aov = { + "beauty": self.generate_expected_files(instance, + beauty_product) + } + + filenames = list(render_products) + instance.data["files"] = filenames + instance.data["renderProducts"] = colorspace.ARenderProduct() + + for product in render_products: + self.log.debug("Found render product: %s" % product) + + if "expectedFiles" not in instance.data: + instance.data["expectedFiles"] = list() + instance.data["expectedFiles"].append(files_by_aov) + + # update the colorspace data + colorspace_data = get_color_management_preferences() + instance.data["colorspaceConfig"] = colorspace_data["config"] + instance.data["colorspaceDisplay"] = colorspace_data["display"] + instance.data["colorspaceView"] = colorspace_data["view"] + + def get_render_product_name(self, prefix, suffix): + product_name = prefix + if suffix: + # Add ".{suffix}" before the extension + prefix_base, ext = os.path.splitext(prefix) + product_name = "{}.{}{}".format(prefix_base, suffix, ext) + + return product_name + + def generate_expected_files(self, instance, path): + """Create expected files in instance data""" + + dir = os.path.dirname(path) + file = os.path.basename(path) + + if "#" in file: + def replace(match): + return "%0{}d".format(len(match.group())) + + file = re.sub("#+", replace, file) + + if "%" not in file: + return path + + expected_files = [] + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): + expected_files.append( + os.path.join(dir, (file % i)).replace("\\", "/")) + + return expected_files diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py new file mode 100644 index 0000000000..c4460f5350 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -0,0 +1,127 @@ +import re +import os + +import hou +import pyblish.api + +from openpype.hosts.houdini.api.lib import ( + evalParmNoFrame, + get_color_management_preferences +) +from openpype.hosts.houdini.api import ( + colorspace +) + + +class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): + """Collect Mantra Render Products + + Collects the instance.data["files"] for the render products. + + Provides: + instance -> files + + """ + + label = "Mantra ROP Render Products" + order = pyblish.api.CollectorOrder + 0.4 + hosts = ["houdini"] + families = ["mantra_rop"] + + def process(self, instance): + + rop = hou.node(instance.data.get("instance_node")) + + # Collect chunkSize + chunk_size_parm = rop.parm("chunkSize") + if chunk_size_parm: + chunk_size = int(chunk_size_parm.eval()) + instance.data["chunkSize"] = chunk_size + self.log.debug("Chunk Size: %s" % chunk_size) + + default_prefix = evalParmNoFrame(rop, "vm_picture") + render_products = [] + + # Default beauty AOV + beauty_product = self.get_render_product_name( + prefix=default_prefix, suffix=None + ) + render_products.append(beauty_product) + + files_by_aov = { + "beauty": self.generate_expected_files(instance, + beauty_product) + } + + aov_numbers = rop.evalParm("vm_numaux") + if aov_numbers > 0: + # get the filenames of the AOVs + for i in range(1, aov_numbers + 1): + var = rop.evalParm("vm_variable_plane%d" % i) + if var: + aov_name = "vm_filename_plane%d" % i + aov_boolean = "vm_usefile_plane%d" % i + aov_enabled = rop.evalParm(aov_boolean) + has_aov_path = rop.evalParm(aov_name) + if has_aov_path and aov_enabled == 1: + aov_prefix = evalParmNoFrame(rop, aov_name) + aov_product = self.get_render_product_name( + prefix=aov_prefix, suffix=None + ) + render_products.append(aov_product) + + files_by_aov[var] = self.generate_expected_files(instance, aov_product) # noqa + + for product in render_products: + self.log.debug("Found render product: %s" % product) + + filenames = list(render_products) + instance.data["files"] = filenames + instance.data["renderProducts"] = colorspace.ARenderProduct() + + # For now by default do NOT try to publish the rendered output + instance.data["publishJobState"] = "Suspended" + instance.data["attachTo"] = [] # stub required data + + if "expectedFiles" not in instance.data: + instance.data["expectedFiles"] = list() + instance.data["expectedFiles"].append(files_by_aov) + + # update the colorspace data + colorspace_data = get_color_management_preferences() + instance.data["colorspaceConfig"] = colorspace_data["config"] + instance.data["colorspaceDisplay"] = colorspace_data["display"] + instance.data["colorspaceView"] = colorspace_data["view"] + + def get_render_product_name(self, prefix, suffix): + product_name = prefix + if suffix: + # Add ".{suffix}" before the extension + prefix_base, ext = os.path.splitext(prefix) + product_name = prefix_base + "." + suffix + ext + + return product_name + + def generate_expected_files(self, instance, path): + """Create expected files in instance data""" + + dir = os.path.dirname(path) + file = os.path.basename(path) + + if "#" in file: + def replace(match): + return "%0{}d".format(len(match.group())) + + file = re.sub("#+", replace, file) + + if "%" not in file: + return path + + expected_files = [] + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): + expected_files.append( + os.path.join(dir, (file % i)).replace("\\", "/")) + + return expected_files diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index f1d73d7523..dbb15ab88f 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -4,52 +4,13 @@ import os import hou import pyblish.api - -def get_top_referenced_parm(parm): - - processed = set() # disallow infinite loop - while True: - if parm.path() in processed: - raise RuntimeError("Parameter references result in cycle.") - - processed.add(parm.path()) - - ref = parm.getReferencedParm() - if ref.path() == parm.path(): - # It returns itself when it doesn't reference - # another parameter - return ref - else: - parm = ref - - -def evalParmNoFrame(node, parm, pad_character="#"): - - parameter = node.parm(parm) - assert parameter, "Parameter does not exist: %s.%s" % (node, parm) - - # If the parameter has a parameter reference, then get that - # parameter instead as otherwise `unexpandedString()` fails. - parameter = get_top_referenced_parm(parameter) - - # Substitute out the frame numbering with padded characters - try: - raw = parameter.unexpandedString() - except hou.Error as exc: - print("Failed: %s" % parameter) - raise RuntimeError(exc) - - def replace(match): - padding = 1 - n = match.group(2) - if n and int(n): - padding = int(n) - return pad_character * padding - - expression = re.sub(r"(\$F([0-9]*))", replace, raw) - - with hou.ScriptEvalContext(parameter): - return hou.expandStringAtFrame(expression, 0) +from openpype.hosts.houdini.api.lib import ( + evalParmNoFrame, + get_color_management_preferences +) +from openpype.hosts.houdini.api import ( + colorspace +) class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): @@ -87,6 +48,9 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): prefix=default_prefix, suffix=beauty_suffix ) render_products.append(beauty_product) + files_by_aov = { + "_": self.generate_expected_files(instance, + beauty_product)} num_aovs = rop.evalParm("RS_aov") for index in range(num_aovs): @@ -104,11 +68,29 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): aov_product = self.get_render_product_name(aov_prefix, aov_suffix) render_products.append(aov_product) + files_by_aov[aov_suffix] = self.generate_expected_files(instance, + aov_product) # noqa + for product in render_products: self.log.debug("Found render product: %s" % product) filenames = list(render_products) instance.data["files"] = filenames + instance.data["renderProducts"] = colorspace.ARenderProduct() + + # For now by default do NOT try to publish the rendered output + instance.data["publishJobState"] = "Suspended" + instance.data["attachTo"] = [] # stub required data + + if "expectedFiles" not in instance.data: + instance.data["expectedFiles"] = list() + instance.data["expectedFiles"].append(files_by_aov) + + # update the colorspace data + colorspace_data = get_color_management_preferences() + instance.data["colorspaceConfig"] = colorspace_data["config"] + instance.data["colorspaceDisplay"] = colorspace_data["display"] + instance.data["colorspaceView"] = colorspace_data["view"] def get_render_product_name(self, prefix, suffix): """Return the output filename using the AOV prefix and suffix""" @@ -133,3 +115,27 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): product_name = prefix return product_name + + def generate_expected_files(self, instance, path): + """Create expected files in instance data""" + + dir = os.path.dirname(path) + file = os.path.basename(path) + + if "#" in file: + def replace(match): + return "%0{}d".format(len(match.group())) + + file = re.sub("#+", replace, file) + + if "%" not in file: + return path + + expected_files = [] + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): + expected_files.append( + os.path.join(dir, (file % i)).replace("\\", "/")) + + return expected_files diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py new file mode 100644 index 0000000000..2a6be6b9f1 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +"""Collector plugin for frames data on ROP instances.""" +import hou # noqa +import pyblish.api +from openpype.hosts.houdini.api import lib + + +class CollectRopFrameRange(pyblish.api.InstancePlugin): + """Collect all frames which would be saved from the ROP nodes""" + + order = pyblish.api.CollectorOrder + label = "Collect RopNode Frame Range" + + def process(self, instance): + + node_path = instance.data.get("instance_node") + if node_path is None: + # Instance without instance node like a workfile instance + return + + ropnode = hou.node(node_path) + frame_data = lib.get_frame_data(ropnode) + + if "frameStart" in frame_data and "frameEnd" in frame_data: + + # Log artist friendly message about the collected frame range + message = ( + "Frame range {0[frameStart]} - {0[frameEnd]}" + ).format(frame_data) + if frame_data.get("step", 1.0) != 1.0: + message += " with step {0[step]}".format(frame_data) + self.log.info(message) + + instance.data.update(frame_data) + + # Add frame range to label if the instance has a frame range. + label = instance.data.get("label", instance.data["name"]) + instance.data["label"] = ( + "{0} [{1[frameStart]} - {1[frameEnd]}]".format(label, + frame_data) + ) diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py new file mode 100644 index 0000000000..d4fe37f993 --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -0,0 +1,129 @@ +import re +import os + +import hou +import pyblish.api + +from openpype.hosts.houdini.api.lib import ( + evalParmNoFrame, + get_color_management_preferences +) +from openpype.hosts.houdini.api import ( + colorspace +) + + +class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): + """Collect Vray Render Products + + Collects the instance.data["files"] for the render products. + + Provides: + instance -> files + + """ + + label = "VRay ROP Render Products" + order = pyblish.api.CollectorOrder + 0.4 + hosts = ["houdini"] + families = ["vray_rop"] + + def process(self, instance): + + rop = hou.node(instance.data.get("instance_node")) + + # Collect chunkSize + chunk_size_parm = rop.parm("chunkSize") + if chunk_size_parm: + chunk_size = int(chunk_size_parm.eval()) + instance.data["chunkSize"] = chunk_size + self.log.debug("Chunk Size: %s" % chunk_size) + + default_prefix = evalParmNoFrame(rop, "SettingsOutput_img_file_path") + render_products = [] + # TODO: add render elements if render element + + beauty_product = self.get_beauty_render_product(default_prefix) + render_products.append(beauty_product) + files_by_aov = { + "RGB Color": self.generate_expected_files(instance, + beauty_product)} + + if instance.data.get("RenderElement", True): + render_element = self.get_render_element_name(rop, default_prefix) + if render_element: + for aov, renderpass in render_element.items(): + render_products.append(renderpass) + files_by_aov[aov] = self.generate_expected_files(instance, renderpass) # noqa + + for product in render_products: + self.log.debug("Found render product: %s" % product) + filenames = list(render_products) + instance.data["files"] = filenames + instance.data["renderProducts"] = colorspace.ARenderProduct() + + # For now by default do NOT try to publish the rendered output + instance.data["publishJobState"] = "Suspended" + instance.data["attachTo"] = [] # stub required data + + if "expectedFiles" not in instance.data: + instance.data["expectedFiles"] = list() + instance.data["expectedFiles"].append(files_by_aov) + self.log.debug("expectedFiles:{}".format(files_by_aov)) + + # update the colorspace data + colorspace_data = get_color_management_preferences() + instance.data["colorspaceConfig"] = colorspace_data["config"] + instance.data["colorspaceDisplay"] = colorspace_data["display"] + instance.data["colorspaceView"] = colorspace_data["view"] + + def get_beauty_render_product(self, prefix, suffix=""): + """Return the beauty output filename if render element enabled + """ + aov_parm = ".{}".format(suffix) + beauty_product = None + if aov_parm in prefix: + beauty_product = prefix.replace(aov_parm, "") + else: + beauty_product = prefix + + return beauty_product + + def get_render_element_name(self, node, prefix, suffix=""): + """Return the output filename using the AOV prefix and suffix + """ + render_element_dict = {} + # need a rewrite + re_path = node.evalParm("render_network_render_channels") + if re_path: + node_children = hou.node(re_path).children() + for element in node_children: + if element.shaderName() != "vray:SettingsRenderChannels": + aov = str(element) + render_product = prefix.replace(suffix, aov) + render_element_dict[aov] = render_product + return render_element_dict + + def generate_expected_files(self, instance, path): + """Create expected files in instance data""" + + dir = os.path.dirname(path) + file = os.path.basename(path) + + if "#" in file: + def replace(match): + return "%0{}d".format(len(match.group())) + + file = re.sub("#+", replace, file) + + if "%" not in file: + return path + + expected_files = [] + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + for i in range(int(start), (int(end) + 1)): + expected_files.append( + os.path.join(dir, (file % i)).replace("\\", "/")) + + return expected_files diff --git a/openpype/hosts/houdini/plugins/publish/increment_current_file.py b/openpype/hosts/houdini/plugins/publish/increment_current_file.py index 16d9ef9aec..2493b28bc1 100644 --- a/openpype/hosts/houdini/plugins/publish/increment_current_file.py +++ b/openpype/hosts/houdini/plugins/publish/increment_current_file.py @@ -2,7 +2,10 @@ import pyblish.api from openpype.lib import version_up from openpype.pipeline import registered_host +from openpype.action import get_errored_plugins_from_data from openpype.hosts.houdini.api import HoudiniHost +from openpype.pipeline.publish import KnownPublishError + class IncrementCurrentFile(pyblish.api.ContextPlugin): """Increment the current file. @@ -14,17 +17,32 @@ class IncrementCurrentFile(pyblish.api.ContextPlugin): label = "Increment current file" order = pyblish.api.IntegratorOrder + 9.0 hosts = ["houdini"] - families = ["workfile"] + families = ["workfile", + "redshift_rop", + "arnold_rop", + "mantra_rop", + "karma_rop", + "usdrender"] optional = True def process(self, context): + errored_plugins = get_errored_plugins_from_data(context) + if any( + plugin.__name__ == "HoudiniSubmitPublishDeadline" + for plugin in errored_plugins + ): + raise KnownPublishError( + "Skipping incrementing current file because " + "submission to deadline failed." + ) + # Filename must not have changed since collecting host = registered_host() # type: HoudiniHost current_file = host.current_file() assert ( context.data["currentFile"] == current_file - ), "Collected filename from current scene name." + ), "Collected filename mismatches from current scene name." new_filepath = version_up(current_file) host.save_workfile(new_filepath) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index e2af0720ec..1d53802ecf 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -1,30 +1,27 @@ # -*- coding: utf-8 -*- """Library of functions useful for 3dsmax pipeline.""" -import json -import six -from pymxs import runtime as rt -from typing import Union import contextlib +import json +from typing import Any, Dict, Union +import six from openpype.pipeline.context_tools import ( - get_current_project_asset, - get_current_project -) - + get_current_project, get_current_project_asset,) +from pymxs import runtime as rt JSON_PREFIX = "JSON::" def imprint(node_name: str, data: dict) -> bool: - node = rt.getNodeByName(node_name) + node = rt.GetNodeByName(node_name) if not node: return False for k, v in data.items(): if isinstance(v, (dict, list)): - rt.setUserProp(node, k, f'{JSON_PREFIX}{json.dumps(v)}') + rt.SetUserProp(node, k, f"{JSON_PREFIX}{json.dumps(v)}") else: - rt.setUserProp(node, k, v) + rt.SetUserProp(node, k, v) return True @@ -44,7 +41,7 @@ def lsattr( Returns: list of nodes. """ - root = rt.rootnode if root is None else rt.getNodeByName(root) + root = rt.RootNode if root is None else rt.GetNodeByName(root) def output_node(node, nodes): nodes.append(node) @@ -55,16 +52,16 @@ def lsattr( output_node(root, nodes) return [ n for n in nodes - if rt.getUserProp(n, attr) == value + if rt.GetUserProp(n, attr) == value ] if value else [ n for n in nodes - if rt.getUserProp(n, attr) + if rt.GetUserProp(n, attr) ] def read(container) -> dict: data = {} - props = rt.getUserPropBuffer(container) + props = rt.GetUserPropBuffer(container) # this shouldn't happen but let's guard against it anyway if not props: return data @@ -79,29 +76,25 @@ def read(container) -> dict: value = value.strip() if isinstance(value.strip(), six.string_types) and \ value.startswith(JSON_PREFIX): - try: + with contextlib.suppress(json.JSONDecodeError): value = json.loads(value[len(JSON_PREFIX):]) - except json.JSONDecodeError: - # not a json - pass - data[key.strip()] = value - data["instance_node"] = container.name + data["instance_node"] = container.Name return data @contextlib.contextmanager def maintained_selection(): - previous_selection = rt.getCurrentSelection() + previous_selection = rt.GetCurrentSelection() try: yield finally: if previous_selection: - rt.select(previous_selection) + rt.Select(previous_selection) else: - rt.select() + rt.Select() def get_all_children(parent, node_type=None): @@ -123,7 +116,7 @@ def get_all_children(parent, node_type=None): return children child_list = list_children(parent) - return ([x for x in child_list if rt.superClassOf(x) == node_type] + return ([x for x in child_list if rt.SuperClassOf(x) == node_type] if node_type else child_list) @@ -182,7 +175,7 @@ def set_scene_resolution(width: int, height: int): """ # make sure the render dialog is closed # for the update of resolution - # Changing the Render Setup dialog settingsshould be done + # Changing the Render Setup dialog settings should be done # with the actual Render Setup dialog in a closed state. if rt.renderSceneDialog.isOpen(): rt.renderSceneDialog.close() @@ -190,6 +183,7 @@ def set_scene_resolution(width: int, height: int): rt.renderWidth = width rt.renderHeight = height + def reset_scene_resolution(): """Apply the scene resolution from the project definition @@ -212,7 +206,7 @@ def reset_scene_resolution(): set_scene_resolution(width, height) -def get_frame_range() -> dict: +def get_frame_range() -> Union[Dict[str, Any], None]: """Get the current assets frame range and handles. Returns: @@ -259,7 +253,7 @@ def reset_frame_range(fps: bool = True): frange_cmd = ( f"animationRange = interval {frame_start_handle} {frame_end_handle}" ) - rt.execute(frange_cmd) + rt.Execute(frange_cmd) set_render_frame_range(frame_start_handle, frame_end_handle) @@ -289,5 +283,5 @@ def get_max_version(): #(25000, 62, 0, 25, 0, 0, 997, 2023, "") max_info[7] = max version date """ - max_info = rt.maxversion() + max_info = rt.MaxVersion() return max_info[7] diff --git a/openpype/hosts/max/api/lib_renderproducts.py b/openpype/hosts/max/api/lib_renderproducts.py index 94b0aeb913..3074f8e170 100644 --- a/openpype/hosts/max/api/lib_renderproducts.py +++ b/openpype/hosts/max/api/lib_renderproducts.py @@ -124,7 +124,7 @@ class RenderProducts(object): """Get all the Arnold AOVs name""" aov_name = [] - amw = rt.MaxtoAOps.AOVsManagerWindow() + amw = rt.MaxToAOps.AOVsManagerWindow() aov_mgr = rt.renderers.current.AOVManager # Check if there is any aov group set in AOV manager aov_group_num = len(aov_mgr.drivers) diff --git a/openpype/hosts/max/api/pipeline.py b/openpype/hosts/max/api/pipeline.py index 50fe30b299..03b85a4066 100644 --- a/openpype/hosts/max/api/pipeline.py +++ b/openpype/hosts/max/api/pipeline.py @@ -6,7 +6,7 @@ from operator import attrgetter import json -from openpype.host import HostBase, IWorkfileHost, ILoadHost, INewPublisher +from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost import pyblish.api from openpype.pipeline import ( register_creator_plugin_path, @@ -28,7 +28,7 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") -class MaxHost(HostBase, IWorkfileHost, ILoadHost, INewPublisher): +class MaxHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): name = "max" menu = None diff --git a/openpype/hosts/max/api/plugin.py b/openpype/hosts/max/api/plugin.py index b54568b360..71a0b94e1f 100644 --- a/openpype/hosts/max/api/plugin.py +++ b/openpype/hosts/max/api/plugin.py @@ -1,15 +1,105 @@ # -*- coding: utf-8 -*- """3dsmax specific Avalon/Pyblish plugin definitions.""" -from pymxs import runtime as rt -import six from abc import ABCMeta -from openpype.pipeline import ( - CreatorError, - Creator, - CreatedInstance -) + +import six +from pymxs import runtime as rt + from openpype.lib import BoolDef -from .lib import imprint, read, lsattr +from openpype.pipeline import CreatedInstance, Creator, CreatorError + +from .lib import imprint, lsattr, read + +MS_CUSTOM_ATTRIB = """attributes "openPypeData" +( + parameters main rollout:OPparams + ( + all_handles type:#maxObjectTab tabSize:0 tabSizeVariable:on + ) + + rollout OPparams "OP Parameters" + ( + listbox list_node "Node References" items:#() + button button_add "Add to Container" + button button_del "Delete from Container" + + fn node_to_name the_node = + ( + handle = the_node.handle + obj_name = the_node.name + handle_name = obj_name + "<" + handle as string + ">" + return handle_name + ) + + on button_add pressed do + ( + current_selection = selectByName title:"Select Objects to add to + the Container" buttontext:"Add" + if current_selection == undefined then return False + temp_arr = #() + i_node_arr = #() + for c in current_selection do + ( + handle_name = node_to_name c + node_ref = NodeTransformMonitor node:c + append temp_arr handle_name + append i_node_arr node_ref + ) + all_handles = join i_node_arr all_handles + list_node.items = join temp_arr list_node.items + ) + + on button_del pressed do + ( + current_selection = selectByName title:"Select Objects to remove + from the Container" buttontext:"Remove" + if current_selection == undefined then return False + temp_arr = #() + i_node_arr = #() + new_i_node_arr = #() + new_temp_arr = #() + + for c in current_selection do + ( + node_ref = NodeTransformMonitor node:c as string + handle_name = node_to_name c + tmp_all_handles = #() + for i in all_handles do + ( + tmp = i as string + append tmp_all_handles tmp + ) + idx = finditem tmp_all_handles node_ref + if idx do + ( + new_i_node_arr = DeleteItem all_handles idx + + ) + idx = finditem list_node.items handle_name + if idx do + ( + new_temp_arr = DeleteItem list_node.items idx + ) + ) + all_handles = join i_node_arr new_i_node_arr + list_node.items = join temp_arr new_temp_arr + ) + + on OPparams open do + ( + if all_handles.count != 0 then + ( + temp_arr = #() + for x in all_handles do + ( + handle_name = node_to_name x.node + append temp_arr handle_name + ) + list_node.items = temp_arr + ) + ) + ) +)""" class OpenPypeCreatorError(CreatorError): @@ -20,28 +110,40 @@ class MaxCreatorBase(object): @staticmethod def cache_subsets(shared_data): - if shared_data.get("max_cached_subsets") is None: - shared_data["max_cached_subsets"] = {} - cached_instances = lsattr("id", "pyblish.avalon.instance") - for i in cached_instances: - creator_id = rt.getUserProp(i, "creator_identifier") - if creator_id not in shared_data["max_cached_subsets"]: - shared_data["max_cached_subsets"][creator_id] = [i.name] - else: - shared_data[ - "max_cached_subsets"][creator_id].append(i.name) # noqa + if shared_data.get("max_cached_subsets") is not None: + return shared_data + + shared_data["max_cached_subsets"] = {} + cached_instances = lsattr("id", "pyblish.avalon.instance") + for i in cached_instances: + creator_id = rt.GetUserProp(i, "creator_identifier") + if creator_id not in shared_data["max_cached_subsets"]: + shared_data["max_cached_subsets"][creator_id] = [i.name] + else: + shared_data[ + "max_cached_subsets"][creator_id].append(i.name) return shared_data @staticmethod - def create_instance_node(node_name: str, parent: str = ""): - parent_node = rt.getNodeByName(parent) if parent else rt.rootScene - if not parent_node: - raise OpenPypeCreatorError(f"Specified parent {parent} not found") + def create_instance_node(node): + """Create instance node. - container = rt.container(name=node_name) - container.Parent = parent_node + If the supplied node is existing node, it will be used to hold the + instance, otherwise new node of type Dummy will be created. - return container + Args: + node (rt.MXSWrapperBase, str): Node or node name to use. + + Returns: + instance + """ + if isinstance(node, str): + node = rt.Container(name=node) + + attrs = rt.Execute(MS_CUSTOM_ATTRIB) + rt.custAttributes.add(node.baseObject, attrs) + + return node @six.add_metaclass(ABCMeta) @@ -50,7 +152,7 @@ class MaxCreator(Creator, MaxCreatorBase): def create(self, subset_name, instance_data, pre_create_data): if pre_create_data.get("use_selection"): - self.selected_nodes = rt.getCurrentSelection() + self.selected_nodes = rt.GetCurrentSelection() instance_node = self.create_instance_node(subset_name) instance_data["instance_node"] = instance_node.name @@ -60,8 +162,16 @@ class MaxCreator(Creator, MaxCreatorBase): instance_data, self ) - for node in self.selected_nodes: - node.Parent = instance_node + if pre_create_data.get("use_selection"): + + node_list = [] + for i in self.selected_nodes: + node_ref = rt.NodeTransformMonitor(node=i) + node_list.append(node_ref) + + # Setting the property + rt.setProperty( + instance_node.openPypeData, "all_handles", node_list) self._add_instance_to_context(instance) imprint(instance_node.name, instance.data_to_store()) @@ -70,10 +180,9 @@ class MaxCreator(Creator, MaxCreatorBase): def collect_instances(self): self.cache_subsets(self.collection_shared_data) - for instance in self.collection_shared_data[ - "max_cached_subsets"].get(self.identifier, []): + for instance in self.collection_shared_data["max_cached_subsets"].get(self.identifier, []): # noqa created_instance = CreatedInstance.from_existing( - read(rt.getNodeByName(instance)), self + read(rt.GetNodeByName(instance)), self ) self._add_instance_to_context(created_instance) @@ -98,12 +207,12 @@ class MaxCreator(Creator, MaxCreatorBase): """ for instance in instances: - instance_node = rt.getNodeByName( + instance_node = rt.GetNodeByName( instance.data.get("instance_node")) if instance_node: - rt.select(instance_node) - rt.execute(f'for o in selection do for c in o.children do c.parent = undefined') # noqa - rt.delete(instance_node) + count = rt.custAttributes.count(instance_node) + rt.custAttributes.delete(instance_node, count) + rt.Delete(instance_node) self._remove_instance_from_context(instance) diff --git a/openpype/hosts/max/plugins/create/create_camera.py b/openpype/hosts/max/plugins/create/create_camera.py index 91d0d4d3dc..804d629ec7 100644 --- a/openpype/hosts/max/plugins/create/create_camera.py +++ b/openpype/hosts/max/plugins/create/create_camera.py @@ -1,26 +1,11 @@ # -*- coding: utf-8 -*- """Creator plugin for creating camera.""" from openpype.hosts.max.api import plugin -from openpype.pipeline import CreatedInstance class CreateCamera(plugin.MaxCreator): + """Creator plugin for Camera.""" identifier = "io.openpype.creators.max.camera" label = "Camera" family = "camera" 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(CreateCamera, 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/create/create_maxScene.py b/openpype/hosts/max/plugins/create/create_maxScene.py index 7900336f32..851e26dda2 100644 --- a/openpype/hosts/max/plugins/create/create_maxScene.py +++ b/openpype/hosts/max/plugins/create/create_maxScene.py @@ -1,26 +1,11 @@ # -*- 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): + """Creator plugin for 3ds max scenes.""" identifier = "io.openpype.creators.max.maxScene" label = "Max Scene" 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/create/create_model.py b/openpype/hosts/max/plugins/create/create_model.py index e7ae3af9db..fc09d475ef 100644 --- a/openpype/hosts/max/plugins/create/create_model.py +++ b/openpype/hosts/max/plugins/create/create_model.py @@ -1,28 +1,11 @@ # -*- coding: utf-8 -*- """Creator plugin for model.""" from openpype.hosts.max.api import plugin -from openpype.pipeline import CreatedInstance class CreateModel(plugin.MaxCreator): + """Creator plugin for Model.""" identifier = "io.openpype.creators.max.model" label = "Model" family = "model" icon = "gear" - - def create(self, subset_name, instance_data, pre_create_data): - from pymxs import runtime as rt - instance = super(CreateModel, 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 - sel_obj = None - if self.selected_nodes: - sel_obj = list(self.selected_nodes) - 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/create/create_pointcache.py b/openpype/hosts/max/plugins/create/create_pointcache.py index 32f0838471..c2d11f4c32 100644 --- a/openpype/hosts/max/plugins/create/create_pointcache.py +++ b/openpype/hosts/max/plugins/create/create_pointcache.py @@ -1,22 +1,11 @@ # -*- coding: utf-8 -*- """Creator plugin for creating pointcache alembics.""" from openpype.hosts.max.api import plugin -from openpype.pipeline import CreatedInstance class CreatePointCache(plugin.MaxCreator): + """Creator plugin for Point caches.""" identifier = "io.openpype.creators.max.pointcache" label = "Point Cache" family = "pointcache" icon = "gear" - - def create(self, subset_name, instance_data, pre_create_data): - # from pymxs import runtime as rt - - _ = super(CreatePointCache, self).create( - subset_name, - instance_data, - pre_create_data) # type: CreatedInstance - - # for additional work on the node: - # instance_node = rt.getNodeByName(instance.get("instance_node")) diff --git a/openpype/hosts/max/plugins/create/create_pointcloud.py b/openpype/hosts/max/plugins/create/create_pointcloud.py index c83acac3df..bc7706069d 100644 --- a/openpype/hosts/max/plugins/create/create_pointcloud.py +++ b/openpype/hosts/max/plugins/create/create_pointcloud.py @@ -1,26 +1,11 @@ # -*- 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): + """Creator plugin for Point Clouds.""" 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/create/create_redshift_proxy.py b/openpype/hosts/max/plugins/create/create_redshift_proxy.py index 698ea82b69..6eb59f0a73 100644 --- a/openpype/hosts/max/plugins/create/create_redshift_proxy.py +++ b/openpype/hosts/max/plugins/create/create_redshift_proxy.py @@ -9,10 +9,3 @@ class CreateRedshiftProxy(plugin.MaxCreator): label = "Redshift Proxy" family = "redshiftproxy" icon = "gear" - - def create(self, subset_name, instance_data, pre_create_data): - - _ = super(CreateRedshiftProxy, self).create( - subset_name, - instance_data, - pre_create_data) # type: CreatedInstance diff --git a/openpype/hosts/max/plugins/create/create_render.py b/openpype/hosts/max/plugins/create/create_render.py index 5ad895b86e..235046684e 100644 --- a/openpype/hosts/max/plugins/create/create_render.py +++ b/openpype/hosts/max/plugins/create/create_render.py @@ -2,11 +2,11 @@ """Creator plugin for creating camera.""" import os from openpype.hosts.max.api import plugin -from openpype.pipeline import CreatedInstance from openpype.hosts.max.api.lib_rendersettings import RenderSettings class CreateRender(plugin.MaxCreator): + """Creator plugin for Renders.""" identifier = "io.openpype.creators.max.render" label = "Render" family = "maxrender" @@ -22,22 +22,11 @@ class CreateRender(plugin.MaxCreator): instance = super(CreateRender, self).create( subset_name, instance_data, - pre_create_data) # type: CreatedInstance + pre_create_data) container_name = instance.data.get("instance_node") - container = rt.getNodeByName(container_name) - # TODO: Disable "Add to Containers?" Panel - # parent the selected cameras into the container - for obj in sel_obj: - obj.parent = container - # for additional work on the node: - # instance_node = rt.getNodeByName(instance.get("instance_node")) - - # make sure the render dialog is closed - # for the update of resolution - # Changing the Render Setup dialog settings should be done - # with the actual Render Setup dialog in a closed state. - - # set viewport camera for rendering(mandatory for deadline) - RenderSettings().set_render_camera(sel_obj) + sel_obj = self.selected_nodes + if sel_obj: + # set viewport camera for rendering(mandatory for deadline) + RenderSettings(self.project_settings).set_render_camera(sel_obj) # set output paths for rendering(mandatory for deadline) RenderSettings().render_output(container_name) diff --git a/openpype/hosts/max/plugins/load/load_camera_fbx.py b/openpype/hosts/max/plugins/load/load_camera_fbx.py index 0c5dd762cf..c51900dbb7 100644 --- a/openpype/hosts/max/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/max/plugins/load/load_camera_fbx.py @@ -1,14 +1,12 @@ import os -from openpype.pipeline import ( - load, - get_representation_path -) + +from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import containerise -from openpype.hosts.max.api import lib +from openpype.pipeline import get_representation_path, load class FbxLoader(load.LoaderPlugin): - """Fbx Loader""" + """Fbx Loader.""" families = ["camera"] representations = ["fbx"] @@ -24,17 +22,17 @@ class FbxLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Camera", True) rt.FBXImporterSetParam("AxisConversionMethod", True) rt.FBXImporterSetParam("Preserveinstances", True) - rt.importFile( + rt.ImportFile( filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.getNodeByName(f"{name}") + container = rt.GetNodeByName(f"{name}") if not container: - container = rt.container() + container = rt.Container() container.name = f"{name}" - for selection in rt.getCurrentSelection(): + for selection in rt.GetCurrentSelection(): selection.Parent = container return containerise( @@ -44,18 +42,33 @@ class FbxLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) + node = rt.GetNodeByName(container["instance_node"]) + rt.Select(node.Children) + fbx_reimport_cmd = ( + f""" - fbx_objects = self.get_container_children(node) - for fbx_object in fbx_objects: - fbx_object.source = path +FBXImporterSetParam "Animation" true +FBXImporterSetParam "Cameras" true +FBXImporterSetParam "AxisConversionMethod" true +FbxExporterSetParam "UpAxis" "Y" +FbxExporterSetParam "Preserveinstances" true + +importFile @"{path}" #noPrompt using:FBXIMP + """) + rt.Execute(fbx_reimport_cmd) + + with maintained_selection(): + rt.Select(node) lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) + def switch(self, container, representation): + self.update(container, representation) + def remove(self, container): from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_max_scene.py b/openpype/hosts/max/plugins/load/load_max_scene.py index 4b19cd671f..e3fb34f5bc 100644 --- a/openpype/hosts/max/plugins/load/load_max_scene.py +++ b/openpype/hosts/max/plugins/load/load_max_scene.py @@ -1,13 +1,12 @@ 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 +from openpype.hosts.max.api.pipeline import containerise +from openpype.pipeline import get_representation_path, load class MaxSceneLoader(load.LoaderPlugin): - """Max Scene Loader""" + """Max Scene Loader.""" families = ["camera", "maxScene", @@ -23,23 +22,11 @@ class MaxSceneLoader(load.LoaderPlugin): path = os.path.normpath(self.fname) # import the max scene by using "merge file" path = path.replace('\\', '/') - - merge_before = { - c for c in rt.rootNode.Children - if rt.classOf(c) == rt.Container - } - rt.mergeMaxFile(path) - - merge_after = { - c for c in rt.rootNode.Children - if rt.classOf(c) == rt.Container - } - max_containers = merge_after.difference(merge_before) - - if len(max_containers) != 1: - self.log.error("Something failed when loading.") - - max_container = max_containers.pop() + rt.MergeMaxFile(path) + max_objects = rt.getLastMergedNodes() + max_container = rt.Container(name=f"{name}") + for max_object in max_objects: + max_object.Parent = max_container return containerise( name, [max_container], context, loader=self.__class__.__name__) @@ -48,17 +35,27 @@ class MaxSceneLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) - max_objects = node.Children + node_name = container["instance_node"] + + rt.MergeMaxFile(path, + rt.Name("noRedraw"), + rt.Name("deleteOldDups"), + rt.Name("useSceneMtlDups")) + + max_objects = rt.getLastMergedNodes() + container_node = rt.GetNodeByName(node_name) for max_object in max_objects: - max_object.source = path + max_object.Parent = container_node lib.imprint(container["instance_node"], { "representation": str(representation["_id"]) }) + def switch(self, container, representation): + self.update(container, representation) + def remove(self, container): from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_model.py b/openpype/hosts/max/plugins/load/load_model.py index 95ee014e07..58c6d3c889 100644 --- a/openpype/hosts/max/plugins/load/load_model.py +++ b/openpype/hosts/max/plugins/load/load_model.py @@ -1,8 +1,5 @@ - import os -from openpype.pipeline import ( - load, get_representation_path -) +from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import containerise from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection @@ -24,24 +21,20 @@ class ModelAbcLoader(load.LoaderPlugin): file_path = os.path.normpath(self.fname) abc_before = { - c for c in rt.rootNode.Children + c + for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } - abc_import_cmd = (f""" -AlembicImport.ImportToRoot = false -AlembicImport.CustomAttributes = true -AlembicImport.UVs = true -AlembicImport.VertexColors = true - -importFile @"{file_path}" #noPrompt - """) - - self.log.debug(f"Executing command: {abc_import_cmd}") - rt.execute(abc_import_cmd) + rt.AlembicImport.ImportToRoot = False + rt.AlembicImport.CustomAttributes = True + rt.AlembicImport.UVs = True + rt.AlembicImport.VertexColors = True + rt.importFile(file_path, rt.name("noPrompt")) abc_after = { - c for c in rt.rootNode.Children + c + for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } @@ -54,31 +47,34 @@ importFile @"{file_path}" #noPrompt abc_container = abc_containers.pop() return containerise( - name, [abc_container], context, loader=self.__class__.__name__) + name, [abc_container], context, loader=self.__class__.__name__ + ) def update(self, container, representation): from pymxs import runtime as rt - path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) - rt.select(node.Children) - for alembic in rt.selection: - abc = rt.getNodeByName(alembic.name) - rt.select(abc.Children) - for abc_con in rt.selection: - container = rt.getNodeByName(abc_con.name) + path = get_representation_path(representation) + node = rt.GetNodeByName(container["instance_node"]) + rt.Select(node.Children) + + for alembic in rt.Selection: + abc = rt.GetNodeByName(alembic.name) + rt.Select(abc.Children) + for abc_con in rt.Selection: + container = rt.GetNodeByName(abc_con.name) container.source = path - rt.select(container.Children) - for abc_obj in rt.selection: - alembic_obj = rt.getNodeByName(abc_obj.name) + rt.Select(container.Children) + for abc_obj in rt.Selection: + alembic_obj = rt.GetNodeByName(abc_obj.name) alembic_obj.source = path with maintained_selection(): - rt.select(node) + rt.Select(node) - lib.imprint(container["instance_node"], { - "representation": str(representation["_id"]) - }) + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) def switch(self, container, representation): self.update(container, representation) @@ -86,8 +82,8 @@ importFile @"{file_path}" #noPrompt def remove(self, container): from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) @staticmethod def get_container_children(parent, type_name): @@ -102,7 +98,7 @@ importFile @"{file_path}" #noPrompt filtered = [] for child in list_children(parent): - class_type = str(rt.classOf(child.baseObject)) + class_type = str(rt.ClassOf(child.baseObject)) if class_type == type_name: filtered.append(child) diff --git a/openpype/hosts/max/plugins/load/load_model_fbx.py b/openpype/hosts/max/plugins/load/load_model_fbx.py index 01e6acae12..663f79f9f5 100644 --- a/openpype/hosts/max/plugins/load/load_model_fbx.py +++ b/openpype/hosts/max/plugins/load/load_model_fbx.py @@ -1,15 +1,12 @@ import os -from openpype.pipeline import ( - load, - get_representation_path -) +from openpype.pipeline import load, get_representation_path from openpype.hosts.max.api.pipeline import containerise from openpype.hosts.max.api import lib from openpype.hosts.max.api.lib import maintained_selection class FbxModelLoader(load.LoaderPlugin): - """Fbx Model Loader""" + """Fbx Model Loader.""" families = ["model"] representations = ["fbx"] @@ -24,46 +21,40 @@ class FbxModelLoader(load.LoaderPlugin): rt.FBXImporterSetParam("Animation", False) rt.FBXImporterSetParam("Cameras", False) rt.FBXImporterSetParam("Preserveinstances", True) - rt.importFile( - filepath, - rt.name("noPrompt"), - using=rt.FBXIMP) + rt.importFile(filepath, rt.name("noPrompt"), using=rt.FBXIMP) - container = rt.getNodeByName(f"{name}") + container = rt.GetNodeByName(name) if not container: - container = rt.container() - container.name = f"{name}" + container = rt.Container() + container.name = name - for selection in rt.getCurrentSelection(): + for selection in rt.GetCurrentSelection(): selection.Parent = container return containerise( - name, [container], context, loader=self.__class__.__name__) + name, [container], context, loader=self.__class__.__name__ + ) def update(self, container, representation): from pymxs import runtime as rt - path = get_representation_path(representation) node = rt.getNodeByName(container["instance_node"]) rt.select(node.Children) - fbx_reimport_cmd = ( - f""" -FBXImporterSetParam "Animation" false -FBXImporterSetParam "Cameras" false -FBXImporterSetParam "AxisConversionMethod" true -FbxExporterSetParam "UpAxis" "Y" -FbxExporterSetParam "Preserveinstances" true -importFile @"{path}" #noPrompt using:FBXIMP - """) - rt.execute(fbx_reimport_cmd) + rt.FBXImporterSetParam("Animation", False) + rt.FBXImporterSetParam("Cameras", False) + rt.FBXImporterSetParam("AxisConversionMethod", True) + rt.FBXImporterSetParam("UpAxis", "Y") + rt.FBXImporterSetParam("Preserveinstances", True) + rt.importFile(path, rt.name("noPrompt"), using=rt.FBXIMP) with maintained_selection(): - rt.select(node) + rt.Select(node) - lib.imprint(container["instance_node"], { - "representation": str(representation["_id"]) - }) + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) def switch(self, container, representation): self.update(container, representation) @@ -71,5 +62,5 @@ importFile @"{path}" #noPrompt using:FBXIMP def remove(self, container): from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_model_obj.py b/openpype/hosts/max/plugins/load/load_model_obj.py index c55e462111..77d4e08cfb 100644 --- a/openpype/hosts/max/plugins/load/load_model_obj.py +++ b/openpype/hosts/max/plugins/load/load_model_obj.py @@ -1,15 +1,13 @@ 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 from openpype.hosts.max.api.lib import maintained_selection +from openpype.hosts.max.api.pipeline import containerise +from openpype.pipeline import get_representation_path, load class ObjLoader(load.LoaderPlugin): - """Obj Loader""" + """Obj Loader.""" families = ["model"] representations = ["obj"] @@ -21,18 +19,18 @@ class ObjLoader(load.LoaderPlugin): from pymxs import runtime as rt filepath = os.path.normpath(self.fname) - self.log.debug(f"Executing command to import..") + self.log.debug("Executing command to import..") - rt.execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') + rt.Execute(f'importFile @"{filepath}" #noPrompt using:ObjImp') # create "missing" container for obj import - container = rt.container() - container.name = f"{name}" + container = rt.Container() + container.name = name # get current selection - for selection in rt.getCurrentSelection(): + for selection in rt.GetCurrentSelection(): selection.Parent = container - asset = rt.getNodeByName(f"{name}") + asset = rt.GetNodeByName(name) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -42,27 +40,30 @@ class ObjLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - node = rt.getNodeByName(node_name) + node = rt.GetNodeByName(node_name) instance_name, _ = node_name.split("_") - container = rt.getNodeByName(instance_name) - for n in container.Children: - rt.delete(n) + container = rt.GetNodeByName(instance_name) + for child in container.Children: + rt.Delete(child) - rt.execute(f'importFile @"{path}" #noPrompt using:ObjImp') + rt.Execute(f'importFile @"{path}" #noPrompt using:ObjImp') # get current selection - for selection in rt.getCurrentSelection(): + for selection in rt.GetCurrentSelection(): selection.Parent = container with maintained_selection(): - rt.select(node) + rt.Select(node) lib.imprint(node_name, { "representation": str(representation["_id"]) }) + def switch(self, container, representation): + self.update(container, representation) + def remove(self, container): from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_model_usd.py b/openpype/hosts/max/plugins/load/load_model_usd.py index 143f91f40b..2b34669278 100644 --- a/openpype/hosts/max/plugins/load/load_model_usd.py +++ b/openpype/hosts/max/plugins/load/load_model_usd.py @@ -1,10 +1,9 @@ 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 from openpype.hosts.max.api.lib import maintained_selection +from openpype.hosts.max.api.pipeline import containerise +from openpype.pipeline import get_representation_path, load class ModelUSDLoader(load.LoaderPlugin): @@ -19,6 +18,7 @@ class ModelUSDLoader(load.LoaderPlugin): def load(self, context, name=None, namespace=None, data=None): from pymxs import runtime as rt + # asset_filepath filepath = os.path.normpath(self.fname) import_options = rt.USDImporter.CreateOptions() @@ -27,11 +27,11 @@ class ModelUSDLoader(load.LoaderPlugin): log_filepath = filepath.replace(ext, "txt") rt.LogPath = log_filepath - rt.LogLevel = rt.name('info') + rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(filepath, importOptions=import_options) - asset = rt.getNodeByName(f"{name}") + asset = rt.GetNodeByName(name) return containerise( name, [asset], context, loader=self.__class__.__name__) @@ -41,11 +41,11 @@ class ModelUSDLoader(load.LoaderPlugin): path = get_representation_path(representation) node_name = container["instance_node"] - node = rt.getNodeByName(node_name) + node = rt.GetNodeByName(node_name) for n in node.Children: for r in n.Children: - rt.delete(r) - rt.delete(n) + rt.Delete(r) + rt.Delete(n) instance_name, _ = node_name.split("_") import_options = rt.USDImporter.CreateOptions() @@ -54,15 +54,15 @@ class ModelUSDLoader(load.LoaderPlugin): log_filepath = path.replace(ext, "txt") rt.LogPath = log_filepath - rt.LogLevel = rt.name('info') + rt.LogLevel = rt.Name("info") rt.USDImporter.importFile(path, importOptions=import_options) - asset = rt.getNodeByName(f"{instance_name}") + asset = rt.GetNodeByName(instance_name) asset.Parent = node with maintained_selection(): - rt.select(node) + rt.Select(node) lib.imprint(node_name, { "representation": str(representation["_id"]) @@ -74,5 +74,5 @@ class ModelUSDLoader(load.LoaderPlugin): def remove(self, container): from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/load/load_pointcache.py b/openpype/hosts/max/plugins/load/load_pointcache.py index b3e12adc7b..cadbe7cac2 100644 --- a/openpype/hosts/max/plugins/load/load_pointcache.py +++ b/openpype/hosts/max/plugins/load/load_pointcache.py @@ -5,19 +5,15 @@ Because of limited api, alembics can be only loaded, but not easily updated. """ import os -from openpype.pipeline import ( - load, get_representation_path -) +from openpype.pipeline import load, get_representation_path +from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import containerise -from openpype.hosts.max.api import lib class AbcLoader(load.LoaderPlugin): """Alembic loader.""" - families = ["camera", - "animation", - "pointcache"] + families = ["camera", "animation", "pointcache"] label = "Load Alembic" representations = ["abc"] order = -10 @@ -30,21 +26,17 @@ class AbcLoader(load.LoaderPlugin): file_path = os.path.normpath(self.fname) abc_before = { - c for c in rt.rootNode.Children + c + for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } - abc_export_cmd = (f""" -AlembicImport.ImportToRoot = false - -importFile @"{file_path}" #noPrompt - """) - - self.log.debug(f"Executing command: {abc_export_cmd}") - rt.execute(abc_export_cmd) + rt.AlembicImport.ImportToRoot = False + rt.importFile(file_path, rt.name("noPrompt")) abc_after = { - c for c in rt.rootNode.Children + c + for c in rt.rootNode.Children if rt.classOf(c) == rt.AlembicContainer } @@ -56,22 +48,42 @@ importFile @"{file_path}" #noPrompt abc_container = abc_containers.pop() + for abc in rt.GetCurrentSelection(): + for cam_shape in abc.Children: + cam_shape.playbackType = 2 + return containerise( - name, [abc_container], context, loader=self.__class__.__name__) + name, [abc_container], context, loader=self.__class__.__name__ + ) def update(self, container, representation): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) + node = rt.GetNodeByName(container["instance_node"]) alembic_objects = self.get_container_children(node, "AlembicObject") for alembic_object in alembic_objects: alembic_object.source = path - lib.imprint(container["instance_node"], { - "representation": str(representation["_id"]) - }) + lib.imprint( + container["instance_node"], + {"representation": str(representation["_id"])}, + ) + + with maintained_selection(): + rt.Select(node.Children) + + for alembic in rt.Selection: + abc = rt.GetNodeByName(alembic.name) + rt.Select(abc.Children) + for abc_con in rt.Selection: + container = rt.GetNodeByName(abc_con.name) + container.source = path + rt.Select(container.Children) + for abc_obj in rt.Selection: + alembic_obj = rt.GetNodeByName(abc_obj.name) + alembic_obj.source = path def switch(self, container, representation): self.update(container, representation) @@ -79,8 +91,8 @@ importFile @"{file_path}" #noPrompt def remove(self, container): from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) @staticmethod def get_container_children(parent, type_name): diff --git a/openpype/hosts/max/plugins/load/load_pointcloud.py b/openpype/hosts/max/plugins/load/load_pointcloud.py index 27bc88b4f3..8634e1d51f 100644 --- a/openpype/hosts/max/plugins/load/load_pointcloud.py +++ b/openpype/hosts/max/plugins/load/load_pointcloud.py @@ -1,13 +1,12 @@ import os -from openpype.pipeline import ( - load, get_representation_path -) + +from openpype.hosts.max.api import lib, maintained_selection from openpype.hosts.max.api.pipeline import containerise -from openpype.hosts.max.api import lib +from openpype.pipeline import get_representation_path, load class PointCloudLoader(load.LoaderPlugin): - """Point Cloud Loader""" + """Point Cloud Loader.""" families = ["pointcloud"] representations = ["prt"] @@ -23,7 +22,7 @@ class PointCloudLoader(load.LoaderPlugin): obj = rt.tyCache() obj.filename = filepath - prt_container = rt.getNodeByName(f"{obj.name}") + prt_container = rt.GetNodeByName(obj.name) return containerise( name, [prt_container], context, loader=self.__class__.__name__) @@ -33,19 +32,23 @@ class PointCloudLoader(load.LoaderPlugin): from pymxs import runtime as rt path = get_representation_path(representation) - node = rt.getNodeByName(container["instance_node"]) + node = rt.GetNodeByName(container["instance_node"]) + with maintained_selection(): + rt.Select(node.Children) + for prt in rt.Selection: + prt_object = rt.GetNodeByName(prt.name) + prt_object.filename = path - 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"]) + }) - lib.imprint(container["instance_node"], { - "representation": str(representation["_id"]) - }) + def switch(self, container, representation): + self.update(container, representation) def remove(self, container): """remove the container""" from pymxs import runtime as rt - node = rt.getNodeByName(container["instance_node"]) - rt.delete(node) + node = rt.GetNodeByName(container["instance_node"]) + rt.Delete(node) diff --git a/openpype/hosts/max/plugins/publish/collect_members.py b/openpype/hosts/max/plugins/publish/collect_members.py new file mode 100644 index 0000000000..812d82ff26 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_members.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +"""Collect instance members.""" +import pyblish.api +from pymxs import runtime as rt + + +class CollectMembers(pyblish.api.InstancePlugin): + """Collect Set Members.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect Instance Members" + hosts = ['max'] + + def process(self, instance): + + if instance.data.get("instance_node"): + container = rt.GetNodeByName(instance.data["instance_node"]) + instance.data["members"] = [ + member.node for member + in container.openPypeData.all_handles + ] + self.log.debug("{}".format(instance.data["members"])) diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index 6b3bb178a3..b42732e70d 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -1,14 +1,14 @@ import os + import pyblish.api -from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt -from openpype.hosts.max.api import maintained_selection, get_all_children + +from openpype.hosts.max.api import maintained_selection +from openpype.pipeline import OptionalPyblishPluginMixin, publish class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): - """ - Extract Camera with AlembicExport - """ + """Extract Camera with AlembicExport.""" order = pyblish.api.ExtractorOrder - 0.1 label = "Extract Alembic Camera" @@ -31,20 +31,21 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): path = os.path.join(stagingdir, filename) # We run the render - self.log.info("Writing alembic '%s' to '%s'" % (filename, stagingdir)) + self.log.info(f"Writing alembic '{filename}' to '{stagingdir}'") - rt.AlembicExport.ArchiveType = rt.name("ogawa") - rt.AlembicExport.CoordinateSystem = rt.name("maya") + rt.AlembicExport.ArchiveType = rt.Name("ogawa") + rt.AlembicExport.CoordinateSystem = rt.Name("maya") rt.AlembicExport.StartFrame = start rt.AlembicExport.EndFrame = end rt.AlembicExport.CustomAttributes = True with maintained_selection(): # select and export - rt.select(get_all_children(rt.getNodeByName(container))) - rt.exportFile( + node_list = instance.data["members"] + rt.Select(node_list) + rt.ExportFile( path, - rt.name("noPrompt"), + rt.Name("noPrompt"), selectedOnly=True, using=rt.AlembicExport, ) @@ -58,6 +59,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): "ext": "abc", "files": filename, "stagingDir": stagingdir, + "frameStart": start, + "frameEnd": end, } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, path)) + self.log.info(f"Extracted instance '{instance.name}' to: {path}") diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py index 4b4b349e19..06ac3da093 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py @@ -1,14 +1,14 @@ import os + import pyblish.api -from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt -from openpype.hosts.max.api import maintained_selection, get_all_children + +from openpype.hosts.max.api import maintained_selection +from openpype.pipeline import OptionalPyblishPluginMixin, publish class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): - """ - Extract Camera with FbxExporter - """ + """Extract Camera with FbxExporter.""" order = pyblish.api.ExtractorOrder - 0.2 label = "Extract Fbx Camera" @@ -26,7 +26,7 @@ class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): filename = "{name}.fbx".format(**instance.data) filepath = os.path.join(stagingdir, filename) - self.log.info("Writing fbx file '%s' to '%s'" % (filename, filepath)) + self.log.info(f"Writing fbx file '{filename}' to '{filepath}'") rt.FBXExporterSetParam("Animation", True) rt.FBXExporterSetParam("Cameras", True) @@ -36,10 +36,11 @@ class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): with maintained_selection(): # select and export - rt.select(get_all_children(rt.getNodeByName(container))) - rt.exportFile( + node_list = instance.data["members"] + rt.Select(node_list) + rt.ExportFile( filepath, - rt.name("noPrompt"), + rt.Name("noPrompt"), selectedOnly=True, using=rt.FBXEXP, ) @@ -55,6 +56,4 @@ class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): "stagingDir": stagingdir, } instance.data["representations"].append(representation) - self.log.info( - "Extracted instance '%s' to: %s" % (instance.name, filepath) - ) + self.log.info(f"Extracted instance '{instance.name}' to: {filepath}") 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 f0c2aff7f3..de5db9ab56 100644 --- a/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py +++ b/openpype/hosts/max/plugins/publish/extract_max_scene_raw.py @@ -2,7 +2,6 @@ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt -from openpype.hosts.max.api import get_all_children class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin): @@ -33,7 +32,7 @@ class ExtractMaxSceneRaw(publish.Extractor, OptionalPyblishPluginMixin): if "representations" not in instance.data: instance.data["representations"] = [] - nodes = get_all_children(rt.getNodeByName(container)) + nodes = instance.data["members"] rt.saveNodes(nodes, max_path, quiet=True) self.log.info("Performing Extraction ...") diff --git a/openpype/hosts/max/plugins/publish/extract_model.py b/openpype/hosts/max/plugins/publish/extract_model.py index 4c7c98e2cc..c7ecf7efc9 100644 --- a/openpype/hosts/max/plugins/publish/extract_model.py +++ b/openpype/hosts/max/plugins/publish/extract_model.py @@ -2,7 +2,7 @@ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt -from openpype.hosts.max.api import maintained_selection, get_all_children +from openpype.hosts.max.api import maintained_selection class ExtractModel(publish.Extractor, OptionalPyblishPluginMixin): @@ -40,7 +40,8 @@ class ExtractModel(publish.Extractor, OptionalPyblishPluginMixin): with maintained_selection(): # select and export - rt.select(get_all_children(rt.getNodeByName(container))) + node_list = instance.data["members"] + rt.Select(node_list) rt.exportFile( filepath, rt.name("noPrompt"), diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_model_fbx.py index e6ccb24cdd..56c2cadd94 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_model_fbx.py @@ -2,7 +2,7 @@ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt -from openpype.hosts.max.api import maintained_selection, get_all_children +from openpype.hosts.max.api import maintained_selection class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): @@ -22,6 +22,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): container = instance.data["instance_node"] + self.log.info("Extracting Geometry ...") stagingdir = self.staging_dir(instance) @@ -39,7 +40,8 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): with maintained_selection(): # select and export - rt.select(get_all_children(rt.getNodeByName(container))) + node_list = instance.data["members"] + rt.Select(node_list) rt.exportFile( filepath, rt.name("noPrompt"), diff --git a/openpype/hosts/max/plugins/publish/extract_model_obj.py b/openpype/hosts/max/plugins/publish/extract_model_obj.py index ed3d68c990..4fde65cf22 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_obj.py +++ b/openpype/hosts/max/plugins/publish/extract_model_obj.py @@ -2,7 +2,7 @@ import os import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt -from openpype.hosts.max.api import maintained_selection, get_all_children +from openpype.hosts.max.api import maintained_selection class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): @@ -31,7 +31,8 @@ class ExtractModelObj(publish.Extractor, OptionalPyblishPluginMixin): with maintained_selection(): # select and export - rt.select(get_all_children(rt.getNodeByName(container))) + node_list = instance.data["members"] + rt.Select(node_list) rt.exportFile( filepath, rt.name("noPrompt"), diff --git a/openpype/hosts/max/plugins/publish/extract_model_usd.py b/openpype/hosts/max/plugins/publish/extract_model_usd.py index 0bed2d855e..da37c77bf7 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_usd.py +++ b/openpype/hosts/max/plugins/publish/extract_model_usd.py @@ -1,20 +1,15 @@ import os + import pyblish.api -from openpype.pipeline import ( - publish, - OptionalPyblishPluginMixin -) from pymxs import runtime as rt -from openpype.hosts.max.api import ( - maintained_selection -) + +from openpype.hosts.max.api import maintained_selection +from openpype.pipeline import OptionalPyblishPluginMixin, publish class ExtractModelUSD(publish.Extractor, OptionalPyblishPluginMixin): - """ - Extract Geometry in USDA Format - """ + """Extract Geometry in USDA Format.""" order = pyblish.api.ExtractorOrder - 0.05 label = "Extract Geometry (USD)" @@ -26,31 +21,28 @@ class ExtractModelUSD(publish.Extractor, if not self.is_active(instance.data): return - container = instance.data["instance_node"] - self.log.info("Extracting Geometry ...") stagingdir = self.staging_dir(instance) asset_filename = "{name}.usda".format(**instance.data) asset_filepath = os.path.join(stagingdir, asset_filename) - self.log.info("Writing USD '%s' to '%s'" % (asset_filepath, - stagingdir)) + self.log.info(f"Writing USD '{asset_filepath}' to '{stagingdir}'") log_filename = "{name}.txt".format(**instance.data) log_filepath = os.path.join(stagingdir, log_filename) - self.log.info("Writing log '%s' to '%s'" % (log_filepath, - stagingdir)) + self.log.info(f"Writing log '{log_filepath}' to '{stagingdir}'") # get the nodes which need to be exported export_options = self.get_export_options(log_filepath) with maintained_selection(): # select and export - node_list = self.get_node_list(container) + node_list = instance.data["members"] + rt.Select(node_list) rt.USDExporter.ExportFile(asset_filepath, exportOptions=export_options, - contentSource=rt.name("selected"), + contentSource=rt.Name("selected"), nodeList=node_list) self.log.info("Performing Extraction ...") @@ -73,25 +65,11 @@ class ExtractModelUSD(publish.Extractor, } instance.data["representations"].append(log_representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - asset_filepath)) + self.log.info( + f"Extracted instance '{instance.name}' to: {asset_filepath}") - def get_node_list(self, container): - """ - Get the target nodes which are - the children of the container - """ - node_list = [] - - container_node = rt.getNodeByName(container) - target_node = container_node.Children - rt.select(target_node) - for sel in rt.selection: - node_list.append(sel) - - return node_list - - def get_export_options(self, log_path): + @staticmethod + def get_export_options(log_path): """Set Export Options for USD Exporter""" export_options = rt.USDExporter.createOptions() @@ -101,13 +79,13 @@ class ExtractModelUSD(publish.Extractor, export_options.Lights = False export_options.Cameras = False export_options.Materials = False - export_options.MeshFormat = rt.name('fromScene') - export_options.FileFormat = rt.name('ascii') - export_options.UpAxis = rt.name('y') - export_options.LogLevel = rt.name('info') + export_options.MeshFormat = rt.Name('fromScene') + export_options.FileFormat = rt.Name('ascii') + export_options.UpAxis = rt.Name('y') + export_options.LogLevel = rt.Name('info') export_options.LogPath = log_path export_options.PreserveEdgeOrientation = True - export_options.TimeMode = rt.name('current') + export_options.TimeMode = rt.Name('current') rt.USDexporter.UIOptions = export_options diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index e09eddb993..5a99a8b845 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -41,7 +41,7 @@ import os import pyblish.api from openpype.pipeline import publish from pymxs import runtime as rt -from openpype.hosts.max.api import maintained_selection, get_all_children +from openpype.hosts.max.api import maintained_selection class ExtractAlembic(publish.Extractor): @@ -72,7 +72,8 @@ class ExtractAlembic(publish.Extractor): with maintained_selection(): # select and export - rt.select(get_all_children(rt.getNodeByName(container))) + node_list = instance.data["members"] + rt.Select(node_list) rt.exportFile( path, rt.name("noPrompt"), diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index e8d58ab713..583bbb6dbd 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -1,42 +1,34 @@ 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"]) +from openpype.hosts.max.api import maintained_selection +from openpype.pipeline import publish class ExtractPointCloud(publish.Extractor): """ - Extract PRT 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 + self.export_particle(): sets up all job arguments for attributes + to be exported in MAXscript - get_operators(): get the export_particle operator + self.get_operators(): get the export_particle operator - get_custom_attr(): get all custom channel attributes from Openpype - setting and sets it as job arguments before exporting + self.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 + self.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 + self.partition_output_name(): get the naming with partition settings. + + self.get_partition(): get partition value """ @@ -46,9 +38,9 @@ class ExtractPointCloud(publish.Extractor): families = ["pointcloud"] def process(self, instance): + self.settings = self.get_setting(instance) 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) @@ -56,22 +48,25 @@ class ExtractPointCloud(publish.Extractor): path = os.path.join(stagingdir, filename) with maintained_selection(): - job_args = self.export_particle(container, + job_args = self.export_particle(instance.data["members"], start, end, path) + for job in job_args: - rt.execute(job) + 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(container, path, start, end) - self.log.debug("filenames: {0}".format(filenames)) + filenames = self.get_files( + instance.data["members"], path, start, end) + self.log.debug(f"filenames: {filenames}") - partition = self.partition_output_name(container) + partition = self.partition_output_name( + instance.data["members"]) representation = { 'name': 'prt', @@ -81,67 +76,84 @@ class ExtractPointCloud(publish.Extractor): "outputName": partition # partition value } instance.data["representations"].append(representation) - self.log.info("Extracted instance '%s' to: %s" % (instance.name, - path)) + self.log.info(f"Extracted instance '{instance.name}' to: {path}") def export_particle(self, - container, + members, start, end, filepath): + """Sets up all job arguments for attributes. + + Those attributes are to be exported in MAX Script. + + Args: + members (list): Member nodes of the instance. + start (int): Start frame. + end (int): End frame. + filepath (str): Path to PRT file. + + Returns: + list of arguments for MAX Script. + + """ job_args = [] - opt_list = self.get_operators(container) + opt_list = self.get_operators(members) for operator in opt_list: - start_frame = "{0}.frameStart={1}".format(operator, - start) + start_frame = f"{operator}.frameStart={start}" job_args.append(start_frame) - end_frame = "{0}.frameEnd={1}".format(operator, - end) + end_frame = f"{operator}.frameEnd={end}" job_args.append(end_frame) filepath = filepath.replace("\\", "/") - prt_filename = '{0}.PRTFilename="{1}"'.format(operator, - filepath) - + prt_filename = f'{operator}.PRTFilename="{filepath}"' job_args.append(prt_filename) # Partition - mode = "{0}.PRTPartitionsMode=2".format(operator) + mode = f"{operator}.PRTPartitionsMode=2" 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.extend(iter(additional_args)) + prt_export = f"{operator}.exportPRT()" job_args.append(prt_export) return job_args - def get_operators(self, container): - """Get Export Particles Operator""" + @staticmethod + def get_operators(members): + """Get Export Particles Operator. + Args: + members (list): Instance members. + + Returns: + list of particle operators + + """ 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 member in members: + obj = member.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 + sub_anim = rt.GetSubAnim(obj, anim_name) + boolean = rt.IsProperty(sub_anim, "Export_Particles") if boolean: - opt = "${0}.{1}.export_particles".format(sel.name, - event_name) - opt_list.append(opt) + event_name = sub_anim.Name + opt = f"${member.Name}.{event_name}.export_particles" + opt_list.append(opt) return opt_list + @staticmethod + def get_setting(instance): + project_setting = instance.context.data["project_settings"] + return project_setting["max"]["PointCloud"] + def get_custom_attr(self, operator): """Get Custom Attributes""" custom_attr_list = [] - attr_settings = get_setting()["attribute"] + attr_settings = self.settings["attribute"] for key, value in attr_settings.items(): custom_attr = "{0}.PRTChannels_{1}=True".format(operator, value) @@ -157,14 +169,25 @@ class ExtractPointCloud(publish.Extractor): path, start_frame, end_frame): - """ - Note: - Set the filenames accordingly to the tyFlow file - naming extension for the publishing purpose + """Get file names for tyFlow. - Actual File Output from tyFlow: + 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 + + Args: + container: Instance node. + path (str): Output directory. + start_frame (int): Start frame. + end_frame (int): End frame. + + Returns: + list of filenames + """ filenames = [] filename = os.path.basename(path) @@ -181,27 +204,36 @@ class ExtractPointCloud(publish.Extractor): return filenames def partition_output_name(self, container): - """ - Notes: - Partition output name set for mapping - the published file output + """Get partition output name. + + Partition output name set for mapping + the published file output. + + Todo: + Customizes the setting for the output. + + Args: + container: Instance node. + + Returns: + str: Partition name. - 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 + return f"_part{partition_start:03}of{partition_count}" def get_partition(self, container): - """ - Get Partition Value + """Get Partition value. + + Args: + container: Instance node. + """ opt_list = self.get_operators(container) + # TODO: This looks strange? Iterating over + # the opt_list but returning from inside? for operator in opt_list: - count = rt.execute(f'{operator}.PRTPartitionsCount') - start = rt.execute(f'{operator}.PRTPartitionsFrom') + count = rt.Execute(f'{operator}.PRTPartitionsCount') + start = rt.Execute(f'{operator}.PRTPartitionsFrom') return count, start diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index 3b44099609..ab569ecbcb 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -30,8 +30,8 @@ class ExtractRedshiftProxy(publish.Extractor): with maintained_selection(): # select and export - con = rt.getNodeByName(container) - rt.select(con.Children) + node_list = instance.data["members"] + rt.Select(node_list) # Redshift rsProxy command # rsProxy fp selected compress connectivity startFrame endFrame # camera warnExisting transformPivotToOrigin diff --git a/openpype/hosts/max/plugins/publish/validate_camera_contents.py b/openpype/hosts/max/plugins/publish/validate_camera_contents.py index c81e28a61f..ab13e5dc05 100644 --- a/openpype/hosts/max/plugins/publish/validate_camera_contents.py +++ b/openpype/hosts/max/plugins/publish/validate_camera_contents.py @@ -20,28 +20,23 @@ class ValidateCameraContent(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Camera instance must only include" - "camera (and camera target)") + raise PublishValidationError(("Camera instance must only include" + "camera (and camera target). " + f"Invalid content {invalid}")) def get_invalid(self, instance): """ Get invalid nodes if the instance is not camera """ - invalid = list() + invalid = [] container = instance.data["instance_node"] - self.log.info("Validating look content for " - "{}".format(container)) + self.log.info(f"Validating camera content for {container}") - con = rt.getNodeByName(container) - selection_list = list(con.Children) + selection_list = instance.data["members"] for sel in selection_list: # to avoid Attribute Error from pymxs wrapper sel_tmp = str(sel) - found = False - for cam in self.camera_type: - if sel_tmp.startswith(cam): - found = True - break + found = any(sel_tmp.startswith(cam) for cam in self.camera_type) if not found: self.log.error("Camera not found") invalid.append(sel) diff --git a/openpype/hosts/max/plugins/publish/validate_model_contents.py b/openpype/hosts/max/plugins/publish/validate_model_contents.py index dd782674ff..cfe016f03f 100644 --- a/openpype/hosts/max/plugins/publish/validate_model_contents.py +++ b/openpype/hosts/max/plugins/publish/validate_model_contents.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- import pyblish.api -from openpype.pipeline import PublishValidationError from pymxs import runtime as rt +from openpype.pipeline import PublishValidationError + class ValidateModelContent(pyblish.api.InstancePlugin): """Validates Model instance contents. @@ -19,26 +20,25 @@ class ValidateModelContent(pyblish.api.InstancePlugin): def process(self, instance): invalid = self.get_invalid(instance) if invalid: - raise PublishValidationError("Model instance must only include" - "Geometry and Editable Mesh") + raise PublishValidationError(("Model instance must only include" + "Geometry and Editable Mesh. " + f"Invalid types on: {invalid}")) def get_invalid(self, instance): """ Get invalid nodes if the instance is not camera """ - invalid = list() + invalid = [] container = instance.data["instance_node"] - self.log.info("Validating look content for " - "{}".format(container)) + self.log.info(f"Validating model content for {container}") - con = rt.getNodeByName(container) - selection_list = list(con.Children) or rt.getCurrentSelection() + selection_list = instance.data["members"] for sel in selection_list: - if rt.classOf(sel) in rt.Camera.classes: + if rt.ClassOf(sel) in rt.Camera.classes: invalid.append(sel) - if rt.classOf(sel) in rt.Light.classes: + if rt.ClassOf(sel) in rt.Light.classes: invalid.append(sel) - if rt.classOf(sel) in rt.Shape.classes: + if rt.ClassOf(sel) in rt.Shape.classes: invalid.append(sel) return invalid 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 c20a1968ed..ba4a6882c2 100644 --- a/openpype/hosts/max/plugins/publish/validate_no_max_content.py +++ b/openpype/hosts/max/plugins/publish/validate_no_max_content.py @@ -18,6 +18,5 @@ class ValidateMaxContents(pyblish.api.InstancePlugin): label = "Max Scene Contents" def process(self, instance): - container = rt.getNodeByName(instance.data["instance_node"]) - if not list(container.Children): + if not instance.data["members"]: raise PublishValidationError("No content found in the container") diff --git a/openpype/hosts/max/plugins/publish/validate_pointcloud.py b/openpype/hosts/max/plugins/publish/validate_pointcloud.py index f654058648..1ff6eb126f 100644 --- a/openpype/hosts/max/plugins/publish/validate_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/validate_pointcloud.py @@ -9,11 +9,11 @@ def get_setting(project_setting=None): project_setting = get_project_settings( legacy_io.Session["AVALON_PROJECT"] ) - return (project_setting["max"]["PointCloud"]) + return project_setting["max"]["PointCloud"] class ValidatePointCloud(pyblish.api.InstancePlugin): - """Validate that workfile was saved.""" + """Validate that work file was saved.""" order = pyblish.api.ValidatorOrder families = ["pointcloud"] @@ -34,39 +34,42 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): of export_particle operator """ - 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)) + report = [] - invalid = self.validate_export_mode(instance) - if invalid: - raise PublishValidationError("The export mode is not at PRT") + invalid_object = self.get_tyflow_object(instance) + if invalid_object: + report.append(f"Non tyFlow object found: {invalid_object}") - 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)) + invalid_operator = self.get_tyflow_operator(instance) + if invalid_operator: + report.append(( + "tyFlow ExportParticle operator not " + f"found: {invalid_operator}")) - def get_tyFlow_object(self, instance): + if self.validate_export_mode(instance): + report.append("The export mode is not at PRT") + + if self.validate_partition_value(instance): + report.append(("tyFlow Partition setting is " + "not at the default value")) + + invalid_attribute = self.validate_custom_attribute(instance) + if invalid_attribute: + report.append(("Custom Attribute not found " + f":{invalid_attribute}")) + + if report: + raise PublishValidationError(f"{report}") + + def get_tyflow_object(self, instance): invalid = [] container = instance.data["instance_node"] - self.log.info("Validating tyFlow container " - "for {}".format(container)) + self.log.info(f"Validating tyFlow container for {container}") - con = rt.getNodeByName(container) - selection_list = list(con.Children) + selection_list = instance.data["members"] for sel in selection_list: sel_tmp = str(sel) - if rt.classOf(sel) in [rt.tyFlow, + if rt.ClassOf(sel) in [rt.tyFlow, rt.Editable_Mesh]: if "tyFlow" not in sel_tmp: invalid.append(sel) @@ -75,23 +78,20 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): return invalid - def get_tyFlow_operator(self, instance): + 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) + self.log.info(f"Validating tyFlow object for {container}") + selection_list = instance.data["members"] bool_list = [] for sel in selection_list: obj = sel.baseobject - anim_names = rt.getsubanimnames(obj) + 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) + sub_anim = rt.GetSubAnim(obj, anim_name) # check if there is export particle operator - boolean = rt.isProperty(sub_anim, "Export_Particles") + 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 @@ -104,21 +104,18 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): def validate_custom_attribute(self, instance): invalid = [] container = instance.data["instance_node"] - self.log.info("Validating tyFlow custom " - "attributes for {}".format(container)) + self.log.info( + f"Validating tyFlow custom attributes for {container}") - con = rt.getNodeByName(container) - selection_list = list(con.Children) + selection_list = instance.data["members"] for sel in selection_list: obj = sel.baseobject - anim_names = rt.getsubanimnames(obj) + 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: + sub_anim = rt.GetSubAnim(obj, anim_name) + if rt.IsProperty(sub_anim, "Export_Particles"): + event_name = sub_anim.name opt = "${0}.{1}.export_particles".format(sel.name, event_name) attributes = get_setting()["attribute"] @@ -126,39 +123,36 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): custom_attr = "{0}.PRTChannels_{1}".format(opt, value) try: - rt.execute(custom_attr) + rt.Execute(custom_attr) except RuntimeError: - invalid.add(key) + invalid.append(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)) + self.log.info( + f"Validating tyFlow partition value for {container}") - con = rt.getNodeByName(container) - selection_list = list(con.Children) + selection_list = instance.data["members"] for sel in selection_list: obj = sel.baseobject - anim_names = rt.getsubanimnames(obj) + 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: + sub_anim = rt.GetSubAnim(obj, anim_name) + if rt.IsProperty(sub_anim, "Export_Particles"): + event_name = sub_anim.name opt = "${0}.{1}.export_particles".format(sel.name, event_name) - count = rt.execute(f'{opt}.PRTPartitionsCount') + count = rt.Execute(f'{opt}.PRTPartitionsCount') if count != 100: invalid.append(count) - start = rt.execute(f'{opt}.PRTPartitionsFrom') + start = rt.Execute(f'{opt}.PRTPartitionsFrom') if start != 1: invalid.append(start) - end = rt.execute(f'{opt}.PRTPartitionsTo') + end = rt.Execute(f'{opt}.PRTPartitionsTo') if end != 1: invalid.append(end) @@ -167,24 +161,23 @@ class ValidatePointCloud(pyblish.api.InstancePlugin): def validate_export_mode(self, instance): invalid = [] container = instance.data["instance_node"] - self.log.info("Validating tyFlow export " - "mode for {}".format(container)) + self.log.info( + f"Validating tyFlow export mode for {container}") - con = rt.getNodeByName(container) + con = rt.GetNodeByName(container) selection_list = list(con.Children) for sel in selection_list: obj = sel.baseobject - anim_names = rt.getsubanimnames(obj) + 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) + sub_anim = rt.GetSubAnim(obj, anim_name) # check if there is export particle operator - boolean = rt.isProperty(sub_anim, "Export_Particles") + 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') + opt = f"${sel.name}.{event_name}.export_particles" + export_mode = rt.Execute(f'{opt}.exportMode') if export_mode != 1: invalid.append(export_mode) diff --git a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py index 747147020a..9957e62736 100644 --- a/openpype/hosts/max/plugins/publish/validate_usd_plugin.py +++ b/openpype/hosts/max/plugins/publish/validate_usd_plugin.py @@ -1,36 +1,37 @@ # -*- coding: utf-8 -*- -import pyblish.api +"""Validator for USD plugin.""" from openpype.pipeline import PublishValidationError +from pyblish.api import InstancePlugin, ValidatorOrder from pymxs import runtime as rt -class ValidateUSDPlugin(pyblish.api.InstancePlugin): - """Validates if USD plugin is installed or loaded in Max - """ +def get_plugins() -> list: + """Get plugin list from 3ds max.""" + manager = rt.PluginManager + count = manager.pluginDllCount + plugin_info_list = [] + for p in range(1, count + 1): + plugin_info = manager.pluginDllName(p) + plugin_info_list.append(plugin_info) - order = pyblish.api.ValidatorOrder - 0.01 + return plugin_info_list + + +class ValidateUSDPlugin(InstancePlugin): + """Validates if USD plugin is installed or loaded in 3ds max.""" + + order = ValidatorOrder - 0.01 families = ["model"] hosts = ["max"] label = "USD Plugin" def process(self, instance): - plugin_mgr = rt.pluginManager - plugin_count = plugin_mgr.pluginDllCount - plugin_info = self.get_plugins(plugin_mgr, - plugin_count) + """Plugin entry point.""" + + plugin_info = get_plugins() usd_import = "usdimport.dli" if usd_import not in plugin_info: - raise PublishValidationError("USD Plugin {}" - " not found".format(usd_import)) + raise PublishValidationError(f"USD Plugin {usd_import} not found") usd_export = "usdexport.dle" if usd_export not in plugin_info: - raise PublishValidationError("USD Plugin {}" - " not found".format(usd_export)) - - def get_plugins(self, manager, count): - plugin_info_list = list() - for p in range(1, count + 1): - plugin_info = manager.pluginDllName(p) - plugin_info_list.append(plugin_info) - - return plugin_info_list + raise PublishValidationError(f"USD Plugin {usd_export} not found") diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index f9c300db60..9fa0507d24 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -1,6 +1,7 @@ """Standalone helper functions""" import os +from pprint import pformat import sys import platform import uuid @@ -3262,75 +3263,6 @@ def iter_shader_edits(relationships, shader_nodes, nodes_by_id, label=None): def set_colorspace(): """Set Colorspace from project configuration """ - project_name = os.getenv("AVALON_PROJECT") - imageio = get_project_settings(project_name)["maya"]["imageio"] - - # Maya 2022+ introduces new OCIO v2 color management settings that - # can override the old color managenement preferences. OpenPype has - # separate settings for both so we fall back when necessary. - use_ocio_v2 = imageio["colorManagementPreference_v2"]["enabled"] - required_maya_version = 2022 - maya_version = int(cmds.about(version=True)) - maya_supports_ocio_v2 = maya_version >= required_maya_version - if use_ocio_v2 and not maya_supports_ocio_v2: - # Fallback to legacy behavior with a warning - log.warning("Color Management Preference v2 is enabled but not " - "supported by current Maya version: {} (< {}). Falling " - "back to legacy settings.".format( - maya_version, required_maya_version) - ) - use_ocio_v2 = False - - if use_ocio_v2: - root_dict = imageio["colorManagementPreference_v2"] - else: - root_dict = imageio["colorManagementPreference"] - - if not isinstance(root_dict, dict): - msg = "set_colorspace(): argument should be dictionary" - log.error(msg) - - log.debug(">> root_dict: {}".format(root_dict)) - - # enable color management - cmds.colorManagementPrefs(e=True, cmEnabled=True) - cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True) - - # set config path - custom_ocio_config = False - if root_dict.get("configFilePath"): - unresolved_path = root_dict["configFilePath"] - ocio_paths = unresolved_path[platform.system().lower()] - - resolved_path = None - for ocio_p in ocio_paths: - resolved_path = str(ocio_p).format(**os.environ) - if not os.path.exists(resolved_path): - continue - - if resolved_path: - filepath = str(resolved_path).replace("\\", "/") - cmds.colorManagementPrefs(e=True, configFilePath=filepath) - cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=True) - log.debug("maya '{}' changed to: {}".format( - "configFilePath", resolved_path)) - custom_ocio_config = True - else: - cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=False) - cmds.colorManagementPrefs(e=True, configFilePath="") - - # If no custom OCIO config file was set we make sure that Maya 2022+ - # either chooses between Maya's newer default v2 or legacy config based - # on OpenPype setting to use ocio v2 or not. - if maya_supports_ocio_v2 and not custom_ocio_config: - if use_ocio_v2: - # Use Maya 2022+ default OCIO v2 config - log.info("Setting default Maya OCIO v2 config") - cmds.colorManagementPrefs(edit=True, configFilePath="") - else: - # Set the Maya default config file path - log.info("Setting default Maya OCIO v1 legacy config") - cmds.colorManagementPrefs(edit=True, configFilePath="legacy") # set color spaces for rendering space and view transforms def _colormanage(**kwargs): @@ -3347,17 +3279,74 @@ def set_colorspace(): except RuntimeError as exc: log.error(exc) - if use_ocio_v2: - _colormanage(renderingSpaceName=root_dict["renderSpace"]) - _colormanage(displayName=root_dict["displayName"]) - _colormanage(viewName=root_dict["viewName"]) - else: - _colormanage(renderingSpaceName=root_dict["renderSpace"]) - if maya_supports_ocio_v2: - _colormanage(viewName=root_dict["viewTransform"]) - _colormanage(displayName="legacy") + project_name = os.getenv("AVALON_PROJECT") + imageio = get_project_settings(project_name)["maya"]["imageio"] + + # ocio compatibility variables + ocio_v2_maya_version = 2022 + maya_version = int(cmds.about(version=True)) + ocio_v2_support = use_ocio_v2 = maya_version >= ocio_v2_maya_version + + root_dict = {} + use_workfile_settings = imageio.get("workfile", {}).get("enabled") + + if use_workfile_settings: + # TODO: deprecated code from 3.15.5 - remove + # Maya 2022+ introduces new OCIO v2 color management settings that + # can override the old color management preferences. OpenPype has + # separate settings for both so we fall back when necessary. + use_ocio_v2 = imageio["colorManagementPreference_v2"]["enabled"] + if use_ocio_v2 and not ocio_v2_support: + # Fallback to legacy behavior with a warning + log.warning( + "Color Management Preference v2 is enabled but not " + "supported by current Maya version: {} (< {}). Falling " + "back to legacy settings.".format( + maya_version, ocio_v2_maya_version) + ) + + if use_ocio_v2: + root_dict = imageio["colorManagementPreference_v2"] else: - _colormanage(viewTransformName=root_dict["viewTransform"]) + root_dict = imageio["colorManagementPreference"] + + if not isinstance(root_dict, dict): + msg = "set_colorspace(): argument should be dictionary" + log.error(msg) + + else: + root_dict = imageio["workfile"] + + log.debug(">> root_dict: {}".format(pformat(root_dict))) + + if root_dict: + # enable color management + cmds.colorManagementPrefs(e=True, cmEnabled=True) + cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True) + + # backward compatibility + # TODO: deprecated code from 3.15.5 - refactor to use new settings + view_name = root_dict.get("viewTransform") + if view_name is None: + view_name = root_dict.get("viewName") + + if use_ocio_v2: + # Use Maya 2022+ default OCIO v2 config + log.info("Setting default Maya OCIO v2 config") + cmds.colorManagementPrefs(edit=True, configFilePath="") + + # set rendering space and view transform + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + _colormanage(viewName=view_name) + _colormanage(displayName=root_dict["displayName"]) + else: + # Set the Maya default config file path + log.info("Setting default Maya OCIO v1 legacy config") + cmds.colorManagementPrefs(edit=True, configFilePath="legacy") + + # set rendering space and view transform + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + _colormanage(viewTransformName=view_name) @contextlib.contextmanager diff --git a/openpype/hosts/maya/api/workfile_template_builder.py b/openpype/hosts/maya/api/workfile_template_builder.py index 6e6166c2ef..b7e731bbb6 100644 --- a/openpype/hosts/maya/api/workfile_template_builder.py +++ b/openpype/hosts/maya/api/workfile_template_builder.py @@ -14,7 +14,7 @@ from openpype.tools.workfile_template_build import ( WorkfileBuildPlaceholderDialog, ) -from .lib import read, imprint +from .lib import read, imprint, get_main_window PLACEHOLDER_SET = "PLACEHOLDERS_SET" @@ -173,44 +173,37 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): def create_placeholder(self, placeholder_data): selection = cmds.ls(selection=True) - if not selection: - raise ValueError("Nothing is selected") if len(selection) > 1: raise ValueError("More then one item are selected") + parent = selection[0] if selection else None + placeholder_data["plugin_identifier"] = self.identifier placeholder_name = self._create_placeholder_name(placeholder_data) placeholder = cmds.spaceLocator(name=placeholder_name)[0] - # TODO: this can crash if selection can't be used - cmds.parent(placeholder, selection[0]) + if parent: + placeholder = cmds.parent(placeholder, selection[0])[0] - # get the long name of the placeholder (with the groups) - placeholder_full_name = ( - cmds.ls(selection[0], long=True)[0] - + "|" - + placeholder.replace("|", "") - ) - - imprint(placeholder_full_name, placeholder_data) + imprint(placeholder, placeholder_data) # Add helper attributes to keep placeholder info cmds.addAttr( - placeholder_full_name, + placeholder, longName="parent", hidden=True, dataType="string" ) cmds.addAttr( - placeholder_full_name, + placeholder, longName="index", hidden=True, attributeType="short", defaultValue=-1 ) - cmds.setAttr(placeholder_full_name + ".parent", "", type="string") + cmds.setAttr(placeholder + ".parent", "", type="string") def update_placeholder(self, placeholder_item, placeholder_data): node_name = placeholder_item.scene_identifier @@ -233,7 +226,7 @@ class MayaPlaceholderLoadPlugin(PlaceholderPlugin, PlaceholderLoadMixin): if placeholder_data.get("plugin_identifier") != self.identifier: continue - # TODO do data validations and maybe updgrades if are invalid + # TODO do data validations and maybe upgrades if they are invalid output.append( LoadPlaceholderItem(node_name, placeholder_data, self) ) @@ -319,8 +312,9 @@ def update_workfile_template(*args): def create_placeholder(*args): host = registered_host() builder = MayaTemplateBuilder(host) - window = WorkfileBuildPlaceholderDialog(host, builder) - window.exec_() + window = WorkfileBuildPlaceholderDialog(host, builder, + parent=get_main_window()) + window.show() def update_placeholder(*args): @@ -343,6 +337,7 @@ def update_placeholder(*args): raise ValueError("Too many selected nodes") placeholder_item = placeholder_items[0] - window = WorkfileBuildPlaceholderDialog(host, builder) + window = WorkfileBuildPlaceholderDialog(host, builder, + parent=get_main_window()) window.set_update_mode(placeholder_item) window.exec_() diff --git a/openpype/hosts/maya/plugins/create/create_arnold_scene_source.py b/openpype/hosts/maya/plugins/create/create_arnold_scene_source.py index 945074e32f..0c8cf8d2bb 100644 --- a/openpype/hosts/maya/plugins/create/create_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/create/create_arnold_scene_source.py @@ -98,4 +98,4 @@ class CreateArnoldSceneSource(plugin.MayaCreator): content = cmds.sets(name=instance_node + "_content_SET", empty=True) proxy = cmds.sets(name=instance_node + "_proxy_SET", empty=True) - cmds.sets([content, proxy], forceElement=instance) + cmds.sets([content, proxy], forceElement=instance_node) diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index ba69debc40..4855f3eed0 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -105,7 +105,8 @@ class ImportMayaLoader(load.LoaderPlugin): "camera", "rig", "camerarig", - "staticMesh" + "staticMesh", + "workfile" ] label = "Import" diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py index 7c3a732389..29215bc5c2 100644 --- a/openpype/hosts/maya/plugins/load/load_arnold_standin.py +++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py @@ -6,23 +6,29 @@ import maya.cmds as cmds from openpype.settings import get_project_settings from openpype.pipeline import ( load, + legacy_io, get_representation_path ) from openpype.hosts.maya.api.lib import ( - unique_namespace, get_attribute_input, maintained_selection + unique_namespace, + get_attribute_input, + maintained_selection, + convert_to_maya_fps ) from openpype.hosts.maya.api.pipeline import containerise - def is_sequence(files): sequence = False - collections, remainder = clique.assemble(files) + collections, remainder = clique.assemble(files, minimum_items=1) if collections: sequence = True - return sequence +def get_current_session_fps(): + session_fps = float(legacy_io.Session.get('AVALON_FPS', 25)) + return convert_to_maya_fps(session_fps) + class ArnoldStandinLoader(load.LoaderPlugin): """Load as Arnold standin""" @@ -35,9 +41,15 @@ class ArnoldStandinLoader(load.LoaderPlugin): color = "orange" def load(self, context, name, namespace, options): + if not cmds.pluginInfo("mtoa", query=True, loaded=True): + cmds.loadPlugin("mtoa") + # Create defaultArnoldRenderOptions before creating aiStandin + # which tries to connect it. Since we load the plugin and directly + # create aiStandin without the defaultArnoldRenderOptions, + # we need to create the render options for aiStandin creation. + from mtoa.core import createOptions + createOptions() - # Make sure to load arnold before importing `mtoa.ui.arnoldmenu` - cmds.loadPlugin("mtoa", quiet=True) import mtoa.ui.arnoldmenu version = context['version'] @@ -84,6 +96,9 @@ class ArnoldStandinLoader(load.LoaderPlugin): sequence = is_sequence(os.listdir(os.path.dirname(self.fname))) cmds.setAttr(standin_shape + ".useFrameExtension", sequence) + fps = float(version["data"].get("fps"))or get_current_session_fps() + cmds.setAttr(standin_shape + ".abcFPS", fps) + nodes = [root, standin, standin_shape] if operator is not None: nodes.append(operator) diff --git a/openpype/hosts/maya/plugins/load/load_image.py b/openpype/hosts/maya/plugins/load/load_image.py index b464c268fc..552bcc33af 100644 --- a/openpype/hosts/maya/plugins/load/load_image.py +++ b/openpype/hosts/maya/plugins/load/load_image.py @@ -273,6 +273,11 @@ class FileNodeLoader(load.LoaderPlugin): project_name, host_name, project_settings=project_settings ) + + # ignore if host imageio is not enabled + if not config_data: + return + file_rules = get_imageio_file_rules( project_name, host_name, project_settings=project_settings diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 3919db3ab8..deadd5b9d3 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -33,7 +33,7 @@ def preserve_modelpanel_cameras(container, log=None): panel_cameras = {} for panel in cmds.getPanel(type="modelPanel"): cam = cmds.ls(cmds.modelPanel(panel, query=True, camera=True), - long=True) + long=True)[0] # Often but not always maya returns the transform from the # modelPanel as opposed to the camera shape, so we convert it diff --git a/openpype/hosts/maya/plugins/load/load_xgen.py b/openpype/hosts/maya/plugins/load/load_xgen.py index 7e6cabc77c..16f2e8e842 100644 --- a/openpype/hosts/maya/plugins/load/load_xgen.py +++ b/openpype/hosts/maya/plugins/load/load_xgen.py @@ -1,4 +1,5 @@ import os +import shutil import maya.cmds as cmds import xgenm @@ -116,8 +117,8 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): def update(self, container, representation): """Workflow for updating Xgen. - - Copy and potentially overwrite the workspace .xgen file. - Export changes to delta file. + - Copy and overwrite the workspace .xgen file. - Set collection attributes to not include delta files. - Update xgen maya file reference. - Apply the delta file changes. @@ -130,6 +131,10 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): There is an implicit increment of the xgen and delta files, due to using the workfile basename. """ + # Storing current description to try and maintain later. + current_description = ( + xgenm.xgGlobal.DescriptionEditor.currentDescription() + ) container_node = container["objectName"] members = get_container_members(container_node) @@ -160,6 +165,7 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): data_path ) data = {"xgProjectPath": project_path, "xgDataPath": data_path} + shutil.copy(new_xgen_file, xgen_file) write_xgen_file(data, xgen_file) attribute_data = { @@ -171,3 +177,11 @@ class XgenLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): super().update(container, representation) xgenm.applyDelta(xgen_palette.replace("|", ""), xgd_file) + + # Restore current selected description if it exists. + if cmds.objExists(current_description): + xgenm.xgGlobal.DescriptionEditor.setCurrentDescription( + current_description + ) + # Full UI refresh. + xgenm.xgGlobal.DescriptionEditor.refresh("Full") diff --git a/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py index 0845f653b1..90079c715a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/publish/collect_arnold_scene_source.py @@ -18,30 +18,39 @@ class CollectArnoldSceneSource(pyblish.api.InstancePlugin): for objset in objsets: objset = str(objset) members = cmds.sets(objset, query=True) + members = cmds.ls(members, long=True) if members is None: self.log.warning("Skipped empty instance: \"%s\" " % objset) continue if objset.endswith("content_SET"): - members = cmds.ls(members, long=True) - children = get_all_children(members) - instance.data["contentMembers"] = children - self.log.debug("content members: {}".format(children)) - elif objset.endswith("proxy_SET"): - set_members = get_all_children(cmds.ls(members, long=True)) - instance.data["proxy"] = set_members - self.log.debug("proxy members: {}".format(set_members)) + instance.data["contentMembers"] = self.get_hierarchy(members) + if objset.endswith("proxy_SET"): + instance.data["proxy"] = self.get_hierarchy(members) # Use camera in object set if present else default to render globals # camera. cameras = cmds.ls(type="camera", long=True) renderable = [c for c in cameras if cmds.getAttr("%s.renderable" % c)] - camera = renderable[0] - for node in instance.data["contentMembers"]: - camera_shapes = cmds.listRelatives( - node, shapes=True, type="camera" - ) - if camera_shapes: - camera = node - instance.data["camera"] = camera + if renderable: + camera = renderable[0] + for node in instance.data["contentMembers"]: + camera_shapes = cmds.listRelatives( + node, shapes=True, type="camera" + ) + if camera_shapes: + camera = node + instance.data["camera"] = camera + else: + self.log.debug("No renderable cameras found.") self.log.debug("data: {}".format(instance.data)) + + def get_hierarchy(self, nodes): + """Return nodes with all their children""" + nodes = cmds.ls(nodes, long=True) + if not nodes: + return [] + children = get_all_children(nodes) + # Make sure nodes merged with children only + # contains unique entries + return list(set(nodes + children)) diff --git a/openpype/hosts/maya/plugins/publish/collect_review.py b/openpype/hosts/maya/plugins/publish/collect_review.py index f7d7cfe109..3820f099c2 100644 --- a/openpype/hosts/maya/plugins/publish/collect_review.py +++ b/openpype/hosts/maya/plugins/publish/collect_review.py @@ -30,11 +30,12 @@ class CollectReview(pyblish.api.InstancePlugin): camera = cameras[0] if cameras else None context = instance.context - objectset = context.data['objectsets'] + objectset = { + i.data.get("instance_node") for i in context + } - # Convert enum attribute index to string for Display Lights. - index = instance.data.get("displayLights", 0) - display_lights = lib.DISPLAY_LIGHTS_VALUES[index] + # Collect display lights. + display_lights = instance.data.get("displayLights", "default") if display_lights == "project_settings": settings = instance.context.data["project_settings"] settings = settings["maya"]["publish"]["ExtractPlayblast"] @@ -56,7 +57,7 @@ class CollectReview(pyblish.api.InstancePlugin): burninDataMembers["focalLength"] = focal_length # Account for nested instances like model. - reviewable_subsets = list(set(members) & set(objectset)) + reviewable_subsets = list(set(members) & objectset) if reviewable_subsets: if len(reviewable_subsets) > 1: raise KnownPublishError( diff --git a/openpype/hosts/maya/plugins/publish/collect_xgen.py b/openpype/hosts/maya/plugins/publish/collect_xgen.py index da0549b2d8..46968f7d1a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_xgen.py +++ b/openpype/hosts/maya/plugins/publish/collect_xgen.py @@ -30,12 +30,12 @@ class CollectXgen(pyblish.api.InstancePlugin): if data["xgmPalettes"]: data["xgmPalette"] = data["xgmPalettes"][0] - data["xgenConnections"] = {} + data["xgenConnections"] = set() for node in data["xgmSubdPatches"]: - data["xgenConnections"][node] = {} - for attr in ["transform", "geometry"]: - input = get_attribute_input("{}.{}".format(node, attr)) - data["xgenConnections"][node][attr] = input + connected_transform = get_attribute_input( + node + ".transform" + ).split(".")[0] + data["xgenConnections"].add(connected_transform) # Collect all files under palette root as resources. import xgenm diff --git a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py index 14bcc71da6..102f0e46a2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py +++ b/openpype/hosts/maya/plugins/publish/extract_arnold_scene_source.py @@ -109,6 +109,7 @@ class ExtractArnoldSceneSource(publish.Extractor): return kwargs["filename"] = file_path.replace(".ass", "_proxy.ass") + filenames, _ = self._extract( instance.data["proxy"], attribute_data, kwargs ) diff --git a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py index 20e1bd37d8..0d2a97bc4b 100644 --- a/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_workfile_xgen.py @@ -57,7 +57,7 @@ class ExtractWorkfileXgen(publish.Extractor): continue render_start_frame = instance.data["frameStart"] - render_end_frame = instance.data["frameStart"] + render_end_frame = instance.data["frameEnd"] if start_frame is None: start_frame = render_start_frame diff --git a/openpype/hosts/maya/plugins/publish/extract_xgen.py b/openpype/hosts/maya/plugins/publish/extract_xgen.py index fb097ca84a..3c9d0bd344 100644 --- a/openpype/hosts/maya/plugins/publish/extract_xgen.py +++ b/openpype/hosts/maya/plugins/publish/extract_xgen.py @@ -51,11 +51,9 @@ class ExtractXgen(publish.Extractor): with delete_after() as delete_bin: duplicate_nodes = [] # Collect nodes to export. - for _, connections in instance.data["xgenConnections"].items(): - transform_name = connections["transform"].split(".")[0] - + for node in instance.data["xgenConnections"]: # Duplicate_transform subd patch geometry. - duplicate_transform = cmds.duplicate(transform_name)[0] + duplicate_transform = cmds.duplicate(node)[0] delete_bin.append(duplicate_transform) # Discard the children. @@ -88,6 +86,18 @@ class ExtractXgen(publish.Extractor): delete_bin.append(palette) + # Copy shading assignments. + nodes = ( + instance.data["xgmDescriptions"] + + instance.data["xgmSubdPatches"] + ) + for node in nodes: + target_node = node.split(":")[-1] + shading_engine = cmds.listConnections( + node, type="shadingEngine" + )[0] + cmds.sets(target_node, edit=True, forceElement=shading_engine) + # Export duplicated palettes. xgenm.exportPalette(palette, xgen_path) diff --git a/openpype/hosts/maya/plugins/publish/validate_arnold_scene_source_cbid.py b/openpype/hosts/maya/plugins/publish/validate_arnold_scene_source_cbid.py index e27723e104..8ce76c8d04 100644 --- a/openpype/hosts/maya/plugins/publish/validate_arnold_scene_source_cbid.py +++ b/openpype/hosts/maya/plugins/publish/validate_arnold_scene_source_cbid.py @@ -70,5 +70,5 @@ class ValidateArnoldSceneSourceCbid(pyblish.api.InstancePlugin): @classmethod def repair(cls, instance): - for content_node, proxy_node in cls.get_invalid_couples(cls, instance): - lib.set_id(proxy_node, lib.get_id(content_node), overwrite=False) + for content_node, proxy_node in cls.get_invalid_couples(instance): + lib.set_id(proxy_node, lib.get_id(content_node), overwrite=True) diff --git a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py index 6975d583bb..49913fa42b 100644 --- a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py +++ b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py @@ -23,11 +23,13 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin): def process(self, instance): # we cannot ask this until user open render settings as - # `defaultArnoldRenderOptions` doesn't exists + # `defaultArnoldRenderOptions` doesn't exist + errors = [] + try: - relative_texture = cmds.getAttr( + absolute_texture = cmds.getAttr( "defaultArnoldRenderOptions.absolute_texture_paths") - relative_procedural = cmds.getAttr( + absolute_procedural = cmds.getAttr( "defaultArnoldRenderOptions.absolute_procedural_paths") texture_search_path = cmds.getAttr( "defaultArnoldRenderOptions.tspath" @@ -42,10 +44,11 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin): scene_dir, scene_basename = os.path.split(cmds.file(q=True, loc=True)) scene_name, _ = os.path.splitext(scene_basename) - assert self.maya_is_true(relative_texture) is not True, \ - ("Texture path is set to be absolute") - assert self.maya_is_true(relative_procedural) is not True, \ - ("Procedural path is set to be absolute") + + if self.maya_is_true(absolute_texture): + errors.append("Texture path is set to be absolute") + if self.maya_is_true(absolute_procedural): + errors.append("Procedural path is set to be absolute") anatomy = instance.context.data["anatomy"] @@ -57,15 +60,20 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin): for k in keys: paths.append("[{}]".format(k)) - self.log.info("discovered roots: {}".format(":".join(paths))) + self.log.debug("discovered roots: {}".format(":".join(paths))) - assert ":".join(paths) in texture_search_path, ( - "Project roots are not in texture_search_path" - ) + if ":".join(paths) not in texture_search_path: + errors.append(( + "Project roots {} are not in texture_search_path: {}" + ).format(paths, texture_search_path)) - assert ":".join(paths) in procedural_search_path, ( - "Project roots are not in procedural_search_path" - ) + if ":".join(paths) not in procedural_search_path: + errors.append(( + "Project roots {} are not in procedural_search_path: {}" + ).format(paths, procedural_search_path)) + + if errors: + raise PublishValidationError("\n".join(errors)) @classmethod def repair(cls, instance): diff --git a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py index 78bb022785..f9aa7f82d0 100644 --- a/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py +++ b/openpype/hosts/maya/plugins/publish/validate_render_image_rule.py @@ -1,10 +1,8 @@ +import pyblish.api from maya import cmds -import pyblish.api from openpype.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, -) + PublishValidationError, RepairAction, ValidateContentsOrder) class ValidateRenderImageRule(pyblish.api.InstancePlugin): @@ -27,12 +25,12 @@ class ValidateRenderImageRule(pyblish.api.InstancePlugin): required_images_rule = self.get_default_render_image_folder(instance) current_images_rule = cmds.workspace(fileRuleEntry="images") - assert current_images_rule == required_images_rule, ( - "Invalid workspace `images` file rule value: '{}'. " - "Must be set to: '{}'".format( - current_images_rule, required_images_rule - ) - ) + if current_images_rule != required_images_rule: + raise PublishValidationError( + ( + "Invalid workspace `images` file rule value: '{}'. " + "Must be set to: '{}'" + ).format(current_images_rule, required_images_rule)) @classmethod def repair(cls, instance): diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 17be882fc8..dccb4ade78 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -278,16 +278,18 @@ 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(): + for data in cls.get_nodes(instance, renderer): for node in data["nodes"]: try: render_value = cmds.getAttr( - "{}.{}".format(node, attribute) + "{}.{}".format(node, data["attribute"]) ) except PublishValidationError: invalid = True cls.log.error( - "Cannot get value of {}.{}".format(node, attribute) + "Cannot get value of {}.{}".format( + node, data["attribute"] + ) ) else: if render_value not in data["values"]: @@ -295,7 +297,10 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error( "Invalid value {} set on {}.{}. Expecting " "{}".format( - render_value, node, attribute, data["values"] + render_value, + node, + data["attribute"], + data["values"] ) ) @@ -309,7 +314,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): "{}_render_attributes".format(renderer) ) or [] ) - result = {} + result = [] for attr, values in OrderedDict(validation_settings).items(): values = [convert_to_int_or_float(v) for v in values if v] @@ -339,7 +344,13 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): ) continue - result[attribute_name] = {"nodes": nodes, "values": values} + result.append( + { + "attribute": attribute_name, + "nodes": nodes, + "values": values + } + ) return result @@ -354,11 +365,11 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): "{aov_separator}", instance.data.get("aovSeparator", "_") ) - for attribute, data in cls.get_nodes(instance, renderer).items(): + for data in cls.get_nodes(instance, renderer): if not data["values"]: continue for node in data["nodes"]: - lib.set_attribute(attribute, data["values"][0], node) + lib.set_attribute(data["attribute"], data["values"][0], node) with lib.renderlayer(layer_node): default = lib.RENDER_ATTRS['default'] @@ -368,6 +379,17 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cmds.setAttr("defaultRenderGlobals.animation", True) # Repair prefix + if renderer == "arnold": + multipart = cmds.getAttr("defaultArnoldDriver.mergeAOVs") + if multipart: + separator_variations = [ + "_", + "_", + "", + ] + for variant in separator_variations: + default_prefix = default_prefix.replace(variant, "") + if renderer != "renderman": node = render_attrs["node"] prefix_attr = render_attrs["prefix"] diff --git a/openpype/hosts/maya/plugins/publish/validate_xgen.py b/openpype/hosts/maya/plugins/publish/validate_xgen.py index 47b24e218c..a44fa56308 100644 --- a/openpype/hosts/maya/plugins/publish/validate_xgen.py +++ b/openpype/hosts/maya/plugins/publish/validate_xgen.py @@ -61,9 +61,7 @@ class ValidateXgen(pyblish.api.InstancePlugin): # We need a namespace else there will be a naming conflict when # extracting because of stripping namespaces and parenting to world. node_names = [instance.data["xgmPalette"]] - for _, connections in instance.data["xgenConnections"].items(): - node_names.append(connections["transform"].split(".")[0]) - + node_names.extend(instance.data["xgenConnections"]) non_namespaced_nodes = [n for n in node_names if ":" not in n] if non_namespaced_nodes: raise PublishValidationError( diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index a439142051..17d75d1e94 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -30,6 +30,7 @@ from openpype.lib import ( env_value_to_bool, Logger, get_version_from_path, + StringTemplate, ) from openpype.settings import ( @@ -39,6 +40,7 @@ from openpype.settings import ( from openpype.modules import ModulesManager from openpype.pipeline.template_data import get_template_data_with_names from openpype.pipeline import ( + get_current_project_name, discover_legacy_creator_plugins, legacy_io, Anatomy, @@ -1299,13 +1301,8 @@ def create_write_node( # build file path to workfiles fdir = str(anatomy_filled["work"]["folder"]).replace("\\", "/") - fpath = data["fpath_template"].format( - work=fdir, - version=data["version"], - subset=data["subset"], - frame=data["frame"], - ext=ext - ) + data["work"] = fdir + fpath = StringTemplate(data["fpath_template"]).format_strict(data) # create directory if not os.path.isdir(os.path.dirname(fpath)): @@ -1403,8 +1400,6 @@ def create_write_node( # adding write to read button add_button_clear_rendered(GN, os.path.dirname(fpath)) - GN.addKnob(nuke.Text_Knob('', '')) - # set tile color tile_color = next( iter( @@ -2003,63 +1998,104 @@ class WorkfileSettings(object): "Attention! Viewer nodes {} were erased." "It had wrong color profile".format(erased_viewers)) - def set_root_colorspace(self, nuke_colorspace): + def set_root_colorspace(self, imageio_host): ''' Adds correct colorspace to root Arguments: - nuke_colorspace (dict): adjustmensts from presets + imageio_host (dict): host colorspace configurations ''' - workfile_settings = nuke_colorspace["workfile"] + config_data = get_imageio_config( + project_name=get_current_project_name(), + host_name="nuke" + ) - # resolve config data if they are enabled in host - config_data = None - if nuke_colorspace.get("ocio_config", {}).get("enabled"): - # switch ocio config to custom config - workfile_settings["OCIO_config"] = "custom" - workfile_settings["colorManagement"] = "OCIO" + workfile_settings = imageio_host["workfile"] - # get resolved ocio config path - config_data = get_imageio_config( - legacy_io.active_project(), "nuke" - ) + if not config_data: + # TODO: backward compatibility for old projects - remove later + # perhaps old project overrides is having it set to older version + # with use of `customOCIOConfigPath` + resolved_path = None + if workfile_settings.get("customOCIOConfigPath"): + unresolved_path = workfile_settings["customOCIOConfigPath"] + ocio_paths = unresolved_path[platform.system().lower()] - # first set OCIO - if self._root_node["colorManagement"].value() \ - not in str(workfile_settings["colorManagement"]): - self._root_node["colorManagement"].setValue( - str(workfile_settings["colorManagement"])) + for ocio_p in ocio_paths: + resolved_path = str(ocio_p).format(**os.environ) + if not os.path.exists(resolved_path): + continue - # we dont need the key anymore - workfile_settings.pop("colorManagement") + if resolved_path: + # set values to root + self._root_node["colorManagement"].setValue("OCIO") + self._root_node["OCIO_config"].setValue("custom") + self._root_node["customOCIOConfigPath"].setValue( + resolved_path) + else: + # no ocio config found and no custom path used + if self._root_node["colorManagement"].value() \ + not in str(workfile_settings["colorManagement"]): + self._root_node["colorManagement"].setValue( + str(workfile_settings["colorManagement"])) - # second set ocio version - if self._root_node["OCIO_config"].value() \ - not in str(workfile_settings["OCIO_config"]): - self._root_node["OCIO_config"].setValue( - str(workfile_settings["OCIO_config"])) + # second set ocio version + if self._root_node["OCIO_config"].value() \ + not in str(workfile_settings["OCIO_config"]): + self._root_node["OCIO_config"].setValue( + str(workfile_settings["OCIO_config"])) - # we dont need the key anymore - workfile_settings.pop("OCIO_config") + else: + # set values to root + self._root_node["colorManagement"].setValue("OCIO") - # third set ocio custom path - if config_data: - self._root_node["customOCIOConfigPath"].setValue( - str(config_data["path"]).replace("\\", "/") - ) - # backward compatibility, remove in case it exists - workfile_settings.pop("customOCIOConfigPath") + # we dont need the key anymore + workfile_settings.pop("customOCIOConfigPath", None) + workfile_settings.pop("colorManagement", None) + workfile_settings.pop("OCIO_config", None) # then set the rest - for knob, value in workfile_settings.items(): + for knob, value_ in workfile_settings.items(): # skip unfilled ocio config path # it will be dict in value - if isinstance(value, dict): + if isinstance(value_, dict): continue - if self._root_node[knob].value() not in value: - self._root_node[knob].setValue(str(value)) + # skip empty values + if not value_: + continue + if self._root_node[knob].value() not in value_: + self._root_node[knob].setValue(str(value_)) log.debug("nuke.root()['{}'] changed to: {}".format( - knob, value)) + knob, value_)) + + # set ocio config path + if config_data: + current_ocio_path = os.getenv("OCIO") + if current_ocio_path != config_data["path"]: + message = """ +It seems like there's a mismatch between the OCIO config path set in your Nuke +settings and the actual path set in your OCIO environment. + +To resolve this, please follow these steps: +1. Close Nuke if it's currently open. +2. Reopen Nuke. + +Please note the paths for your reference: + +- The OCIO environment path currently set: + `{env_path}` + +- The path in your current Nuke settings: + `{settings_path}` + +Reopening Nuke should synchronize these paths and resolve any discrepancies. +""" + nuke.message( + message.format( + env_path=current_ocio_path, + settings_path=config_data["path"] + ) + ) def set_writes_colorspace(self): ''' Adds correct colorspace to write node dict @@ -2156,7 +2192,7 @@ class WorkfileSettings(object): log.debug(changes) if changes: - msg = "Read nodes are not set to correct colospace:\n\n" + msg = "Read nodes are not set to correct colorspace:\n\n" for nname, knobs in changes.items(): msg += ( " - node: '{0}' is now '{1}' but should be '{2}'\n" diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 75b0f80d21..8406a251e9 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -237,15 +237,25 @@ def _install_menu(): menu.addSeparator() if not ASSIST: + # only add parent if nuke version is 14 or higher + # known issue with no solution yet menu.addCommand( "Create...", lambda: host_tools.show_publisher( + parent=( + main_window if nuke.NUKE_VERSION_RELEASE >= 14 else None + ), tab="create" ) ) + # only add parent if nuke version is 14 or higher + # known issue with no solution yet menu.addCommand( "Publish...", lambda: host_tools.show_publisher( + parent=( + main_window if nuke.NUKE_VERSION_RELEASE >= 14 else None + ), tab="publish" ) ) @@ -564,6 +574,7 @@ def remove_instance(instance): instance_node = instance.transient_data["node"] instance_knob = instance_node.knobs()[INSTANCE_DATA_KNOB] instance_node.removeKnob(instance_knob) + nuke.delete(instance_node) def select_instance(instance): diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index 3566cb64c1..7035da2bb5 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -75,20 +75,6 @@ class NukeCreator(NewCreator): for pass_key in keys: creator_attrs[pass_key] = pre_create_data[pass_key] - def add_info_knob(self, node): - if "OP_info" in node.knobs().keys(): - return - - # add info text - info_knob = nuke.Text_Knob("OP_info", "") - info_knob.setValue(""" - -

This node is maintained by OpenPype Publisher.

-

To remove it use Publisher gui.

-
- """) - node.addKnob(info_knob) - def check_existing_subset(self, subset_name): """Make sure subset name is unique. @@ -153,8 +139,6 @@ class NukeCreator(NewCreator): created_node = nuke.createNode(node_type) created_node["name"].setValue(node_name) - self.add_info_knob(created_node) - for key, values in node_knobs.items(): if key in created_node.knobs(): created_node["key"].setValue(values) diff --git a/openpype/hosts/nuke/api/workfile_template_builder.py b/openpype/hosts/nuke/api/workfile_template_builder.py index 72d4ffb476..766fb0bc47 100644 --- a/openpype/hosts/nuke/api/workfile_template_builder.py +++ b/openpype/hosts/nuke/api/workfile_template_builder.py @@ -25,6 +25,7 @@ from .lib import ( select_nodes, duplicate_node, node_tempfile, + get_main_window ) PLACEHOLDER_SET = "PLACEHOLDERS_SET" @@ -963,8 +964,9 @@ def update_workfile_template(*args): def create_placeholder(*args): host = registered_host() builder = NukeTemplateBuilder(host) - window = WorkfileBuildPlaceholderDialog(host, builder) - window.exec_() + window = WorkfileBuildPlaceholderDialog(host, builder, + parent=get_main_window()) + window.show() def update_placeholder(*args): @@ -988,6 +990,7 @@ def update_placeholder(*args): raise ValueError("Too many selected nodes") placeholder_item = placeholder_items[0] - window = WorkfileBuildPlaceholderDialog(host, builder) + window = WorkfileBuildPlaceholderDialog(host, builder, + parent=get_main_window()) window.set_update_mode(placeholder_item) window.exec_() diff --git a/openpype/hosts/nuke/plugins/create/create_backdrop.py b/openpype/hosts/nuke/plugins/create/create_backdrop.py index ff415626be..52959bbef2 100644 --- a/openpype/hosts/nuke/plugins/create/create_backdrop.py +++ b/openpype/hosts/nuke/plugins/create/create_backdrop.py @@ -36,8 +36,6 @@ class CreateBackdrop(NukeCreator): created_node["note_font_size"].setValue(24) created_node["label"].setValue("[{}]".format(node_name)) - self.add_info_knob(created_node) - return created_node def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/nuke/plugins/create/create_camera.py b/openpype/hosts/nuke/plugins/create/create_camera.py index 5553645af6..b84280b11b 100644 --- a/openpype/hosts/nuke/plugins/create/create_camera.py +++ b/openpype/hosts/nuke/plugins/create/create_camera.py @@ -39,8 +39,6 @@ class CreateCamera(NukeCreator): created_node["name"].setValue(node_name) - self.add_info_knob(created_node) - return created_node def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/nuke/plugins/create/create_gizmo.py b/openpype/hosts/nuke/plugins/create/create_gizmo.py index e3ce70dd59..cbe2f635c9 100644 --- a/openpype/hosts/nuke/plugins/create/create_gizmo.py +++ b/openpype/hosts/nuke/plugins/create/create_gizmo.py @@ -40,8 +40,6 @@ class CreateGizmo(NukeCreator): created_node["name"].setValue(node_name) - self.add_info_knob(created_node) - return created_node def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/nuke/plugins/create/create_model.py b/openpype/hosts/nuke/plugins/create/create_model.py index 08a53abca2..a94c9f0313 100644 --- a/openpype/hosts/nuke/plugins/create/create_model.py +++ b/openpype/hosts/nuke/plugins/create/create_model.py @@ -40,8 +40,6 @@ class CreateModel(NukeCreator): created_node["name"].setValue(node_name) - self.add_info_knob(created_node) - return created_node def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/nuke/plugins/create/create_source.py b/openpype/hosts/nuke/plugins/create/create_source.py index 57504b5d53..8419c3ef33 100644 --- a/openpype/hosts/nuke/plugins/create/create_source.py +++ b/openpype/hosts/nuke/plugins/create/create_source.py @@ -32,7 +32,7 @@ class CreateSource(NukeCreator): read_node["tile_color"].setValue( int(self.node_color, 16)) read_node["name"].setValue(node_name) - self.add_info_knob(read_node) + return read_node def create(self, subset_name, instance_data, pre_create_data): diff --git a/openpype/hosts/nuke/plugins/create/create_write_image.py b/openpype/hosts/nuke/plugins/create/create_write_image.py index b74cea5dae..0c8adfb75c 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_image.py +++ b/openpype/hosts/nuke/plugins/create/create_write_image.py @@ -86,7 +86,6 @@ class CreateWriteImage(napi.NukeWriteCreator): "frame": nuke.frame() } ) - self.add_info_knob(created_node) self._add_frame_range_limit(created_node, instance_data) diff --git a/openpype/hosts/nuke/plugins/create/create_write_prerender.py b/openpype/hosts/nuke/plugins/create/create_write_prerender.py index 387768b1dd..f46dd2d6d5 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_prerender.py +++ b/openpype/hosts/nuke/plugins/create/create_write_prerender.py @@ -74,7 +74,6 @@ class CreateWritePrerender(napi.NukeWriteCreator): "height": height } ) - self.add_info_knob(created_node) self._add_frame_range_limit(created_node) diff --git a/openpype/hosts/nuke/plugins/create/create_write_render.py b/openpype/hosts/nuke/plugins/create/create_write_render.py index 09257f662e..c24405873a 100644 --- a/openpype/hosts/nuke/plugins/create/create_write_render.py +++ b/openpype/hosts/nuke/plugins/create/create_write_render.py @@ -66,7 +66,6 @@ class CreateWriteRender(napi.NukeWriteCreator): "height": height } ) - self.add_info_knob(created_node) self.integrate_links(created_node, outputs=False) diff --git a/openpype/hosts/nuke/plugins/publish/extract_render_local.py b/openpype/hosts/nuke/plugins/publish/extract_render_local.py index e5feda4cd8..e2cf2addc5 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_render_local.py +++ b/openpype/hosts/nuke/plugins/publish/extract_render_local.py @@ -23,7 +23,7 @@ class NukeRenderLocal(publish.Extractor, order = pyblish.api.ExtractorOrder label = "Render Local" hosts = ["nuke"] - families = ["render.local", "prerender.local", "still.local"] + families = ["render.local", "prerender.local", "image.local"] def process(self, instance): child_nodes = ( @@ -136,9 +136,9 @@ class NukeRenderLocal(publish.Extractor, families.remove('prerender.local') families.insert(0, "prerender") instance.data["anatomyData"]["family"] = "prerender" - elif "still.local" in families: + elif "image.local" in families: instance.data['family'] = 'image' - families.remove('still.local') + families.remove('image.local') instance.data["anatomyData"]["family"] = "image" instance.data["families"] = families diff --git a/openpype/hosts/nuke/startup/custom_write_node.py b/openpype/hosts/nuke/startup/custom_write_node.py new file mode 100644 index 0000000000..ea53725834 --- /dev/null +++ b/openpype/hosts/nuke/startup/custom_write_node.py @@ -0,0 +1,151 @@ +""" OpenPype custom script for setting up write nodes for non-publish """ +import os +import nuke +import nukescripts +from openpype.pipeline import Anatomy +from openpype.hosts.nuke.api.lib import ( + set_node_knobs_from_settings, + get_nuke_imageio_settings +) + + +temp_rendering_path_template = ( + "{work}/renders/nuke/{subset}/{subset}.{frame}.{ext}") + +knobs_setting = { + "knobs": [ + { + "type": "text", + "name": "file_type", + "value": "exr" + }, + { + "type": "text", + "name": "datatype", + "value": "16 bit half" + }, + { + "type": "text", + "name": "compression", + "value": "Zip (1 scanline)" + }, + { + "type": "bool", + "name": "autocrop", + "value": True + }, + { + "type": "color_gui", + "name": "tile_color", + "value": [ + 186, + 35, + 35, + 255 + ] + }, + { + "type": "text", + "name": "channels", + "value": "rgb" + }, + { + "type": "bool", + "name": "create_directories", + "value": True + } + ] +} + + +class WriteNodeKnobSettingPanel(nukescripts.PythonPanel): + """ Write Node's Knobs Settings Panel """ + def __init__(self): + nukescripts.PythonPanel.__init__(self, "Set Knobs Value(Write Node)") + + preset_name, _ = self.get_node_knobs_setting() + # create knobs + + self.selected_preset_name = nuke.Enumeration_Knob( + 'preset_selector', 'presets', preset_name) + # add knobs to panel + self.addKnob(self.selected_preset_name) + + def process(self): + """ Process the panel values. """ + write_selected_nodes = [ + selected_nodes for selected_nodes in nuke.selectedNodes() + if selected_nodes.Class() == "Write"] + + selected_preset = self.selected_preset_name.value() + ext = None + knobs = knobs_setting["knobs"] + preset_name, node_knobs_presets = ( + self.get_node_knobs_setting(selected_preset) + ) + + if selected_preset and preset_name: + if not node_knobs_presets: + nuke.message( + "No knobs value found in subset group.." + "\nDefault setting will be used..") + else: + knobs = node_knobs_presets + + ext_knob_list = [knob for knob in knobs if knob["name"] == "file_type"] + if not ext_knob_list: + nuke.message( + "ERROR: No file type found in the subset's knobs." + "\nPlease add one to complete setting up the node") + return + else: + for knob in ext_knob_list: + ext = knob["value"] + + anatomy = Anatomy() + + frame_padding = int( + anatomy.templates["render"].get( + "frame_padding" + ) + ) + for write_node in write_selected_nodes: + # data for mapping the path + data = { + "work": os.getenv("AVALON_WORKDIR"), + "subset": write_node["name"].value(), + "frame": "#" * frame_padding, + "ext": ext + } + file_path = temp_rendering_path_template.format(**data) + file_path = file_path.replace("\\", "/") + write_node["file"].setValue(file_path) + set_node_knobs_from_settings(write_node, knobs) + + def get_node_knobs_setting(self, selected_preset=None): + preset_name = [] + knobs_nodes = [] + settings = [ + node_settings for node_settings + in get_nuke_imageio_settings()["nodes"]["overrideNodes"] + if node_settings["nukeNodeClass"] == "Write" + and node_settings["subsets"] + ] + if not settings: + return + + for i, _ in enumerate(settings): + if selected_preset in settings[i]["subsets"]: + knobs_nodes = settings[i]["knobs"] + + for setting in settings: + for subset in setting["subsets"]: + preset_name.append(subset) + + return preset_name, knobs_nodes + + +def main(): + p_ = WriteNodeKnobSettingPanel() + if p_.showModalDialog(): + print(p_.process()) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py index 96aaae23dc..8fa53f5f48 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py @@ -222,7 +222,6 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): "label": subset, "name": subset, "family": in_data["family"], - # "version": in_data.get("version", 1), "frameStart": in_data.get("representations", [None])[0].get( "frameStart", None ), @@ -232,6 +231,14 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): "families": instance_families } ) + # Fill version only if 'use_next_available_version' is disabled + # and version is filled in instance data + version = in_data.get("version") + use_next_available_version = in_data.get( + "use_next_available_version", True) + if not use_next_available_version and version is not None: + instance.data["version"] = version + self.log.info("collected instance: {}".format(pformat(instance.data))) self.log.info("parsing data: {}".format(pformat(in_data))) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 75930f0f31..36e041a32c 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -1,4 +1,14 @@ -from openpype.lib.attribute_definitions import FileDef +from openpype.client import ( + get_assets, + get_subsets, + get_last_versions, +) +from openpype.lib.attribute_definitions import ( + FileDef, + BoolDef, + NumberDef, + UISeparatorDef, +) from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS from openpype.pipeline.create import ( Creator, @@ -94,6 +104,7 @@ class TrayPublishCreator(Creator): class SettingsCreator(TrayPublishCreator): create_allow_context_change = True create_allow_thumbnail = True + allow_version_control = False extensions = [] @@ -101,8 +112,18 @@ class SettingsCreator(TrayPublishCreator): # Pass precreate data to creator attributes thumbnail_path = pre_create_data.pop(PRE_CREATE_THUMBNAIL_KEY, None) + # Fill 'version_to_use' if version control is enabled + if self.allow_version_control: + asset_name = data["asset"] + subset_docs_by_asset_id = self._prepare_next_versions( + [asset_name], [subset_name]) + version = subset_docs_by_asset_id[asset_name].get(subset_name) + pre_create_data["version_to_use"] = version + data["_previous_last_version"] = version + data["creator_attributes"] = pre_create_data data["settings_creator"] = True + # Create new instance new_instance = CreatedInstance(self.family, subset_name, data, self) @@ -111,7 +132,158 @@ class SettingsCreator(TrayPublishCreator): if thumbnail_path: self.set_instance_thumbnail_path(new_instance.id, thumbnail_path) + def _prepare_next_versions(self, asset_names, subset_names): + """Prepare next versions for given asset and subset names. + + Todos: + Expect combination of subset names by asset name to avoid + unnecessary server calls for unused subsets. + + Args: + asset_names (Iterable[str]): Asset names. + subset_names (Iterable[str]): Subset names. + + Returns: + dict[str, dict[str, int]]: Last versions by asset + and subset names. + """ + + # Prepare all versions for all combinations to '1' + subset_docs_by_asset_id = { + asset_name: { + subset_name: 1 + for subset_name in subset_names + } + for asset_name in asset_names + } + if not asset_names or not subset_names: + return subset_docs_by_asset_id + + asset_docs = get_assets( + self.project_name, + asset_names=asset_names, + fields=["_id", "name"] + ) + asset_names_by_id = { + asset_doc["_id"]: asset_doc["name"] + for asset_doc in asset_docs + } + subset_docs = list(get_subsets( + self.project_name, + asset_ids=asset_names_by_id.keys(), + subset_names=subset_names, + fields=["_id", "name", "parent"] + )) + + subset_ids = {subset_doc["_id"] for subset_doc in subset_docs} + last_versions = get_last_versions( + self.project_name, + subset_ids, + fields=["name", "parent"]) + + for subset_doc in subset_docs: + asset_id = subset_doc["parent"] + asset_name = asset_names_by_id[asset_id] + subset_name = subset_doc["name"] + subset_id = subset_doc["_id"] + last_version = last_versions.get(subset_id) + version = 0 + if last_version is not None: + version = last_version["name"] + subset_docs_by_asset_id[asset_name][subset_name] += version + return subset_docs_by_asset_id + + def _fill_next_versions(self, instances_data): + """Fill next version for instances. + + Instances have also stored previous next version to be able to + recognize if user did enter different version. If version was + not changed by user, or user set it to '0' the next version will be + updated by current database state. + """ + + filtered_instance_data = [] + for instance in instances_data: + previous_last_version = instance.get("_previous_last_version") + creator_attributes = instance["creator_attributes"] + use_next_version = creator_attributes.get( + "use_next_version", True) + version = creator_attributes.get("version_to_use", 0) + if ( + use_next_version + or version == 0 + or version == previous_last_version + ): + filtered_instance_data.append(instance) + + asset_names = { + instance["asset"] + for instance in filtered_instance_data} + subset_names = { + instance["subset"] + for instance in filtered_instance_data} + subset_docs_by_asset_id = self._prepare_next_versions( + asset_names, subset_names + ) + for instance in filtered_instance_data: + asset_name = instance["asset"] + subset_name = instance["subset"] + version = subset_docs_by_asset_id[asset_name][subset_name] + instance["creator_attributes"]["version_to_use"] = version + instance["_previous_last_version"] = version + + def collect_instances(self): + """Collect instances from host. + + Overriden to be able to manage version control attributes. If version + control is disabled, the attributes will be removed from instances, + and next versions are filled if is version control enabled. + """ + + instances_by_identifier = cache_and_get_instances( + self, SHARED_DATA_KEY, list_instances + ) + instances = instances_by_identifier[self.identifier] + if not instances: + return + + if self.allow_version_control: + self._fill_next_versions(instances) + + for instance_data in instances: + # Make sure that there are not data related to version control + # if plugin does not support it + if not self.allow_version_control: + instance_data.pop("_previous_last_version", None) + creator_attributes = instance_data["creator_attributes"] + creator_attributes.pop("version_to_use", None) + creator_attributes.pop("use_next_version", None) + + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) + def get_instance_attr_defs(self): + defs = self.get_pre_create_attr_defs() + if self.allow_version_control: + defs += [ + UISeparatorDef(), + BoolDef( + "use_next_version", + default=True, + label="Use next version", + ), + NumberDef( + "version_to_use", + default=1, + minimum=0, + maximum=999, + label="Version to use", + ) + ] + return defs + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attributes return [ FileDef( "representation_files", @@ -132,10 +304,6 @@ class SettingsCreator(TrayPublishCreator): ) ] - def get_pre_create_attr_defs(self): - # Use same attributes as for instance attrobites - return self.get_instance_attr_defs() - @classmethod def from_settings(cls, item_data): identifier = item_data["identifier"] @@ -155,6 +323,8 @@ class SettingsCreator(TrayPublishCreator): "extensions": item_data["extensions"], "allow_sequences": item_data["allow_sequences"], "allow_multiple_items": item_data["allow_multiple_items"], - "default_variants": item_data["default_variants"] + "allow_version_control": item_data.get( + "allow_version_control", False), + "default_variants": item_data["default_variants"], } ) diff --git a/openpype/hosts/traypublisher/plugins/create/create_editorial.py b/openpype/hosts/traypublisher/plugins/create/create_editorial.py index 0630dfb3da..8640500b18 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_editorial.py +++ b/openpype/hosts/traypublisher/plugins/create/create_editorial.py @@ -487,7 +487,22 @@ or updating already created. Publishing will create OTIO file. ) # get video stream data - video_stream = media_data["streams"][0] + video_streams = [] + audio_streams = [] + for stream in media_data["streams"]: + codec_type = stream.get("codec_type") + if codec_type == "audio": + audio_streams.append(stream) + + elif codec_type == "video": + video_streams.append(stream) + + if not video_streams: + raise ValueError( + "Could not find video stream in source file." + ) + + video_stream = video_streams[0] return_data = { "video": True, "start_frame": 0, @@ -500,12 +515,7 @@ or updating already created. Publishing will create OTIO file. } # get audio streams data - audio_stream = [ - stream for stream in media_data["streams"] - if stream["codec_type"] == "audio" - ] - - if audio_stream: + if audio_streams: return_data["audio"] = True except Exception as exc: diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py index c081216481..3fa3c3b8c8 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -47,6 +47,8 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): "Created temp staging directory for instance {}. {}" ).format(instance_label, tmp_folder)) + self._fill_version(instance, instance_label) + # Store filepaths for validation of their existence source_filepaths = [] # Make sure there are no representations with same name @@ -93,6 +95,28 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): ) ) + def _fill_version(self, instance, instance_label): + """Fill instance version under which will be instance integrated. + + Instance must have set 'use_next_version' to 'False' + and 'version_to_use' to version to use. + + Args: + instance (pyblish.api.Instance): Instance to fill version for. + instance_label (str): Label of instance to fill version for. + """ + + creator_attributes = instance.data["creator_attributes"] + use_next_version = creator_attributes.get("use_next_version", True) + # If 'version_to_use' is '0' it means that next version should be used + version_to_use = creator_attributes.get("version_to_use", 0) + if use_next_version or not version_to_use: + return + instance.data["version"] = version_to_use + self.log.debug( + "Version for instance \"{}\" was set to \"{}\"".format( + instance_label, version_to_use)) + def _create_main_representations( self, instance, diff --git a/openpype/hosts/traypublisher/plugins/publish/help/validate_existing_version.xml b/openpype/hosts/traypublisher/plugins/publish/help/validate_existing_version.xml new file mode 100644 index 0000000000..8a3b8f4d7d --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/help/validate_existing_version.xml @@ -0,0 +1,16 @@ + + + +Version already exists + +## Version already exists + +Version {version} you have set on instance '{subset_name}' under '{asset_name}' already exists. This validation is enabled by default to prevent accidental override of existing versions. + +### How to repair? +- Click on 'Repair' action -> this will change version to next available. +- Disable validation on the instance if you are sure you want to override the version. +- Reset publishing and manually change the version number. + + + diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_existing_version.py b/openpype/hosts/traypublisher/plugins/publish/validate_existing_version.py new file mode 100644 index 0000000000..1fb27acdeb --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_existing_version.py @@ -0,0 +1,57 @@ +import pyblish.api + +from openpype.pipeline.publish import ( + ValidateContentsOrder, + PublishXmlValidationError, + OptionalPyblishPluginMixin, + RepairAction, +) + + +class ValidateExistingVersion( + OptionalPyblishPluginMixin, + pyblish.api.InstancePlugin +): + label = "Validate Existing Version" + order = ValidateContentsOrder + + hosts = ["traypublisher"] + + actions = [RepairAction] + + settings_category = "traypublisher" + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + + version = instance.data.get("version") + if version is None: + return + + last_version = instance.data.get("latestVersion") + if last_version is None or last_version < version: + return + + subset_name = instance.data["subset"] + msg = "Version {} already exists for subset {}.".format( + version, subset_name) + + formatting_data = { + "subset_name": subset_name, + "asset_name": instance.data["asset"], + "version": version + } + raise PublishXmlValidationError( + self, msg, formatting_data=formatting_data) + + @classmethod + def repair(cls, instance): + create_context = instance.context.data["create_context"] + created_instance = create_context.get_instance_by_id( + instance.data["instance_id"]) + creator_attributes = created_instance["creator_attributes"] + # Disable version override + creator_attributes["use_next_version"] = True + create_context.save_changes() diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index 4039d2c8ec..9fe4b4d3c1 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -293,7 +293,7 @@ class BatchPublishEndpoint(WebpublishApiEndpoint): log.debug("Adding to queue") self.resource.studio_task_queue.append(args) else: - subprocess.call(args) + subprocess.Popen(args) return Response( status=200, diff --git a/openpype/lib/project_backpack.py b/openpype/lib/project_backpack.py index 07107ec011..91a5b76e35 100644 --- a/openpype/lib/project_backpack.py +++ b/openpype/lib/project_backpack.py @@ -113,31 +113,46 @@ def pack_project( project_name )) - roots = project_doc["config"]["roots"] - # Determine root directory of project - source_root = None - source_root_name = None - for root_name, root_value in roots.items(): - if source_root is not None: - raise ValueError( - "Packaging is supported only for single root projects" - ) - source_root = root_value - source_root_name = root_name + if only_documents and not destination_dir: + raise ValueError(( + "Destination directory must be defined" + " when only documents should be packed." + )) - root_path = source_root[platform.system().lower()] - print("Using root \"{}\" with path \"{}\"".format( - source_root_name, root_path - )) + root_path = None + source_root = {} + project_source_path = None + if not only_documents: + roots = project_doc["config"]["roots"] + # Determine root directory of project + source_root = None + source_root_name = None + for root_name, root_value in roots.items(): + if source_root is not None: + raise ValueError( + "Packaging is supported only for single root projects" + ) + source_root = root_value + source_root_name = root_name - project_source_path = os.path.join(root_path, project_name) - if not os.path.exists(project_source_path): - raise ValueError("Didn't find source of project files") + root_path = source_root[platform.system().lower()] + print("Using root \"{}\" with path \"{}\"".format( + source_root_name, root_path + )) + + project_source_path = os.path.join(root_path, project_name) + if not os.path.exists(project_source_path): + raise ValueError("Didn't find source of project files") # Determine zip filepath where data will be stored if not destination_dir: destination_dir = root_path + if not destination_dir: + raise ValueError( + "Project {} does not have any roots.".format(project_name) + ) + destination_dir = os.path.normpath(destination_dir) if not os.path.exists(destination_dir): os.makedirs(destination_dir) @@ -273,8 +288,7 @@ def unpack_project( low_platform = platform.system().lower() project_name = metadata["project_name"] - source_root = metadata["root"] - root_path = source_root[low_platform] + root_path = metadata["root"].get(low_platform) # Drop existing collection replace_project_documents(project_name, docs, database_name) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 57968b3700..de6495900e 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -51,7 +51,7 @@ IMAGE_EXTENSIONS = { ".jng", ".jpeg", ".jpeg-ls", ".jpeg", ".2000", ".jpg", ".xr", ".jpeg", ".xt", ".jpeg-hdr", ".kra", ".mng", ".miff", ".nrrd", ".ora", ".pam", ".pbm", ".pgm", ".ppm", ".pnm", ".pcx", ".pgf", - ".pictor", ".png", ".psb", ".psp", ".qtvr", ".ras", + ".pictor", ".png", ".psd", ".psb", ".psp", ".qtvr", ".ras", ".rgbe", ".logluv", ".tiff", ".sgi", ".tga", ".tiff", ".tiff/ep", ".tiff/it", ".ufo", ".ufp", ".wbmp", ".webp", ".xbm", ".xcf", ".xpm", ".xwd" diff --git a/openpype/modules/README.md b/openpype/modules/README.md index 86afdb9d91..ce3f99b338 100644 --- a/openpype/modules/README.md +++ b/openpype/modules/README.md @@ -138,7 +138,8 @@ class ClockifyModule( "publish": [], "create": [], "load": [], - "actions": [] + "actions": [], + "inventory": [] } ``` diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 732525b6eb..fb9b4e1096 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -740,15 +740,16 @@ class ModulesManager: Unknown keys are logged out. Returns: - dict: Output is dictionary with keys "publish", "create", "load" - and "actions" each containing list of paths. + dict: Output is dictionary with keys "publish", "create", "load", + "actions" and "inventory" each containing list of paths. """ # Output structure output = { "publish": [], "create": [], "load": [], - "actions": [] + "actions": [], + "inventory": [] } unknown_keys_by_module = {} for module in self.get_enabled_modules(): @@ -853,6 +854,21 @@ class ModulesManager: host_name ) + def collect_inventory_action_paths(self, host_name): + """Helper to collect load plugin paths from modules. + + Args: + host_name (str): For which host are load plugins meant. + + Returns: + list: List of pyblish plugin paths. + """ + + return self._collect_plugin_paths( + "get_inventory_action_paths", + host_name + ) + def get_host_module(self, host_name): """Find host module by host name. diff --git a/openpype/modules/deadline/abstract_submit_deadline.py b/openpype/modules/deadline/abstract_submit_deadline.py index 0efa89c087..551a2f7373 100644 --- a/openpype/modules/deadline/abstract_submit_deadline.py +++ b/openpype/modules/deadline/abstract_submit_deadline.py @@ -36,7 +36,7 @@ def requests_post(*args, **kwargs): Warning: Disabling SSL certificate validation is defeating one line - of defense SSL is providing and it is not recommended. + of defense SSL is providing, and it is not recommended. """ if 'verify' not in kwargs: @@ -57,7 +57,7 @@ def requests_get(*args, **kwargs): Warning: Disabling SSL certificate validation is defeating one line - of defense SSL is providing and it is not recommended. + of defense SSL is providing, and it is not recommended. """ if 'verify' not in kwargs: diff --git a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py index 9981bead3e..2de6073e29 100644 --- a/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py +++ b/openpype/modules/deadline/plugins/publish/collect_deadline_server_from_instance.py @@ -5,23 +5,26 @@ This is resolving index of server lists stored in `deadlineServers` instance attribute or using default server if that attribute doesn't exists. """ +from maya import cmds + import pyblish.api class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): """Collect Deadline Webservice URL from instance.""" - order = pyblish.api.CollectorOrder + 0.415 + # Run before collect_render. + order = pyblish.api.CollectorOrder + 0.005 label = "Deadline Webservice from the Instance" families = ["rendering", "renderlayer"] + hosts = ["maya"] def process(self, instance): instance.data["deadlineUrl"] = self._collect_deadline_url(instance) self.log.info( "Using {} for submission.".format(instance.data["deadlineUrl"])) - @staticmethod - def _collect_deadline_url(render_instance): + def _collect_deadline_url(self, render_instance): # type: (pyblish.api.Instance) -> str """Get Deadline Webservice URL from render instance. @@ -49,8 +52,16 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): default_server = render_instance.context.data["defaultDeadline"] instance_server = render_instance.data.get("deadlineServers") if not instance_server: + self.log.debug("Using default server.") return default_server + # Get instance server as sting. + if isinstance(instance_server, int): + instance_server = cmds.getAttr( + "{}.deadlineServers".format(render_instance.data["objset"]), + asString=True + ) + default_servers = deadline_settings["deadline_urls"] project_servers = ( render_instance.context.data @@ -58,15 +69,23 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): ["deadline"] ["deadline_servers"] ) - deadline_servers = { + if not project_servers: + self.log.debug("Not project servers found. Using default servers.") + return default_servers[instance_server] + + project_enabled_servers = { k: default_servers[k] for k in project_servers if k in default_servers } - # This is Maya specific and may not reflect real selection of deadline - # url as dictionary keys in Python 2 are not ordered - return deadline_servers[ - list(deadline_servers.keys())[ - int(render_instance.data.get("deadlineServers")) - ] - ] + + msg = ( + "\"{}\" server on instance is not enabled in project settings." + " Enabled project servers:\n{}".format( + instance_server, project_enabled_servers + ) + ) + assert instance_server in project_enabled_servers, msg + + self.log.debug("Using project approved server.") + return project_enabled_servers[instance_server] diff --git a/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py index cb2b0cf156..1a0d615dc3 100644 --- a/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py +++ b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py @@ -17,7 +17,8 @@ class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): `CollectDeadlineServerFromInstance`. """ - order = pyblish.api.CollectorOrder + 0.410 + # Run before collect_deadline_server_instance. + order = pyblish.api.CollectorOrder + 0.0025 label = "Default Deadline Webservice" pass_mongo_url = False diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index bcf0850768..ee28612b44 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -59,7 +59,6 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): render_path).replace("\\", "/") instance.data["publishJobState"] = "Suspended" - instance.context.data['ftrackStatus'] = "Render" # adding 2d render specific family for version identification in Loader instance.data["families"] = ["render2d"] diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py index 73ab689c9a..254914a850 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -1,19 +1,27 @@ +import hou + import os -import json +import attr import getpass from datetime import datetime - -import requests import pyblish.api -# import hou ??? - from openpype.pipeline import legacy_io from openpype.tests.lib import is_in_tests +from openpype_modules.deadline import abstract_submit_deadline +from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype.lib import is_running_from_build -class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): +@attr.s +class DeadlinePluginInfo(): + SceneFile = attr.ib(default=None) + OutputDriver = attr.ib(default=None) + Version = attr.ib(default=None) + IgnoreInputs = attr.ib(default=True) + + +class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): """Submit Solaris USD Render ROPs to Deadline. Renders are submitted to a Deadline Web Service as @@ -30,83 +38,57 @@ class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder hosts = ["houdini"] families = ["usdrender", - "redshift_rop"] + "redshift_rop", + "arnold_rop", + "mantra_rop", + "karma_rop", + "vray_rop"] targets = ["local"] + use_published = True - def process(self, instance): + def get_job_info(self): + job_info = DeadlineJobInfo(Plugin="Houdini") + instance = self._instance context = instance.context - code = context.data["code"] + filepath = context.data["currentFile"] filename = os.path.basename(filepath) - comment = context.data.get("comment", "") - deadline_user = context.data.get("deadlineUser", getpass.getuser()) - jobname = "%s - %s" % (filename, instance.name) - # Support code prefix label for batch name - batch_name = filename - if code: - batch_name = "{0} - {1}".format(code, batch_name) + job_info.Name = "{} - {}".format(filename, instance.name) + job_info.BatchName = filename + job_info.Plugin = "Houdini" + job_info.UserName = context.data.get( + "deadlineUser", getpass.getuser()) if is_in_tests(): - batch_name += datetime.now().strftime("%d%m%Y%H%M%S") + job_info.BatchName += datetime.now().strftime("%d%m%Y%H%M%S") - # Output driver to render - driver = instance[0] - - # StartFrame to EndFrame by byFrameStep + # Deadline requires integers in frame range frames = "{start}-{end}x{step}".format( start=int(instance.data["frameStart"]), end=int(instance.data["frameEnd"]), step=int(instance.data["byFrameStep"]), ) + job_info.Frames = frames - # Documentation for keys available at: - # https://docs.thinkboxsoftware.com - # /products/deadline/8.0/1_User%20Manual/manual - # /manual-submission.html#job-info-file-options - payload = { - "JobInfo": { - # Top-level group name - "BatchName": batch_name, + job_info.Pool = instance.data.get("primaryPool") + job_info.SecondaryPool = instance.data.get("secondaryPool") + job_info.ChunkSize = instance.data.get("chunkSize", 10) + job_info.Comment = context.data.get("comment") - # Job name, as seen in Monitor - "Name": jobname, - - # Arbitrary username, for visualisation in Monitor - "UserName": deadline_user, - - "Plugin": "Houdini", - "Pool": instance.data.get("primaryPool"), - "secondaryPool": instance.data.get("secondaryPool"), - "Frames": frames, - - "ChunkSize": instance.data.get("chunkSize", 10), - - "Comment": comment - }, - "PluginInfo": { - # Input - "SceneFile": filepath, - "OutputDriver": driver.path(), - - # Mandatory for Deadline - # Houdini version without patch number - "Version": hou.applicationVersionString().rsplit(".", 1)[0], - - "IgnoreInputs": True - }, - - # Mandatory for Deadline, may be empty - "AuxFiles": [] - } - - # Include critical environment variables with submission + api.Session keys = [ - # Submit along the current Avalon tool setup that we launched - # this application with so the Render Slave can build its own - # similar environment using it, e.g. "maya2018;vray4.x;yeti3.1.9" - "AVALON_TOOLS" + "FTRACK_API_KEY", + "FTRACK_API_USER", + "FTRACK_SERVER", + "OPENPYPE_SG_USER", + "AVALON_PROJECT", + "AVALON_ASSET", + "AVALON_TASK", + "AVALON_APP_NAME", + "OPENPYPE_DEV", + "OPENPYPE_LOG_NO_COLORS", + "OPENPYPE_VERSION" ] # Add OpenPype version if we are running from build. @@ -114,61 +96,50 @@ class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): keys.append("OPENPYPE_VERSION") # Add mongo url if it's enabled - if context.data.get("deadlinePassMongoUrl"): + if self._instance.context.data.get("deadlinePassMongoUrl"): keys.append("OPENPYPE_MONGO") environment = dict({key: os.environ[key] for key in keys if key in os.environ}, **legacy_io.Session) + for key in keys: + value = environment.get(key) + if value: + job_info.EnvironmentKeyValue[key] = value - payload["JobInfo"].update({ - "EnvironmentKeyValue%d" % index: "{key}={value}".format( - key=key, - value=environment[key] - ) for index, key in enumerate(environment) - }) + # to recognize job from PYPE for turning Event On/Off + job_info.EnvironmentKeyValue["OPENPYPE_RENDER_JOB"] = "1" - # Include OutputFilename entries - # The first entry also enables double-click to preview rendered - # frames from Deadline Monitor - output_data = {} for i, filepath in enumerate(instance.data["files"]): dirname = os.path.dirname(filepath) fname = os.path.basename(filepath) - output_data["OutputDirectory%d" % i] = dirname.replace("\\", "/") - output_data["OutputFilename%d" % i] = fname + job_info.OutputDirectory += dirname.replace("\\", "/") + job_info.OutputFilename += fname - # For now ensure destination folder exists otherwise HUSK - # will fail to render the output image. This is supposedly fixed - # in new production builds of Houdini - # TODO Remove this workaround with Houdini 18.0.391+ - if not os.path.exists(dirname): - self.log.info("Ensuring output directory exists: %s" % - dirname) - os.makedirs(dirname) + return job_info - payload["JobInfo"].update(output_data) + def get_plugin_info(self): - self.submit(instance, payload) + instance = self._instance + context = instance.context - def submit(self, instance, payload): + # Output driver to render + driver = hou.node(instance.data["instance_node"]) + hou_major_minor = hou.applicationVersionString().rsplit(".", 1)[0] - AVALON_DEADLINE = legacy_io.Session.get("AVALON_DEADLINE", - "http://localhost:8082") - assert AVALON_DEADLINE, "Requires AVALON_DEADLINE" + plugin_info = DeadlinePluginInfo( + SceneFile=context.data["currentFile"], + OutputDriver=driver.path(), + Version=hou_major_minor, + IgnoreInputs=True + ) - plugin = payload["JobInfo"]["Plugin"] - self.log.info("Using Render Plugin : {}".format(plugin)) + return attr.asdict(plugin_info) - self.log.info("Submitting..") - self.log.debug(json.dumps(payload, indent=4, sort_keys=True)) - - # E.g. http://192.168.0.1:8082/api/jobs - url = "{}/api/jobs".format(AVALON_DEADLINE) - response = requests.post(url, json=payload) - if not response.ok: - raise Exception(response.text) + def process(self, instance): + super(HoudiniSubmitDeadline, self).process(instance) + # TODO: Avoid the need for this logic here, needed for submit publish # Store output dir for unified publisher (filesequence) output_dir = os.path.dirname(instance.data["files"][0]) instance.data["outputDir"] = output_dir - instance.data["deadlineSubmissionJob"] = response.json() + instance.data["toBeRenderedOn"] = "deadline" diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index f9cfaa9266..7b671a9a65 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -23,6 +23,7 @@ from openpype.lib import EnumDef from openpype.tests.lib import is_in_tests from openpype.pipeline.farm.patterning import match_aov_pattern from openpype.lib import is_running_from_build +from openpype.pipeline import publish def get_resources(project_name, version, extension=None): @@ -121,11 +122,15 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, deadline_plugin = "OpenPype" targets = ["local"] - hosts = ["fusion", "max", "maya", "nuke", + hosts = ["fusion", "max", "maya", "nuke", "houdini", "celaction", "aftereffects", "harmony"] families = ["render.farm", "prerender.farm", - "renderlayer", "imagesequence", "maxrender", "vrayscene"] + "renderlayer", "imagesequence", + "vrayscene", "maxrender", + "arnold_rop", "mantra_rop", + "karma_rop", "vray_rop", + "redshift_rop"] aov_filter = {"maya": [r".*([Bb]eauty).*"], "aftereffects": [r".*"], # for everything from AE @@ -143,7 +148,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "FTRACK_SERVER", "AVALON_APP_NAME", "OPENPYPE_USERNAME", - "OPENPYPE_SG_USER", + "OPENPYPE_VERSION", + "OPENPYPE_SG_USER" ] # Add OpenPype version if we are running from build. @@ -600,7 +606,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, self.log.debug("instances:{}".format(instances)) return instances - def _get_representations(self, instance, exp_files, do_not_add_review): + def _get_representations(self, instance_data, exp_files, + do_not_add_review): """Create representations for file sequences. This will return representations of expected files if they are not @@ -608,7 +615,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, most cases, but if not - we create representation from each of them. Arguments: - instance (dict): instance data for which we are + instance_data (dict): instance.data for which we are setting representations exp_files (list): list of expected files do_not_add_review (bool): explicitly skip review @@ -630,9 +637,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, # expected files contains more explicitly and from what # should be review made. # - "review" tag is never added when is set to 'False' - if instance["useSequenceForReview"]: + if instance_data["useSequenceForReview"]: # toggle preview on if multipart is on - if instance.get("multipartExr", False): + if instance_data.get("multipartExr", False): self.log.debug( "Adding preview tag because its multipartExr" ) @@ -657,8 +664,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, " This may cause issues on farm." ).format(staging)) - frame_start = int(instance.get("frameStartHandle")) - if instance.get("slate"): + frame_start = int(instance_data.get("frameStartHandle")) + if instance_data.get("slate"): frame_start -= 1 preview = preview and not do_not_add_review @@ -667,10 +674,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "ext": ext, "files": [os.path.basename(f) for f in list(collection)], "frameStart": frame_start, - "frameEnd": int(instance.get("frameEndHandle")), + "frameEnd": int(instance_data.get("frameEndHandle")), # If expectedFile are absolute, we need only filenames "stagingDir": staging, - "fps": instance.get("fps"), + "fps": instance_data.get("fps"), "tags": ["review"] if preview else [], } @@ -678,17 +685,17 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, if ext in self.skip_integration_repre_list: rep["tags"].append("delete") - if instance.get("multipartExr", False): + if instance_data.get("multipartExr", False): rep["tags"].append("multipartExr") # support conversion from tiled to scanline - if instance.get("convertToScanline"): + if instance_data.get("convertToScanline"): self.log.info("Adding scanline conversion.") rep["tags"].append("toScanline") representations.append(rep) - self._solve_families(instance, preview) + self._solve_families(instance_data, preview) # add remainders as representations for remainder in remainders: @@ -719,13 +726,13 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, preview = preview and not do_not_add_review if preview: rep.update({ - "fps": instance.get("fps"), + "fps": instance_data.get("fps"), "tags": ["review"] }) - self._solve_families(instance, preview) + self._solve_families(instance_data, preview) already_there = False - for repre in instance.get("representations", []): + for repre in instance_data.get("representations", []): # might be added explicitly before by publish_on_farm already_there = repre.get("files") == rep["files"] if already_there: @@ -735,6 +742,13 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, if not already_there: representations.append(rep) + for rep in representations: + # inject colorspace data + self.set_representation_colorspace( + rep, self.context, + colorspace=instance_data["colorspace"] + ) + return representations def _solve_families(self, instance, preview=False): @@ -827,7 +841,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, ).format(source)) family = "render" - if "prerender" in instance.data["families"]: + if ("prerender" in instance.data["families"] or + "prerender.farm" in instance.data["families"]): family = "prerender" families = [family] @@ -862,7 +877,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, "jobBatchName": data.get("jobBatchName", ""), "useSequenceForReview": data.get("useSequenceForReview", True), # map inputVersions `ObjectId` -> `str` so json supports it - "inputVersions": list(map(str, data.get("inputVersions", []))) + "inputVersions": list(map(str, data.get("inputVersions", []))), + "colorspace": instance.data.get("colorspace") } # skip locking version if we are creating v01 @@ -1096,6 +1112,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, deadline_publish_job_id = \ self._submit_deadline_post_job(instance, render_job, instances) + # Inject deadline url to instances. + for inst in instances: + inst["deadlineUrl"] = self.deadline_url + # publish job file publish_job = { "asset": asset, diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 8b4c4619a1..7c3ba1a30c 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -890,7 +890,7 @@ class SyncEntitiesFactory: else: parent_dict = self.entities_dict.get(parent_id, {}) - for child_id in parent_dict.get("children", []): + for child_id in list(parent_dict.get("children", [])): # keep original `remove` value for all children _remove = (remove is True) if not _remove: diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index cec48ef54f..deb8b414f0 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -109,8 +109,6 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): for status in asset_version_statuses } - self._set_task_status(instance, project_entity, task_entity, session) - # Prepare AssetTypes asset_types_by_short = self._ensure_asset_types_exists( session, component_list @@ -180,45 +178,6 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): if asset_version not in instance.data[asset_versions_key]: instance.data[asset_versions_key].append(asset_version) - def _set_task_status(self, instance, project_entity, task_entity, session): - if not project_entity: - self.log.info("Task status won't be set, project is not known.") - return - - if not task_entity: - self.log.info("Task status won't be set, task is not known.") - return - - status_name = instance.context.data.get("ftrackStatus") - if not status_name: - self.log.info("Ftrack status name is not set.") - return - - self.log.debug( - "Ftrack status name will be (maybe) set to \"{}\"".format( - status_name - ) - ) - - project_schema = project_entity["project_schema"] - task_statuses = project_schema.get_statuses( - "Task", task_entity["type_id"] - ) - task_statuses_by_low_name = { - status["name"].lower(): status for status in task_statuses - } - status = task_statuses_by_low_name.get(status_name.lower()) - if not status: - self.log.warning(( - "Task status \"{}\" won't be set," - " status is now allowed on task type \"{}\"." - ).format(status_name, task_entity["type"]["name"])) - return - - self.log.info("Setting task status to \"{}\"".format(status_name)) - task_entity["status"] = status - session.commit() - def _fill_component_locations(self, session, component_list): components_by_location_name = collections.defaultdict(list) components_by_location_id = collections.defaultdict(list) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_farm_status.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_farm_status.py deleted file mode 100644 index ab5738c33f..0000000000 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_farm_status.py +++ /dev/null @@ -1,150 +0,0 @@ -import pyblish.api -from openpype.lib import filter_profiles - - -class IntegrateFtrackFarmStatus(pyblish.api.ContextPlugin): - """Change task status when should be published on farm. - - Instance which has set "farm" key in data to 'True' is considered as will - be rendered on farm thus it's status should be changed. - """ - - order = pyblish.api.IntegratorOrder + 0.48 - label = "Integrate Ftrack Farm Status" - - farm_status_profiles = [] - - def process(self, context): - # Quick end - if not self.farm_status_profiles: - project_name = context.data["projectName"] - self.log.info(( - "Status profiles are not filled for project \"{}\". Skipping" - ).format(project_name)) - return - - filtered_instances = self.filter_instances(context) - instances_with_status_names = self.get_instances_with_statuse_names( - context, filtered_instances - ) - if instances_with_status_names: - self.fill_statuses(context, instances_with_status_names) - - def filter_instances(self, context): - filtered_instances = [] - for instance in context: - # Skip disabled instances - if instance.data.get("publish") is False: - continue - subset_name = instance.data["subset"] - msg_start = "Skipping instance {}.".format(subset_name) - if not instance.data.get("farm"): - self.log.debug( - "{} Won't be rendered on farm.".format(msg_start) - ) - continue - - task_entity = instance.data.get("ftrackTask") - if not task_entity: - self.log.debug( - "{} Does not have filled task".format(msg_start) - ) - continue - - filtered_instances.append(instance) - return filtered_instances - - def get_instances_with_statuse_names(self, context, instances): - instances_with_status_names = [] - for instance in instances: - family = instance.data["family"] - subset_name = instance.data["subset"] - task_entity = instance.data["ftrackTask"] - host_name = context.data["hostName"] - task_name = task_entity["name"] - task_type = task_entity["type"]["name"] - status_profile = filter_profiles( - self.farm_status_profiles, - { - "hosts": host_name, - "task_types": task_type, - "task_names": task_name, - "families": family, - "subsets": subset_name, - }, - logger=self.log - ) - if not status_profile: - # There already is log in 'filter_profiles' - continue - - status_name = status_profile["status_name"] - if status_name: - instances_with_status_names.append((instance, status_name)) - return instances_with_status_names - - def fill_statuses(self, context, instances_with_status_names): - # Prepare available task statuses on the project - project_name = context.data["projectName"] - session = context.data["ftrackSession"] - project_entity = session.query(( - "select project_schema from Project where full_name is \"{}\"" - ).format(project_name)).one() - project_schema = project_entity["project_schema"] - - task_type_ids = set() - for item in instances_with_status_names: - instance, _ = item - task_entity = instance.data["ftrackTask"] - task_type_ids.add(task_entity["type"]["id"]) - - task_statuses_by_type_id = { - task_type_id: project_schema.get_statuses("Task", task_type_id) - for task_type_id in task_type_ids - } - - # Keep track if anything has changed - skipped_status_names = set() - status_changed = False - for item in instances_with_status_names: - instance, status_name = item - task_entity = instance.data["ftrackTask"] - task_statuses = task_statuses_by_type_id[task_entity["type"]["id"]] - status_name_low = status_name.lower() - - status_id = None - status_name = None - # Skip if status name was already tried to be found - for status in task_statuses: - if status["name"].lower() == status_name_low: - status_id = status["id"] - status_name = status["name"] - break - - if status_id is None: - if status_name_low not in skipped_status_names: - skipped_status_names.add(status_name_low) - joined_status_names = ", ".join({ - '"{}"'.format(status["name"]) - for status in task_statuses - }) - self.log.warning(( - "Status \"{}\" is not available on project \"{}\"." - " Available statuses are {}" - ).format(status_name, project_name, joined_status_names)) - continue - - # Change task status id - if status_id != task_entity["status_id"]: - task_entity["status_id"] = status_id - status_changed = True - path = "/".join([ - item["name"] - for item in task_entity["link"] - ]) - self.log.debug("Set status \"{}\" to \"{}\"".format( - status_name, path - )) - - if status_changed: - session.commit() diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_status.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_status.py new file mode 100644 index 0000000000..e862dba7fc --- /dev/null +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_status.py @@ -0,0 +1,433 @@ +import copy + +import pyblish.api +from openpype.lib import filter_profiles + + +def create_chunks(iterable, chunk_size=None): + """Separate iterable into multiple chunks by size. + + Args: + iterable(list|tuple|set): Object that will be separated into chunks. + chunk_size(int): Size of one chunk. Default value is 200. + + Returns: + list: Chunked items. + """ + chunks = [] + + tupled_iterable = tuple(iterable) + if not tupled_iterable: + return chunks + iterable_size = len(tupled_iterable) + if chunk_size is None: + chunk_size = 200 + + if chunk_size < 1: + chunk_size = 1 + + for idx in range(0, iterable_size, chunk_size): + chunks.append(tupled_iterable[idx:idx + chunk_size]) + return chunks + + +class CollectFtrackTaskStatuses(pyblish.api.ContextPlugin): + """Collect available task statuses on the project. + + This is preparation for integration of task statuses. + + Requirements: + ftrackSession (ftrack_api.Session): Prepared ftrack session. + + Provides: + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + # After 'CollectFtrackApi' + order = pyblish.api.CollectorOrder + 0.4992 + label = "Collect Ftrack Task Statuses" + settings_category = "ftrack" + + def process(self, context): + ftrack_session = context.data("ftrackSession") + if ftrack_session is None: + self.log.info("Ftrack session is not created.") + return + + # Prepare available task statuses on the project + project_name = context.data["projectName"] + project_entity = ftrack_session.query(( + "select project_schema from Project where full_name is \"{}\"" + ).format(project_name)).one() + project_schema = project_entity["project_schema"] + + task_type_ids = { + task_type["id"] + for task_type in ftrack_session.query("select id from Type").all() + } + task_statuses_by_type_id = { + task_type_id: project_schema.get_statuses("Task", task_type_id) + for task_type_id in task_type_ids + } + context.data["ftrackTaskStatuses"] = task_statuses_by_type_id + context.data["ftrackStatusByTaskId"] = {} + self.log.info("Collected ftrack task statuses.") + + +class IntegrateFtrackStatusBase(pyblish.api.InstancePlugin): + """Base plugin for status collection. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + active = False + settings_key = None + status_profiles = [] + + @classmethod + def apply_settings(cls, project_settings): + settings_key = cls.settings_key + if settings_key is None: + settings_key = cls.__name__ + + try: + settings = project_settings["ftrack"]["publish"][settings_key] + except KeyError: + return + + for key, value in settings.items(): + setattr(cls, key, value) + + def process(self, instance): + context = instance.context + # No profiles -> skip + profiles = self.get_status_profiles() + if not profiles: + project_name = context.data["projectName"] + self.log.info(( + "Status profiles are not filled for project \"{}\". Skipping" + ).format(project_name)) + return + + # Task statuses were not collected -> skip + task_statuses_by_type_id = context.data.get("ftrackTaskStatuses") + if not task_statuses_by_type_id: + self.log.info( + "Ftrack task statuses are not collected. Skipping.") + return + + self.prepare_status_names(context, instance, profiles) + + def get_status_profiles(self): + """List of profiles to determine status name. + + Example profile item: + { + "host_names": ["nuke"], + "task_types": ["Compositing"], + "task_names": ["Comp"], + "families": ["render"], + "subset_names": ["renderComp"], + "status_name": "Rendering", + } + + Returns: + list[dict[str, Any]]: List of profiles. + """ + + return self.status_profiles + + def prepare_status_names(self, context, instance, profiles): + if not self.is_valid_instance(context, instance): + return + + filter_data = self.get_profile_filter_data(context, instance) + status_profile = filter_profiles( + profiles, + filter_data, + logger=self.log + ) + if not status_profile: + return + + status_name = status_profile["status_name"] + if status_name: + self.fill_status(context, instance, status_name) + + def get_profile_filter_data(self, context, instance): + task_entity = instance.data["ftrackTask"] + return { + "host_names": context.data["hostName"], + "task_types": task_entity["type"]["name"], + "task_names": task_entity["name"], + "families": instance.data["family"], + "subset_names": instance.data["subset"], + } + + def is_valid_instance(self, context, instance): + """Filter instances that should be processed. + + Ignore instances that are not enabled for publishing or don't have + filled task. Also skip instances with tasks that already have defined + status. + + Plugin should do more filtering which is custom for plugin logic. + + Args: + context (pyblish.api.Context): Pyblish context. + instance (pyblish.api.Instance): Instance to process. + + Returns: + list[pyblish.api.Instance]: List of instances that should be + processed. + """ + + ftrack_status_by_task_id = context.data["ftrackStatusByTaskId"] + # Skip disabled instances + if instance.data.get("publish") is False: + return False + + task_entity = instance.data.get("ftrackTask") + if not task_entity: + self.log.debug( + "Skipping instance Does not have filled task".format( + instance.data["subset"])) + return False + + task_id = task_entity["id"] + if task_id in ftrack_status_by_task_id: + self.log.debug("Status for task {} was already defined".format( + task_entity["name"] + )) + return False + + return True + + def fill_status(self, context, instance, status_name): + """Fill status for instance task. + + If task already had set status, it will be skipped. + + Args: + context (pyblish.api.Context): Pyblish context. + instance (pyblish.api.Instance): Pyblish instance. + status_name (str): Name of status to set. + """ + + task_entity = instance.data["ftrackTask"] + task_id = task_entity["id"] + ftrack_status_by_task_id = context.data["ftrackStatusByTaskId"] + if task_id in ftrack_status_by_task_id: + self.log.debug("Status for task {} was already defined".format( + task_entity["name"] + )) + return + + ftrack_status_by_task_id[task_id] = status_name + self.log.info(( + "Task {} will be set to \"{}\" status." + ).format(task_entity["name"], status_name)) + + +class IntegrateFtrackFarmStatus(IntegrateFtrackStatusBase): + """Collect task status names for instances that are sent to farm. + + Instance which has set "farm" key in data to 'True' is considered as will + be rendered on farm thus it's status should be changed. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + order = pyblish.api.IntegratorOrder + 0.48 + label = "Ftrack Task Status To Farm Status" + active = True + + farm_status_profiles = [] + status_profiles = None + + def is_valid_instance(self, context, instance): + if not instance.data.get("farm"): + self.log.debug("{} Won't be rendered on farm.".format( + instance.data["subset"] + )) + return False + return super(IntegrateFtrackFarmStatus, self).is_valid_instance( + context, instance) + + def get_status_profiles(self): + if self.status_profiles is None: + profiles = copy.deepcopy(self.farm_status_profiles) + for profile in profiles: + profile["host_names"] = profile.pop("hosts") + profile["subset_names"] = profile.pop("subsets") + self.status_profiles = profiles + return self.status_profiles + + +class IntegrateFtrackLocalStatus(IntegrateFtrackStatusBase): + """Collect task status names for instances that are published locally. + + Instance which has set "farm" key in data to 'True' is considered as will + be rendered on farm thus it's status should be changed. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + order = IntegrateFtrackFarmStatus.order + 0.001 + label = "Ftrack Task Status Local Publish" + active = True + targets = ["local"] + settings_key = "ftrack_task_status_local_publish" + + def is_valid_instance(self, context, instance): + if instance.data.get("farm"): + self.log.debug("{} Will be rendered on farm.".format( + instance.data["subset"] + )) + return False + return super(IntegrateFtrackLocalStatus, self).is_valid_instance( + context, instance) + + +class IntegrateFtrackOnFarmStatus(IntegrateFtrackStatusBase): + """Collect task status names for instances that are published on farm. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + order = IntegrateFtrackLocalStatus.order + 0.001 + label = "Ftrack Task Status On Farm Status" + active = True + targets = ["farm"] + settings_key = "ftrack_task_status_on_farm_publish" + + +class IntegrateFtrackTaskStatus(pyblish.api.ContextPlugin): + # Use order of Integrate Ftrack Api plugin and offset it before or after + base_order = pyblish.api.IntegratorOrder + 0.499 + # By default is after Integrate Ftrack Api + order = base_order + 0.0001 + label = "Integrate Ftrack Task Status" + + @classmethod + def apply_settings(cls, project_settings): + """Apply project settings to plugin. + + Args: + project_settings (dict[str, Any]): Project settings. + """ + + settings = ( + project_settings["ftrack"]["publish"]["IntegrateFtrackTaskStatus"] + ) + diff = 0.001 + if not settings["after_version_statuses"]: + diff = -diff + cls.order = cls.base_order + diff + + def process(self, context): + task_statuses_by_type_id = context.data.get("ftrackTaskStatuses") + if not task_statuses_by_type_id: + self.log.info("Ftrack task statuses are not collected. Skipping.") + return + + status_by_task_id = self._get_status_by_task_id(context) + if not status_by_task_id: + self.log.info("No statuses to set. Skipping.") + return + + ftrack_session = context.data["ftrackSession"] + + task_entities = self._get_task_entities( + ftrack_session, status_by_task_id) + + for task_entity in task_entities: + task_path = "/".join([ + item["name"] for item in task_entity["link"] + ]) + task_id = task_entity["id"] + type_id = task_entity["type_id"] + new_status = None + status_name = status_by_task_id[task_id] + self.log.debug( + "Status to set {} on task {}.".format(status_name, task_path)) + status_name_low = status_name.lower() + available_statuses = task_statuses_by_type_id[type_id] + for status in available_statuses: + if status["name"].lower() == status_name_low: + new_status = status + break + + if new_status is None: + joined_statuses = ", ".join([ + "'{}'".format(status["name"]) + for status in available_statuses + ]) + self.log.debug(( + "Status '{}' was not found in available statuses: {}." + ).format(status_name, joined_statuses)) + continue + + if task_entity["status_id"] != new_status["id"]: + task_entity["status_id"] = new_status["id"] + + self.log.debug("Changing status of task '{}' to '{}'".format( + task_path, status_name + )) + ftrack_session.commit() + + def _get_status_by_task_id(self, context): + status_by_task_id = context.data["ftrackStatusByTaskId"] + return { + task_id: status_name + for task_id, status_name in status_by_task_id.items() + if status_name + } + + def _get_task_entities(self, ftrack_session, status_by_task_id): + task_entities = [] + for chunk_ids in create_chunks(status_by_task_id.keys()): + joined_ids = ",".join( + ['"{}"'.format(task_id) for task_id in chunk_ids] + ) + task_entities.extend(ftrack_session.query(( + "select id, type_id, status_id, link from Task" + " where id in ({})" + ).format(joined_ids)).all()) + return task_entities diff --git a/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py b/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py index 6daaea5f18..a1aa7c0daa 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py @@ -63,7 +63,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): """ order = pyblish.api.IntegratorOrder - 0.04 - label = 'Integrate Hierarchy To Ftrack' + label = "Integrate Hierarchy To Ftrack" families = ["shot"] hosts = [ "hiero", @@ -94,14 +94,13 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): "Project \"{}\" was not found on ftrack.".format(project_name) ) - self.context = context self.session = session self.ft_project = project self.task_types = self.get_all_task_types(project) self.task_statuses = self.get_task_statuses(project) # import ftrack hierarchy - self.import_to_ftrack(project_name, hierarchy_context) + self.import_to_ftrack(context, project_name, hierarchy_context) def query_ftrack_entitites(self, session, ft_project): project_id = ft_project["id"] @@ -227,7 +226,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): return output - def import_to_ftrack(self, project_name, hierarchy_context): + def import_to_ftrack(self, context, project_name, hierarchy_context): # Prequery hiearchical custom attributes hier_attrs = get_pype_attr(self.session)[1] hier_attr_by_key = { @@ -258,7 +257,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): self.session, matching_entities, hier_attrs) # Get ftrack api module (as they are different per python version) - ftrack_api = self.context.data["ftrackPythonModule"] + ftrack_api = context.data["ftrackPythonModule"] # Use queue of hierarchy items to process import_queue = collections.deque() @@ -292,7 +291,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): # CUSTOM ATTRIBUTES custom_attributes = entity_data.get('custom_attributes', {}) instances = [] - for instance in self.context: + for instance in context: instance_asset_name = instance.data.get("asset") if ( instance_asset_name @@ -369,6 +368,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): if task_name: instances_by_task_name[task_name.lower()].append(instance) + ftrack_status_by_task_id = context.data["ftrackStatusByTaskId"] tasks = entity_data.get('tasks', []) existing_tasks = [] tasks_to_create = [] @@ -389,11 +389,11 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): for task_name, task_type in tasks_to_create: task_entity = self.create_task( - name=task_name, - task_type=task_type, - parent=entity + task_name, + task_type, + entity, + ftrack_status_by_task_id ) - for instance in instances_by_task_name[task_name.lower()]: instance.data["ftrackTask"] = task_entity @@ -481,7 +481,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): for status in task_workflow_statuses } - def create_task(self, name, task_type, parent): + def create_task(self, name, task_type, parent, ftrack_status_by_task_id): filter_data = { "task_names": name, "task_types": task_type @@ -491,12 +491,14 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): filter_data ) status_id = None + status_name = None if profile: status_name = profile["status_name"] status_name_low = status_name.lower() for _status_id, status in self.task_statuses.items(): if status["name"].lower() == status_name_low: status_id = _status_id + status_name = status["name"] break if status_id is None: @@ -523,6 +525,8 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): self.session._configure_locations() six.reraise(tp, value, tb) + if status_id is not None: + ftrack_status_by_task_id[task["id"]] = None return task def _get_active_assets(self, context): diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 8c9a6ee1dd..0d73bc35a3 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -33,8 +33,8 @@ class OpenPypeInterface: class IPluginPaths(OpenPypeInterface): """Module has plugin paths to return. - Expected result is dictionary with keys "publish", "create", "load" or - "actions" and values as list or string. + Expected result is dictionary with keys "publish", "create", "load", + "actions" or "inventory" and values as list or string. { "publish": ["path/to/publish_plugins"] } @@ -109,6 +109,21 @@ class IPluginPaths(OpenPypeInterface): return self._get_plugin_paths_by_type("publish") + def get_inventory_action_paths(self, host_name): + """Receive inventory action paths. + + Give addons ability to add inventory action plugin paths. + + Notes: + Default implementation uses 'get_plugin_paths' and always return + all publish plugin paths. + + Args: + host_name (str): For which host are the plugins meant. + """ + + return self._get_plugin_paths_by_type("inventory") + class ILaunchHookPaths(OpenPypeInterface): """Module has launch hook paths to return. @@ -395,13 +410,11 @@ class ITrayService(ITrayModule): class ISettingsChangeListener(OpenPypeInterface): - """Module has plugin paths to return. + """Module tries to listen to settings changes. + + Only settings changes in the current process are propagated. + Changes made in other processes or machines won't trigger the callbacks. - Expected result is dictionary with keys "publish", "create", "load" or - "actions" and values as list or string. - { - "publish": ["path/to/publish_plugins"] - } """ @abstractmethod diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index b21008af9f..269825f85f 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -18,6 +18,10 @@ from openpype.pipeline import Anatomy log = Logger.get_logger(__name__) +class CashedData: + remapping = None + + @contextlib.contextmanager def _make_temp_json_file(): """Wrapping function for json temp file @@ -92,6 +96,11 @@ def get_imageio_colorspace_from_filepath( ) config_data = get_imageio_config( project_name, host_name, project_settings) + + # in case host color management is not enabled + if not config_data: + return None + file_rules = get_imageio_file_rules( project_name, host_name, project_settings) @@ -303,7 +312,8 @@ def get_views_data_subprocess(config_path): def get_imageio_config( - project_name, host_name, + project_name, + host_name, project_settings=None, anatomy_data=None, anatomy=None @@ -316,15 +326,12 @@ def get_imageio_config( Args: project_name (str): project name host_name (str): host name - project_settings (dict, optional): project settings. - Defaults to None. - anatomy_data (dict, optional): anatomy formatting data. - Defaults to None. - anatomy (lib.Anatomy, optional): Anatomy object. - Defaults to None. + project_settings (Optional[dict]): Project settings. + anatomy_data (Optional[dict]): anatomy formatting data. + anatomy (Optional[Anatomy]): Anatomy object. Returns: - dict or bool: config path data or None + dict: config path data or empty dict """ project_settings = project_settings or get_project_settings(project_name) anatomy = anatomy or Anatomy(project_name) @@ -335,25 +342,69 @@ def get_imageio_config( anatomy_data = get_template_data_from_session() formatting_data = deepcopy(anatomy_data) - # add project roots to anatomy data + + # Add project roots to anatomy data formatting_data["root"] = anatomy.roots formatting_data["platform"] = platform.system().lower() - # get colorspace settings + # Get colorspace settings imageio_global, imageio_host = _get_imageio_settings( project_settings, host_name) - config_host = imageio_host.get("ocio_config", {}) + # Host 'ocio_config' is optional + host_ocio_config = imageio_host.get("ocio_config") or {} - if config_host.get("enabled"): + # Global color management must be enabled to be able to use host settings + activate_color_management = imageio_global.get( + "activate_global_color_management") + # TODO: remove this in future - backward compatibility + # For already saved overrides from previous version look for 'enabled' + # on host settings. + if activate_color_management is None: + activate_color_management = host_ocio_config.get("enabled", False) + + if not activate_color_management: + # if global settings are disabled return empty dict because + # it is expected that no colorspace management is needed + log.info("Colorspace management is disabled globally.") + return {} + + # Check if host settings group is having 'activate_host_color_management' + # - if it does not have activation key then default it to True so it uses + # global settings + # This is for backward compatibility. + # TODO: in future rewrite this to be more explicit + activate_host_color_management = imageio_host.get( + "activate_host_color_management") + + # TODO: remove this in future - backward compatibility + if activate_host_color_management is None: + activate_host_color_management = host_ocio_config.get("enabled", False) + + if not activate_host_color_management: + # if host settings are disabled return False because + # it is expected that no colorspace management is needed + log.info( + "Colorspace management for host '{}' is disabled.".format( + host_name) + ) + return {} + + # get config path from either global or host settings + # depending on override flag + # TODO: in future rewrite this to be more explicit + override_global_config = host_ocio_config.get("override_global_config") + if override_global_config is None: + # for already saved overrides from previous version + # TODO: remove this in future - backward compatibility + override_global_config = host_ocio_config.get("enabled") + + if override_global_config: config_data = _get_config_data( - config_host["filepath"], formatting_data + host_ocio_config["filepath"], formatting_data ) else: - config_data = None - - if not config_data: - # get config path from either global or host_name + # get config path from global config_global = imageio_global["ocio_config"] config_data = _get_config_data( config_global["filepath"], formatting_data @@ -437,17 +488,82 @@ def get_imageio_file_rules(project_name, host_name, project_settings=None): # get file rules from global and host_name frules_global = imageio_global["file_rules"] + activate_global_rules = ( + frules_global.get("activate_global_file_rules", False) + # TODO: remove this in future - backward compatibility + or frules_global.get("enabled") + ) + global_rules = frules_global["rules"] + + if not activate_global_rules: + log.info( + "Colorspace global file rules are disabled." + ) + global_rules = {} + # host is optional, some might not have any settings frules_host = imageio_host.get("file_rules", {}) # compile file rules dictionary - file_rules = {} - if frules_global["enabled"]: - file_rules.update(frules_global["rules"]) - if frules_host and frules_host["enabled"]: - file_rules.update(frules_host["rules"]) + activate_host_rules = frules_host.get("activate_host_rules") + if activate_host_rules is None: + # TODO: remove this in future - backward compatibility + activate_host_rules = frules_host.get("enabled", False) - return file_rules + # return host rules if activated or global rules + return frules_host["rules"] if activate_host_rules else global_rules + + +def get_remapped_colorspace_to_native( + ocio_colorspace_name, host_name, imageio_host_settings +): + """Return native colorspace name. + + Args: + ocio_colorspace_name (str | None): ocio colorspace name + host_name (str): Host name. + imageio_host_settings (dict[str, Any]): ImageIO host settings. + + Returns: + Union[str, None]: native colorspace name defined in remapping or None + """ + + CashedData.remapping.setdefault(host_name, {}) + if CashedData.remapping[host_name].get("to_native") is None: + remapping_rules = imageio_host_settings["remapping"]["rules"] + CashedData.remapping[host_name]["to_native"] = { + rule["ocio_name"]: rule["host_native_name"] + for rule in remapping_rules + } + + return CashedData.remapping[host_name]["to_native"].get( + ocio_colorspace_name) + + +def get_remapped_colorspace_from_native( + host_native_colorspace_name, host_name, imageio_host_settings +): + """Return ocio colorspace name remapped from host native used name. + + Args: + host_native_colorspace_name (str): host native colorspace name + host_name (str): Host name. + imageio_host_settings (dict[str, Any]): ImageIO host settings. + + Returns: + Union[str, None]: Ocio colorspace name defined in remapping or None. + """ + + CashedData.remapping.setdefault(host_name, {}) + if CashedData.remapping[host_name].get("from_native") is None: + remapping_rules = imageio_host_settings["remapping"]["rules"] + CashedData.remapping[host_name]["from_native"] = { + rule["host_native_name"]: rule["ocio_name"] + for rule in remapping_rules + } + + return CashedData.remapping[host_name]["from_native"].get( + host_native_colorspace_name) def _get_imageio_settings(project_settings, host_name): diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index ada78b989d..97a5c1ba69 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -181,6 +181,11 @@ def install_openpype_plugins(project_name=None, host_name=None): for path in load_plugin_paths: register_loader_plugin_path(path) + inventory_action_paths = modules_manager.collect_inventory_action_paths( + host_name) + for path in inventory_action_paths: + register_inventory_action_path(path) + if project_name is None: project_name = os.environ.get("AVALON_PROJECT") diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 2fc0669732..332e271b0d 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -1441,6 +1441,19 @@ class CreateContext: """Access to global publish attributes.""" return self._publish_attributes + def get_instance_by_id(self, instance_id): + """Receive instance by id. + + Args: + instance_id (str): Instance id. + + Returns: + Union[CreatedInstance, None]: Instance or None if instance with + given id is not available. + """ + + return self._instances_by_id.get(instance_id) + def get_sorted_creators(self, identifiers=None): """Sorted creators by 'order' attribute. diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index a67c8397b1..4a7b1b3a27 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -331,6 +331,11 @@ class ColormanagedPyblishPluginMixin(object): project_settings=project_settings_, anatomy_data=anatomy_data ) + + # in case host color management is not enabled + if not config_data: + return None + file_rules = get_imageio_file_rules( project_name, host_name, project_settings=project_settings_ @@ -387,6 +392,11 @@ class ColormanagedPyblishPluginMixin(object): if colorspace_settings is None: colorspace_settings = self.get_colorspace_settings(context) + # in case host color management is not enabled + if not colorspace_settings: + self.log.warning("Host's colorspace management is disabled.") + return + # unpack colorspace settings config_data, file_rules = colorspace_settings diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 4888476fff..8806a13ca0 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -16,7 +16,7 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): - create_context = context.data.pop("create_context", None) + create_context = context.data.get("create_context") if not create_context: host = registered_host() if isinstance(host, IPublishHost): diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 6a8ae958d2..41d6cf81fc 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -266,6 +266,16 @@ class ExtractBurnin(publish.Extractor): first_output = True files_to_delete = [] + + repre_burnin_options = copy.deepcopy(burnin_options) + # Use fps from representation for output in options + fps = repre.get("fps") + if fps is not None: + repre_burnin_options["fps"] = fps + # TODO Should we use fps from source representation to fill + # it in review? + # burnin_data["fps"] = fps + for filename_suffix, burnin_def in repre_burnin_defs.items(): new_repre = copy.deepcopy(repre) new_repre["stagingDir"] = src_repre_staging_dir @@ -308,7 +318,7 @@ class ExtractBurnin(publish.Extractor): "input": temp_data["full_input_path"], "output": temp_data["full_output_path"], "burnin_data": burnin_data, - "options": copy.deepcopy(burnin_options), + "options": repre_burnin_options, "values": burnin_values, "full_input_path": temp_data["full_input_paths"][0], "first_frame": temp_data["first_frame"], @@ -463,15 +473,11 @@ class ExtractBurnin(publish.Extractor): handle_start = instance.data.get("handleStart") if handle_start is None: - handle_start = context.data.get("handleStart") - if handle_start is None: - handle_start = handles + handle_start = context.data.get("handleStart") or 0 handle_end = instance.data.get("handleEnd") if handle_end is None: - handle_end = context.data.get("handleEnd") - if handle_end is None: - handle_end = handles + handle_end = context.data.get("handleEnd") or 0 frame_start_handle = frame_start - handle_start frame_end_handle = frame_end + handle_end diff --git a/openpype/plugins/publish/extract_color_transcode.py b/openpype/plugins/publish/extract_color_transcode.py index 45b10620d1..f7c8af9318 100644 --- a/openpype/plugins/publish/extract_color_transcode.py +++ b/openpype/plugins/publish/extract_color_transcode.py @@ -184,6 +184,11 @@ class ExtractOIIOTranscode(publish.Extractor): if tag == "review": added_review = True + # If there is only 1 file outputted then convert list to + # string, cause that'll indicate that its not a sequence. + if len(new_repre["files"]) == 1: + new_repre["files"] = new_repre["files"][0] + new_representations.append(new_repre) added_representations = True diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 6a24cb0ebc..56a0fe60cd 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -356,6 +356,13 @@ class PypeCommands: def pack_project(self, project_name, dirpath, database_only): from openpype.lib.project_backpack import pack_project + if database_only and not dirpath: + raise ValueError(( + "Destination dir must be defined when using --dbonly." + " Use '--dirpath {output dir path}' flag" + " to specify directory." + )) + pack_project(project_name, dirpath, database_only) def unpack_project(self, zip_filepath, new_root, database_only): diff --git a/openpype/scripts/otio_burnin.py b/openpype/scripts/otio_burnin.py index d0a4266941..581734a789 100644 --- a/openpype/scripts/otio_burnin.py +++ b/openpype/scripts/otio_burnin.py @@ -1,6 +1,5 @@ import os import sys -import re import subprocess import platform import json @@ -13,6 +12,7 @@ from openpype.lib import ( get_ffmpeg_codec_args, get_ffmpeg_format_args, convert_ffprobe_fps_value, + convert_ffprobe_fps_to_float, ) @@ -41,45 +41,6 @@ TIMECODE_KEY = "{timecode}" SOURCE_TIMECODE_KEY = "{source_timecode}" -def convert_list_to_command(list_to_convert, fps, label=""): - """Convert a list of values to a drawtext command file for ffmpeg `sendcmd` - - The list of values is expected to have a value per frame. If the video - file ends up being longer than the amount of samples per frame than the - last value will be held. - - Args: - list_to_convert (list): List of values per frame. - fps (float or int): The expected frame per seconds of the output file. - label (str): Label for the drawtext, if specific drawtext filter is - required - - Returns: - str: Filepath to the temporary drawtext command file. - - """ - - with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: - for i, value in enumerate(list_to_convert): - seconds = i / fps - - # Escape special character - value = str(value).replace(":", "\\:") - - filter = "drawtext" - if label: - filter += "@" + label - - line = ( - "{start} {filter} reinit text='{value}';" - "\n".format(start=seconds, filter=filter, value=value) - ) - - f.write(line) - f.flush() - return f.name - - def _get_ffprobe_data(source): """Reimplemented from otio burnins to be able use full path to ffprobe :param str source: source media file @@ -178,6 +139,7 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): self.ffprobe_data = ffprobe_data self.first_frame = first_frame self.input_args = [] + self.cleanup_paths = [] super().__init__(source, source_streams) @@ -191,7 +153,6 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): frame_start=None, frame_end=None, options=None, - cmd="" ): """ Adding static text to a filter. @@ -212,13 +173,9 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): if frame_end is not None: options["frame_end"] = frame_end - draw_text = DRAWTEXT - if cmd: - draw_text = "{}, {}".format(cmd, DRAWTEXT) options["label"] = align - - self._add_burnin(text, align, options, draw_text) + self._add_burnin(text, align, options, DRAWTEXT) def add_timecode( self, align, frame_start=None, frame_end=None, frame_start_tc=None, @@ -263,6 +220,139 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): self._add_burnin(text, align, options, TIMECODE) + def add_per_frame_text( + self, + text, + align, + frame_start, + frame_end, + listed_keys, + options=None + ): + """Add text that changes per frame. + + Args: + text (str): Template string with unfilled keys that are changed + per frame. + align (str): Alignment of text. + frame_start (int): Starting frame for burnins current frame. + frame_end (int): Ending frame for burnins current frame. + listed_keys (list): List of keys that are changed per frame. + options (Optional[dict]): Options to affect style of burnin. + """ + + if not options: + options = ffmpeg_burnins.TimeCodeOptions(**self.options_init) + + options = options.copy() + if frame_start is None: + frame_start = options["frame_offset"] + + # `frame_end` is only for meassurements of text position + if frame_end is None: + frame_end = options["frame_end"] + + fps = options.get("fps") + if not fps: + fps = self.frame_rate + + text_for_size = text + if CURRENT_FRAME_SPLITTER in text: + expr = self._get_current_frame_expression(frame_start, frame_end) + if expr is None: + expr = MISSING_KEY_VALUE + text_for_size = text_for_size.replace( + CURRENT_FRAME_SPLITTER, MISSING_KEY_VALUE) + text = text.replace(CURRENT_FRAME_SPLITTER, expr) + + # Find longest list with values + longest_list_len = max( + len(item["values"]) for item in listed_keys.values() + ) + # Where to store formatted values per frame by key + new_listed_keys = [{} for _ in range(longest_list_len)] + # Find the longest value per fill key. + # The longest value is used to determine size of burnin box. + longest_value_by_key = {} + for key, item in listed_keys.items(): + values = item["values"] + # Fill the missing values from the longest list with the last + # value to make sure all values have same "frame count" + last_value = values[-1] if values else "" + for _ in range(longest_list_len - len(values)): + values.append(last_value) + + # Prepare dictionary structure for nestes values + # - last key is overriden on each frame loop + item_keys = list(item["keys"]) + fill_data = {} + sub_value = fill_data + last_item_key = item_keys.pop(-1) + for item_key in item_keys: + sub_value[item_key] = {} + sub_value = sub_value[item_key] + + # Fill value per frame + key_max_len = 0 + key_max_value = "" + for value, new_values in zip(values, new_listed_keys): + sub_value[last_item_key] = value + try: + value = key.format(**sub_value) + except (TypeError, KeyError, ValueError): + value = MISSING_KEY_VALUE + new_values[key] = value + + value_len = len(value) + if value_len > key_max_len: + key_max_value = value + key_max_len = value_len + + # Store the longest value + longest_value_by_key[key] = key_max_value + + # Make sure the longest value of each key is replaced for text size + # calculation + for key, value in longest_value_by_key.items(): + text_for_size = text_for_size.replace(key, value) + + # Create temp file with instructions for each frame of text + lines = [] + for frame, value in enumerate(new_listed_keys): + seconds = float(frame) / fps + # Escape special character + new_text = text + for _key, _value in value.items(): + _value = str(_value) + new_text = new_text.replace(_key, str(_value)) + + new_text = ( + str(new_text) + .replace("\\", "\\\\") + .replace(",", "\\,") + .replace(":", "\\:") + ) + lines.append( + f"{seconds} drawtext@{align} reinit text='{new_text}';") + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp: + path = temp.name + temp.write("\n".join(lines)) + + self.cleanup_paths.append(path) + self.filters["drawtext"].append("sendcmd=f='{}'".format( + path.replace("\\", "/").replace(":", "\\:") + )) + self.add_text(text_for_size, align, frame_start, frame_end, options) + + def _get_current_frame_expression(self, frame_start, frame_end): + if frame_start is None: + return None + return ( + "%{eif:n+" + str(frame_start) + + ":d:" + str(len(str(frame_end))) + "}" + ) + def _add_burnin(self, text, align, options, draw): """ Generic method for building the filter flags. @@ -276,18 +366,19 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): if CURRENT_FRAME_SPLITTER in text: frame_start = options["frame_offset"] frame_end = options.get("frame_end", frame_start) - if frame_start is None: - replacement_final = replacement_size = str(MISSING_KEY_VALUE) + expr = self._get_current_frame_expression(frame_start, frame_end) + if expr is not None: + max_length = len(str(frame_end)) + # Use number '8' length times for replacement + size_replacement = max_length * "8" else: - replacement_final = "%{eif:n+" + str(frame_start) + ":d:" + \ - str(len(str(frame_end))) + "}" - replacement_size = str(frame_end) + expr = size_replacement = MISSING_KEY_VALUE final_text = final_text.replace( - CURRENT_FRAME_SPLITTER, replacement_final + CURRENT_FRAME_SPLITTER, expr ) text_for_size = text_for_size.replace( - CURRENT_FRAME_SPLITTER, replacement_size + CURRENT_FRAME_SPLITTER, size_replacement ) resolution = self.resolution @@ -314,13 +405,11 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): ffmpeg_burnins._drawtext(align, resolution, text_for_size, options) ) - arg_font_path = font_path - if platform.system().lower() == "windows": - arg_font_path = ( - arg_font_path - .replace(os.sep, r'\\' + os.sep) - .replace(':', r'\:') - ) + arg_font_path = ( + font_path + .replace("\\", "\\\\") + .replace(':', r'\:') + ) data["font"] = arg_font_path self.filters['drawtext'].append(draw % data) @@ -347,9 +436,15 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): if overwrite: output = '-y {}'.format(output) - filters = '' - if self.filter_string: - filters = '-vf "{}"'.format(self.filter_string) + filters = "" + filter_string = self.filter_string + if filter_string: + with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp: + temp.write(filter_string) + filters_path = temp.name + filters = '-filter_script "{}"'.format(filters_path) + print("Filters:", filter_string) + self.cleanup_paths.append(filters_path) if self.first_frame is not None: start_number_arg = "-start_number {}".format(self.first_frame) @@ -420,6 +515,10 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins): "Failed to generate this f*cking file '%s'" % output ) + for path in self.cleanup_paths: + if os.path.exists(path): + os.remove(path) + def example(input_path, output_path): options_init = { @@ -440,6 +539,51 @@ def example(input_path, output_path): burnin.render(output_path, overwrite=True) +def prepare_fill_values(burnin_template, data): + """Prepare values that will be filled instead of burnin template. + + Args: + burnin_template (str): Burnin template string. + data (dict[str, Any]): Data that will be used to fill template. + + Returns: + tuple[dict[str, dict[str, Any]], dict[str, Any], set[str]]: Filled + values that can be used as are, listed values that have different + value per frame and missing keys that are not present in data. + """ + + fill_values = {} + listed_keys = {} + missing_keys = set() + for item in Formatter().parse(burnin_template): + _, field_name, format_spec, conversion = item + if not field_name: + continue + # Calculate nested keys '{project[name]}' -> ['project', 'name'] + keys = [key.rstrip("]") for key in field_name.split("[")] + # Calculate original full key for replacement + conversion = "!{}".format(conversion) if conversion else "" + format_spec = ":{}".format(format_spec) if format_spec else "" + orig_key = "{{{}{}{}}}".format( + field_name, conversion, format_spec) + + key_value = data + try: + for key in keys: + key_value = key_value[key] + + if isinstance(key_value, list): + listed_keys[orig_key] = { + "values": key_value, + "keys": keys} + else: + fill_values[orig_key] = orig_key.format(**data) + except (KeyError, TypeError): + missing_keys.add(orig_key) + continue + return fill_values, listed_keys, missing_keys + + def burnins_from_data( input_path, output_path, data, codec_data=None, options=None, burnin_values=None, overwrite=True, @@ -512,17 +656,26 @@ def burnins_from_data( frame_end = data.get("frame_end") frame_start_tc = data.get('frame_start_tc', frame_start) - stream = burnin._streams[0] + video_stream = None + for stream in burnin._streams: + if stream.get("codec_type") == "video": + video_stream = stream + break + + if video_stream is None: + raise ValueError("Source didn't have video stream.") + if "resolution_width" not in data: - data["resolution_width"] = stream.get("width", MISSING_KEY_VALUE) + data["resolution_width"] = video_stream.get( + "width", MISSING_KEY_VALUE) if "resolution_height" not in data: - data["resolution_height"] = stream.get("height", MISSING_KEY_VALUE) + data["resolution_height"] = video_stream.get( + "height", MISSING_KEY_VALUE) + r_frame_rate = video_stream.get("r_frame_rate", "0/0") if "fps" not in data: - data["fps"] = convert_ffprobe_fps_value( - stream.get("r_frame_rate", "0/0") - ) + data["fps"] = convert_ffprobe_fps_value(r_frame_rate) # Check frame start and add expression if is available if frame_start is not None: @@ -531,9 +684,9 @@ def burnins_from_data( if frame_start_tc is not None: data[TIMECODE_KEY[1:-1]] = TIMECODE_KEY - source_timecode = stream.get("timecode") + source_timecode = video_stream.get("timecode") if source_timecode is None: - source_timecode = stream.get("tags", {}).get("timecode") + source_timecode = video_stream.get("tags", {}).get("timecode") # Use "format" key from ffprobe data # - this is used e.g. in mxf extension @@ -589,59 +742,24 @@ def burnins_from_data( print("Source does not have set timecode value.") value = value.replace(SOURCE_TIMECODE_KEY, MISSING_KEY_VALUE) - # Convert lists. - cmd = "" - text = None - keys = [i[1] for i in Formatter().parse(value) if i[1] is not None] - list_to_convert = [] - - # Warn about nested dictionary support for lists. Ei. we dont support - # it. - if "[" in "".join(keys): - print( - "We dont support converting nested dictionaries to lists," - " so skipping {}".format(value) - ) - else: - for key in keys: - data_value = data[key] - - # Multiple lists are not supported. - if isinstance(data_value, list) and list_to_convert: - raise ValueError( - "Found multiple lists to convert, which is not " - "supported: {}".format(value) - ) - - if isinstance(data_value, list): - print("Found list to convert: {}".format(data_value)) - for v in data_value: - data[key] = v - list_to_convert.append(value.format(**data)) - - if list_to_convert: - value = list_to_convert[0] - path = convert_list_to_command( - list_to_convert, data["fps"], label=align - ) - cmd = "sendcmd=f='{}'".format(path) - cmd = cmd.replace("\\", "/") - cmd = cmd.replace(":", "\\:") - clean_up_paths.append(path) - # Failsafe for missing keys. - key_pattern = re.compile(r"(\{.*?[^{0]*\})") - missing_keys = [] - for group in key_pattern.findall(value): - try: - group.format(**data) - except (TypeError, KeyError): - missing_keys.append(group) + fill_values, listed_keys, missing_keys = prepare_fill_values( + value, data + ) - missing_keys = list(set(missing_keys)) for key in missing_keys: value = value.replace(key, MISSING_KEY_VALUE) + if listed_keys: + for key, key_value in fill_values.items(): + if key == CURRENT_FRAME_KEY: + key_value = CURRENT_FRAME_SPLITTER + value = value.replace(key, str(key_value)) + burnin.add_per_frame_text( + value, align, frame_start, frame_end, listed_keys + ) + continue + # Handle timecode differently if has_source_timecode: args = [align, frame_start, frame_end, source_timecode] @@ -665,7 +783,7 @@ def burnins_from_data( text = value.format(**data) - burnin.add_text(text, align, frame_start, frame_end, cmd=cmd) + burnin.add_text(text, align, frame_start, frame_end) ffmpeg_args = [] if codec_data: diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json index 6128534344..9be8a6e7d5 100644 --- a/openpype/settings/defaults/project_settings/aftereffects.json +++ b/openpype/settings/defaults/project_settings/aftereffects.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 41aebfa537..eae5b239c8 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -5,12 +5,13 @@ "base_file_unit_scale": 0.01 }, "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/celaction.json b/openpype/settings/defaults/project_settings/celaction.json index 822604fd2f..af56a36649 100644 --- a/openpype/settings/defaults/project_settings/celaction.json +++ b/openpype/settings/defaults/project_settings/celaction.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index 5a13d81384..5b4b62c140 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -1,11 +1,15 @@ { "imageio": { + "activate_host_color_management": true, + "remapping": { + "rules": [] + }, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} }, "project": { diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 4ca4a35d1f..b87c45666d 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -493,7 +493,29 @@ "upload_reviewable_with_origin_name": false }, "IntegrateFtrackFarmStatus": { - "farm_status_profiles": [] + "farm_status_profiles": [ + { + "hosts": [ + "celaction" + ], + "task_types": [], + "task_names": [], + "families": [ + "render" + ], + "subsets": [], + "status_name": "Render" + } + ] + }, + "ftrack_task_status_local_publish": { + "status_profiles": [] + }, + "ftrack_task_status_on_farm_publish": { + "status_profiles": [] + }, + "IntegrateFtrackTaskStatus": { + "after_version_statuses": true } } } diff --git a/openpype/settings/defaults/project_settings/fusion.json b/openpype/settings/defaults/project_settings/fusion.json index 066fc3816a..0ee7d6127d 100644 --- a/openpype/settings/defaults/project_settings/fusion.json +++ b/openpype/settings/defaults/project_settings/fusion.json @@ -1,20 +1,13 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} - }, - "ocio": { - "enabled": false, - "configFilePath": { - "windows": [], - "darwin": [], - "linux": [] - } } }, "copy_fusion_settings": { diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 002e547feb..a78c5cb7ac 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -1,5 +1,6 @@ { "imageio": { + "activate_global_color_management": false, "ocio_config": { "filepath": [ "{OPENPYPE_ROOT}/vendor/bin/ocioconfig/OpenColorIOConfigs/aces_1.2/config.ocio", @@ -7,7 +8,7 @@ ] }, "file_rules": { - "enabled": false, + "activate_global_file_rules": false, "rules": { "example": { "pattern": ".*(beauty).*", diff --git a/openpype/settings/defaults/project_settings/harmony.json b/openpype/settings/defaults/project_settings/harmony.json index 3f51a9c28b..02f51d1d2b 100644 --- a/openpype/settings/defaults/project_settings/harmony.json +++ b/openpype/settings/defaults/project_settings/harmony.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/hiero.json b/openpype/settings/defaults/project_settings/hiero.json index 3e613aa1bf..9c83733b09 100644 --- a/openpype/settings/defaults/project_settings/hiero.json +++ b/openpype/settings/defaults/project_settings/hiero.json @@ -1,20 +1,16 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} }, "workfile": { "ocioConfigName": "nuke-default", - "ocioconfigpath": { - "windows": [], - "darwin": [], - "linux": [] - }, "workingSpace": "linear", "sixteenBitLut": "sRGB", "eightBitLut": "sRGB", diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 1b7faf8526..a53f1ff202 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index a757e08ef5..bfb1aa4aeb 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -1,12 +1,23 @@ { + "imageio": { + "activate_host_color_management": true, + "ocio_config": { + "override_global_config": false, + "filepath": [] + }, + "file_rules": { + "activate_host_rules": false, + "rules": {} + } + }, "RenderSettings": { "default_render_image_folder": "renders/3dsmax", "aov_separator": "underscore", "image_format": "exr", "multipass": true }, - "PointCloud":{ - "attribute":{ + "PointCloud": { + "attribute": { "Age": "age", "Radius": "radius", "Position": "position", diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 9aad71563a..af0f74d2af 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -410,31 +410,28 @@ ] }, "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} }, + "workfile": { + "enabled": false, + "renderSpace": "ACEScg", + "displayName": "sRGB", + "viewName": "ACES 1.0 SDR-video" + }, "colorManagementPreference_v2": { "enabled": true, - "configFilePath": { - "windows": [], - "darwin": [], - "linux": [] - }, "renderSpace": "ACEScg", "displayName": "sRGB", "viewName": "ACES 1.0 SDR-video" }, "colorManagementPreference": { - "configFilePath": { - "windows": [], - "darwin": [], - "linux": [] - }, "renderSpace": "scene-linear Rec 709/sRGB", "viewTransform": "sRGB gamma" } @@ -456,6 +453,10 @@ "destination-path": [] } }, + "include_handles": { + "include_handles_default": false, + "per_task_type": [] + }, "scriptsmenu": { "name": "OpenPype Tools", "definition": [ @@ -1556,10 +1557,6 @@ } ] }, - "include_handles": { - "include_handles_default": false, - "per_task_type": [] - }, "templated_workfile_build": { "profiles": [] }, diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 3f8be4c872..85e3c0d3c3 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -9,12 +9,13 @@ } }, "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} }, "viewer": { @@ -26,11 +27,6 @@ "workfile": { "colorManagement": "Nuke", "OCIO_config": "nuke-default", - "customOCIOConfigPath": { - "windows": [], - "darwin": [], - "linux": [] - }, "workingSpaceLUT": "linear", "monitorLut": "sRGB", "int8Lut": "sRGB", @@ -148,7 +144,7 @@ }, { "plugins": [ - "CreateWriteStill" + "CreateWriteImage" ], "nukeNodeClass": "Write", "knobs": [ @@ -229,6 +225,13 @@ "title": "Set Frame Start (Read Node)", "command": "from openpype.hosts.nuke.startup.frame_setting_for_read_nodes import main;main();", "tooltip": "Set frame start for read node(s)" + }, + { + "type": "action", + "sourcetype": "python", + "title": "Set non publish output for Write Node", + "command": "from openpype.hosts.nuke.startup.custom_write_node import main;main();", + "tooltip": "Open the OpenPype Nuke user doc page" } ] }, @@ -556,15 +559,7 @@ "load": { "LoadImage": { "enabled": true, - "_representations": [ - "exr", - "dpx", - "jpg", - "jpeg", - "png", - "psd", - "tiff" - ], + "_representations": [], "node_name_template": "{class_name}_{ext}" }, "LoadClip": { diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 2454691958..71f94f5bfc 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -1,11 +1,15 @@ { "imageio": { + "activate_host_color_management": true, + "remapping": { + "rules": [] + }, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/resolve.json b/openpype/settings/defaults/project_settings/resolve.json index 56efa78e89..95b3cc66b3 100644 --- a/openpype/settings/defaults/project_settings/resolve.json +++ b/openpype/settings/defaults/project_settings/resolve.json @@ -1,12 +1,16 @@ { "launch_openpype_menu_on_start": false, "imageio": { + "activate_host_color_management": true, + "remapping": { + "rules": [] + }, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/substancepainter.json b/openpype/settings/defaults/project_settings/substancepainter.json index 60929e85fd..4adeff98ef 100644 --- a/openpype/settings/defaults/project_settings/substancepainter.json +++ b/openpype/settings/defaults/project_settings/substancepainter.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": true, + "override_global_config": true, "filepath": [] }, "file_rules": { - "enabled": true, + "activate_host_rules": true, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index 1b4253a1f8..4c2c2f1391 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, @@ -22,6 +23,7 @@ "detailed_description": "Workfiles are full scenes from any application that are directly edited by artists. They represent a state of work on a task at a given point and are usually not directly referenced into other scenes.", "allow_sequences": false, "allow_multiple_items": false, + "allow_version_control": false, "extensions": [ ".ma", ".mb", @@ -56,6 +58,7 @@ "detailed_description": "Models should only contain geometry data, without any extras like cameras, locators or bones.\n\nKeep in mind that models published from tray publisher are not validated for correctness. ", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".ma", ".mb", @@ -81,6 +84,7 @@ "detailed_description": "Alembic or bgeo cache of animated data", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".abc", ".bgeo", @@ -104,6 +108,7 @@ "detailed_description": "Any type of image seqeuence coming from outside of the studio. Usually camera footage, but could also be animatics used for reference.", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".exr", ".png", @@ -126,6 +131,7 @@ "detailed_description": "Sequence or single file renders", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".exr", ".png", @@ -149,6 +155,7 @@ "detailed_description": "Ideally this should be only camera itself with baked animation, however, it can technically also include helper geometry.", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".abc", ".ma", @@ -173,6 +180,7 @@ "detailed_description": "Any image data can be published as image family. References, textures, concept art, matte paints. This is a fallback 2d family for everything that doesn't fit more specific family.", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".exr", ".jpg", @@ -196,6 +204,7 @@ "detailed_description": "Hierarchical data structure for the efficient storage and manipulation of sparse volumetric data discretized on three-dimensional grids", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".vdb" ] @@ -214,6 +223,7 @@ "detailed_description": "Script exported from matchmoving application to be later processed into a tracked camera with additional data", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [] }, { @@ -226,6 +236,7 @@ "detailed_description": "CG rigged character or prop. Rig should be clean of any extra data and directly loadable into it's respective application\t", "allow_sequences": false, "allow_multiple_items": false, + "allow_version_control": false, "extensions": [ ".ma", ".blend", @@ -243,6 +254,7 @@ "detailed_description": "Texture files with Unreal Engine naming conventions", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [] } ], @@ -321,6 +333,11 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateExistingVersion": { + "enabled": true, + "optional": true, + "active": true } } } diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index 1671748e97..1f4f468656 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/unreal.json b/openpype/settings/defaults/project_settings/unreal.json index 92bdb468ba..20e55c74f0 100644 --- a/openpype/settings/defaults/project_settings/unreal.json +++ b/openpype/settings/defaults/project_settings/unreal.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/defaults/project_settings/webpublisher.json b/openpype/settings/defaults/project_settings/webpublisher.json index e830ba6a40..e451bcfc17 100644 --- a/openpype/settings/defaults/project_settings/webpublisher.json +++ b/openpype/settings/defaults/project_settings/webpublisher.json @@ -1,11 +1,12 @@ { "imageio": { + "activate_host_color_management": true, "ocio_config": { - "enabled": false, + "override_global_config": false, "filepath": [] }, "file_rules": { - "enabled": false, + "activate_host_rules": false, "rules": {} } }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json index 313e0ce8ea..d4f52b50d4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json index 5b40169872..c549b577b2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_blender.json @@ -34,18 +34,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_celaction.json b/openpype/settings/entities/schemas/projects_schema/schema_project_celaction.json index c5ca3eb9f5..9d50e85631 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_celaction.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_celaction.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (derived to OCIO)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_derived" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index aab8f21d15..06f818966f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -8,16 +8,13 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (remapped to OCIO)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_remapped" }, { "key": "project", @@ -47,10 +44,14 @@ } ] }, + { + "type": "label", + "label": "Profile names mapping settings is deprecated use ./imagio/remapping instead" + }, { "key": "profilesMapping", "type": "dict", - "label": "Profile names mapping", + "label": "Profile names mapping [deprecated]", "collapsible": true, "children": [ { @@ -362,7 +363,7 @@ }, { "key": "colorspace_out", - "label": "Output color (imageio)", + "label": "Output color", "type": "text", "default": "linear" }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index 7050721742..157a8d297e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -1058,7 +1058,7 @@ { "type": "dict", "key": "IntegrateFtrackFarmStatus", - "label": "Integrate Ftrack Farm Status", + "label": "Ftrack Status To Farm", "children": [ { "type": "label", @@ -1068,7 +1068,7 @@ "type": "list", "collapsible": true, "key": "farm_status_profiles", - "label": "Farm status profiles", + "label": "Profiles", "use_label_wrap": true, "object_type": { "type": "dict", @@ -1114,6 +1114,142 @@ } } ] + }, + { + "type": "dict", + "key": "ftrack_task_status_local_publish", + "label": "Ftrack Status Local Integration", + "children": [ + { + "type": "label", + "label": "Change status of task when is integrated locally" + }, + { + "type": "list", + "collapsible": true, + "key": "status_profiles", + "label": "Profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "host_names", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subset_names", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "status_name", + "label": "Status name", + "type": "text" + } + ] + } + } + ] + }, + { + "type": "dict", + "key": "ftrack_task_status_on_farm_publish", + "label": "Ftrack Status On Farm", + "children": [ + { + "type": "label", + "label": "Change status of task when it's subset is integrated on farm" + }, + { + "type": "list", + "collapsible": true, + "key": "status_profiles", + "label": "Profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "host_names", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subset_names", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "status_name", + "label": "Status name", + "type": "text" + } + ] + } + } + ] + }, + { + "type": "dict", + "key": "IntegrateFtrackTaskStatus", + "label": "Integrate Ftrack Task Status", + "children": [ + { + "type": "label", + "label": "Apply collected task statuses. This plugin can run before or after version integration. Some status automations may conflict with status changes on versions because of wrong order." + }, + { + "type": "boolean", + "key": "after_version_statuses", + "label": "After version integration" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json index 7971c62300..656c50dd98 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_fusion.json @@ -8,41 +8,13 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", "collapsible": true, + "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" - }, - { - "key": "ocio", - "type": "dict", - "label": "OpenColorIO (OCIO)", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Set OCIO variable for Fusion" - }, - { - "type": "label", - "label": "'configFilePath' will be deprecated.
Please move values to : project_settings/{app}/imageio/ocio_config/filepath." - }, - { - "type": "path", - "key": "configFilePath", - "label": "OCIO Config File Path", - "multiplatform": true, - "multipath": true - } - ] + "type": "template", + "name": "template_host_color_management_ocio" } ] }, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_global.json b/openpype/settings/entities/schemas/projects_schema/schema_project_global.json index 6f31f4f685..953361935c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_global.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_global.json @@ -8,9 +8,18 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management", "is_group": true, "children": [ + { + "type": "label", + "label": "It's important to note that once color management is activated on a project, all hosts will be color managed by default.
The OpenColorIO (OCIO) config file is used either from the global settings or from the host's overrides. It's worth
noting that the order of the defined configuration paths matters, with higher priority given to paths listed earlier in
the configuration list.

To avoid potential issues, ensure that the OCIO configuration path is not an absolute path and includes at least
the root token (Anatomy). This helps ensure that the configuration path remains valid across different environments and
avoids any hard-coding of paths that may be specific to one particular system.

Related documentation." + }, + { + "type": "boolean", + "key": "activate_global_color_management", + "label": "Enable Color Management" + }, { "key": "ocio_config", "type": "dict", @@ -27,8 +36,44 @@ ] }, { - "type": "schema", - "name": "schema_imageio_file_rules" + "key": "file_rules", + "type": "dict", + "label": "File Rules (OCIO v1 only)", + "collapsible": true, + "children": [ + { + "type": "boolean", + "key": "activate_global_file_rules", + "label": "Enable File Rules" + }, + { + "key": "rules", + "label": "Rules", + "type": "dict-modifiable", + "highlight_content": true, + "collapsible": false, + "object_type": { + "type": "dict", + "children": [ + { + "key": "pattern", + "label": "Regex pattern", + "type": "text" + }, + { + "key": "colorspace", + "label": "Colorspace name", + "type": "text" + }, + { + "key": "ext", + "label": "File extension", + "type": "text" + } + ] + } + } + ] } ] diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json index e6bf835c9f..98a815f2d4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json index ea05f4ab9b..d80edf902b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_hiero.json @@ -8,17 +8,13 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", - "is_group": true, + "label": "Color Management (OCIO managed)", "collapsible": true, + "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" }, { "key": "workfile", @@ -26,10 +22,6 @@ "label": "Workfile", "collapsible": false, "children": [ - { - "type": "label", - "label": "'ocioconfigpath' will be deprecated.
Please move values to : project_settings/{app}/imageio/ocio_config/filepath." - }, { "type": "form", "children": [ @@ -55,19 +47,9 @@ }, { "cg-config-v1.0.0_aces-v1.3_ocio-v2.1": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)" - }, - { - "custom": "custom" } ] }, - { - "type": "path", - "key": "ocioconfigpath", - "label": "Custom OCIO path", - "multiplatform": true, - "multipath": true - }, { "type": "text", "key": "workingSpace", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json index 24b06f77db..7f782e3647 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_houdini.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" } - ] }, { @@ -35,4 +31,4 @@ "name": "schema_houdini_publish" } ] -} \ No newline at end of file +} 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 42506559d0..e314174dff 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json @@ -5,6 +5,19 @@ "label": "Max", "is_file": true, "children": [ + { + "key": "imageio", + "type": "dict", + "label": "Color Management (OCIO managed)", + "collapsible": true, + "is_group": true, + "children": [ + { + "type": "template", + "name": "template_host_color_management_ocio" + } + ] + }, { "type": "dict", "collapsible": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json index b27d795806..dca955dab4 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_maya.json @@ -48,40 +48,25 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" + "type": "template", + "name": "template_host_color_management_ocio" }, { - "type": "schema", - "name": "schema_imageio_file_rules" - }, - { - "key": "colorManagementPreference_v2", + "key": "workfile", "type": "dict", - "label": "Color Management Preference v2 (Maya 2022+)", + "label": "Workfile", "collapsible": true, "checkbox_key": "enabled", "children": [ { "type": "boolean", "key": "enabled", - "label": "Use Color Management Preference v2" - }, - { - "type": "label", - "label": "'configFilePath' will be deprecated.
Please move values to : project_settings/{app}/imageio/ocio_config/filepath." - }, - { - "type": "path", - "key": "configFilePath", - "label": "OCIO Config File Path", - "multiplatform": true, - "multipath": true + "label": "Enabled" }, { "type": "text", @@ -101,31 +86,57 @@ ] }, { - "key": "colorManagementPreference", - "type": "dict", - "label": "Color Management Preference (legacy)", + "type": "collapsible-wrap", + "label": "[Deprecated] please migrate all to 'Workfile' and enable it.", "collapsible": true, + "collapsed": true, "children": [ { - "type": "label", - "label": "'configFilePath' will be deprecated.
Please move values to : project_settings/{app}/imageio/ocio_config/filepath." + "key": "colorManagementPreference_v2", + "type": "dict", + "label": "[DEPRECATED] Color Management Preference v2 (Maya 2022+)", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Use Color Management Preference v2" + }, + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "displayName", + "label": "Display" + }, + { + "type": "text", + "key": "viewName", + "label": "View" + } + ] }, { - "type": "path", - "key": "configFilePath", - "label": "OCIO Config File Path", - "multiplatform": true, - "multipath": true - }, - { - "type": "text", - "key": "renderSpace", - "label": "Rendering Space" - }, - { - "type": "text", - "key": "viewTransform", - "label": "Viewer Transform" + "key": "colorManagementPreference", + "type": "dict", + "label": "[DEPRECATED] Color Management Preference (legacy)", + "collapsible": true, + "children": [ + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "viewTransform", + "label": "Viewer Transform (workfile/viewName)" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index f6c46aba8b..20d4ff0aa3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (remapped to OCIO)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_remapped" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_resolve.json b/openpype/settings/entities/schemas/projects_schema/schema_project_resolve.json index 6f98bdd3bd..650470850e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_resolve.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_resolve.json @@ -13,18 +13,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (remapped to OCIO)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_remapped" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_substancepainter.json b/openpype/settings/entities/schemas/projects_schema/schema_project_substancepainter.json index 79a39b8e6e..6be8cecad3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_substancepainter.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_substancepainter.json @@ -8,18 +8,13 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index f05f3433b0..e75e2887db 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (derived to OCIO)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_derived" } - ] }, { @@ -89,6 +85,12 @@ "label": "Allow multiple items", "type": "boolean" }, + { + "type": "boolean", + "key": "allow_version_control", + "label": "Allow version control", + "default": false + }, { "type": "list", "key": "extensions", @@ -350,6 +352,10 @@ { "key": "ValidateFrameRange", "label": "Validate frame range" + }, + { + "key": "ValidateExistingVersion", + "label": "Validate Existing Version" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index 1094595851..45fc13bdde 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (derived to OCIO)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_derived" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json b/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json index 35eb0b24f1..b23744f406 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_unreal.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json index 66ccca644d..87de732d69 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_webpublisher.json @@ -8,18 +8,14 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (derived to OCIO)", + "collapsible": true, "is_group": true, "children": [ { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_derived" } - ] }, { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_imageio_config.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_imageio_config.json deleted file mode 100644 index e7cff969d3..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_imageio_config.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "key": "ocio_config", - "type": "dict", - "label": "OCIO config", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "path", - "key": "filepath", - "label": "Config path", - "multiplatform": false, - "multipath": true - } - ] -} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_imageio_file_rules.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_imageio_file_rules.json deleted file mode 100644 index a171ba1c55..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_imageio_file_rules.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "key": "file_rules", - "type": "dict", - "label": "File Rules", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "key": "rules", - "label": "Rules", - "type": "dict-modifiable", - "highlight_content": true, - "collapsible": false, - "object_type": { - "type": "dict", - "children": [ - { - "key": "pattern", - "label": "Regex pattern", - "type": "text" - }, - { - "key": "colorspace", - "label": "Colorspace name", - "type": "text" - }, - { - "key": "ext", - "label": "File extension", - "type": "text" - } - ] - } - } - ] -} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json index 21f6baff9e..d4cd332ef8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_imageio.json @@ -1,21 +1,13 @@ { "key": "imageio", "type": "dict", - "label": "Color Management (ImageIO)", + "label": "Color Management (OCIO managed)", "collapsible": true, "is_group": true, "children": [ { - "type": "label", - "label": "'Custom OCIO config path' has deprecated.
If you need to set custom config, just enable and add path into 'OCIO config'.
Anatomy keys are supported.." - }, - { - "type": "schema", - "name": "schema_imageio_config" - }, - { - "type": "schema", - "name": "schema_imageio_file_rules" + "type": "template", + "name": "template_host_color_management_ocio" }, { "key": "viewer", @@ -102,19 +94,9 @@ }, { "cg-config-v1.0.0_aces-v1.3_ocio-v2.1": "cg-config-v1.0.0_aces-v1.3_ocio-v2.1 (14)" - }, - { - "custom": "custom" } ] }, - { - "type": "path", - "key": "customOCIOConfigPath", - "label": "Custom OCIO config path", - "multiplatform": true, - "multipath": true - }, { "type": "text", "key": "workingSpaceLUT", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_colorspace_remapping.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_colorspace_remapping.json new file mode 100644 index 0000000000..acd36ece9d --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_colorspace_remapping.json @@ -0,0 +1,29 @@ +[ + { + "key": "remapping", + "type": "dict", + "label": "Remapping colorspace names", + "collapsible": true, + "children": [ + { + "type": "list", + "key": "rules", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "host_native_name", + "label": "Application native colorspace name" + }, + { + "type": "text", + "key": "ocio_name", + "label": "OCIO colorspace name" + } + ] + } + } + ] + } +] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_derived.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_derived.json new file mode 100644 index 0000000000..a129d470c0 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_derived.json @@ -0,0 +1,19 @@ +[ + { + "type": "label", + "label": "The application does not include any built-in color management capabilities, OpenPype offers a solution
to this limitation by deriving valid colorspace names for the OpenColorIO (OCIO) color management
system from file paths, using File Rules feature only during Publishing.

Related documentation." + }, + { + "type": "boolean", + "key": "activate_host_color_management", + "label": "Enable Color Management" + }, + { + "type": "template", + "name": "template_imageio_config" + }, + { + "type": "template", + "name": "template_imageio_file_rules" + } +] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_ocio.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_ocio.json new file mode 100644 index 0000000000..88c22fa762 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_ocio.json @@ -0,0 +1,19 @@ +[ + { + "type": "label", + "label": "Colorspace management for the application can be controlled through OpenPype settings.
Specifically, the configured OpenColorIO (OCIO) config path is utilized in the application's workfile.
Additionally, the File Rules feature can be leveraged for both publishing and loading procedures.

Related documentation." + }, + { + "type": "boolean", + "key": "activate_host_color_management", + "label": "Enable Color Management" + }, + { + "type": "template", + "name": "template_imageio_config" + }, + { + "type": "template", + "name": "template_imageio_file_rules" + } +] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_remapped.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_remapped.json new file mode 100644 index 0000000000..780264947f --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_host_color_management_remapped.json @@ -0,0 +1,23 @@ +[ + { + "type": "label", + "label": "The application includes internal color management functionality, but it does not offer external control
over this feature. To address this limitation, OpenPype uses mapping rules to remap the native
colorspace names used in the internal color management system to the OpenColorIO (OCIO)
color management system. Remapping feature is used in Publishing and Loading procedures.

Related documentation.." + }, + { + "type": "boolean", + "key": "activate_host_color_management", + "label": "Enable Color Management" + }, + { + "type": "template", + "name": "template_colorspace_remapping" + }, + { + "type": "template", + "name": "template_imageio_config" + }, + { + "type": "template", + "name": "template_imageio_file_rules" + } +] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_imageio_config.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_imageio_config.json new file mode 100644 index 0000000000..0550e5093c --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_imageio_config.json @@ -0,0 +1,22 @@ +[ + { + "key": "ocio_config", + "type": "dict", + "label": "OCIO config", + "collapsible": true, + "children": [ + { + "type": "boolean", + "key": "override_global_config", + "label": "Override global OCIO config" + }, + { + "type": "path", + "key": "filepath", + "label": "Config path", + "multiplatform": false, + "multipath": true + } + ] + } +] diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_imageio_file_rules.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_imageio_file_rules.json new file mode 100644 index 0000000000..5c6c696578 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schemas/template_imageio_file_rules.json @@ -0,0 +1,42 @@ +[ + { + "key": "file_rules", + "type": "dict", + "label": "File Rules (OCIO v1 only)", + "collapsible": true, + "children": [ + { + "type": "boolean", + "key": "activate_host_rules", + "label": "Activate Host File Rules" + }, + { + "key": "rules", + "label": "Rules", + "type": "dict-modifiable", + "highlight_content": true, + "collapsible": false, + "object_type": { + "type": "dict", + "children": [ + { + "key": "pattern", + "label": "Regex pattern", + "type": "text" + }, + { + "key": "colorspace", + "label": "Colorspace name", + "type": "text" + }, + { + "key": "ext", + "label": "File extension", + "type": "text" + } + ] + } + } + ] + } +] diff --git a/openpype/tools/creator/model.py b/openpype/tools/creator/model.py index 7bb2757a11..6e905d0b56 100644 --- a/openpype/tools/creator/model.py +++ b/openpype/tools/creator/model.py @@ -53,6 +53,9 @@ class CreatorsModel(QtGui.QStandardItemModel): index = self.index(row, 0) item_id = index.data(ITEM_ID_ROLE) creator_plugin = self._creators_by_id.get(item_id) - if creator_plugin and creator_plugin.family == family: + if creator_plugin and ( + creator_plugin.label.lower() == family.lower() + or creator_plugin.family.lower() == family.lower() + ): indexes.append(index) return indexes diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 3aa6c5d8cb..63ffcc9365 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -273,7 +273,7 @@ class ActionModel(QtGui.QStandardItemModel): # Sort by order and name return sorted( compatible, - key=lambda action: (action.order, action.name) + key=lambda action: (action.order, lib.get_action_label(action)) ) def update_force_not_open_workfile_settings(self, is_checked, action_id): diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index e5d8400031..e58e02f89a 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -446,6 +446,7 @@ class SubsetsModel(BaseRepresentationModel, TreeModel): last_versions_by_subset_id = get_last_versions( project_name, subset_ids, + active=True, fields=["_id", "parent", "name", "type", "data", "schema"] ) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 06ae06e4d2..3154f777df 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -1,4 +1,5 @@ import re +import platform from openpype.client import get_projects, create_project from .constants import ( @@ -8,13 +9,16 @@ from .constants import ( from openpype.client.operations import ( PROJECT_NAME_ALLOWED_SYMBOLS, PROJECT_NAME_REGEX, + OperationsSession, ) from openpype.style import load_stylesheet from openpype.pipeline import AvalonMongoDB from openpype.tools.utils import ( PlaceholderLineEdit, - get_warning_pixmap + get_warning_pixmap, + PixmapLabel, ) +from openpype.settings.lib import get_default_anatomy_settings from qtpy import QtWidgets, QtCore, QtGui @@ -35,7 +39,7 @@ class NameTextEdit(QtWidgets.QLineEdit): sub_regex = "[^{}]+".format(NAME_ALLOWED_SYMBOLS) new_before_text = re.sub(sub_regex, "", before_text) new_after_text = re.sub(sub_regex, "", after_text) - idx -= (len(before_text) - len(new_before_text)) + idx -= len(before_text) - len(new_before_text) self.setText(new_before_text + new_after_text) self.setCursorPosition(idx) @@ -141,13 +145,40 @@ class CreateProjectDialog(QtWidgets.QDialog): inputs_widget = QtWidgets.QWidget(self) project_name_input = QtWidgets.QLineEdit(inputs_widget) project_code_input = QtWidgets.QLineEdit(inputs_widget) + project_width_input = NumScrollWidget(0, 9999999) + project_height_input = NumScrollWidget(0, 9999999) + project_fps_input = FloatScrollWidget(1, 9999999, decimals=3, step=1) + project_aspect_input = FloatScrollWidget( + 0, 9999999, decimals=2, step=0.1 + ) + project_frame_start_input = NumScrollWidget(-9999999, 9999999) + project_frame_end_input = NumScrollWidget(-9999999, 9999999) + + default_project_data = self.get_default_attributes() + project_width_input.setValue(default_project_data["resolutionWidth"]) + project_height_input.setValue(default_project_data["resolutionHeight"]) + project_fps_input.setValue(default_project_data["fps"]) + project_aspect_input.setValue(default_project_data["pixelAspect"]) + project_frame_start_input.setValue(default_project_data["frameStart"]) + project_frame_end_input.setValue(default_project_data["frameEnd"]) + library_project_input = QtWidgets.QCheckBox(inputs_widget) inputs_layout = QtWidgets.QFormLayout(inputs_widget) + if platform.system() == "Darwin": + inputs_layout.setFieldGrowthPolicy( + QtWidgets.QFormLayout.AllNonFixedFieldsGrow + ) inputs_layout.setContentsMargins(0, 0, 0, 0) inputs_layout.addRow("Project name:", project_name_input) inputs_layout.addRow("Project code:", project_code_input) inputs_layout.addRow("Library project:", library_project_input) + inputs_layout.addRow("Width:", project_width_input) + inputs_layout.addRow("Height:", project_height_input) + inputs_layout.addRow("FPS:", project_fps_input) + inputs_layout.addRow("Aspect:", project_aspect_input) + inputs_layout.addRow("Frame Start:", project_frame_start_input) + inputs_layout.addRow("Frame End:", project_frame_end_input) project_name_label = QtWidgets.QLabel(self) project_code_label = QtWidgets.QLabel(self) @@ -183,6 +214,12 @@ class CreateProjectDialog(QtWidgets.QDialog): self.project_name_input = project_name_input self.project_code_input = project_code_input self.library_project_input = library_project_input + self.project_width_input = project_width_input + self.project_height_input = project_height_input + self.project_fps_input = project_fps_input + self.project_aspect_input = project_aspect_input + self.project_frame_start_input = project_frame_start_input + self.project_frame_end_input = project_frame_end_input self.ok_btn = ok_btn @@ -190,6 +227,10 @@ class CreateProjectDialog(QtWidgets.QDialog): def project_name(self): return self.project_name_input.text() + def get_default_attributes(self): + settings = get_default_anatomy_settings() + return settings["attributes"] + def _on_project_name_change(self, value): if self._project_code_value is None: self._ignore_code_change = True @@ -215,12 +256,12 @@ class CreateProjectDialog(QtWidgets.QDialog): is_valid = False elif value in self.invalid_project_names: - message = "Project name \"{}\" already exist".format(value) + message = 'Project name "{}" already exist'.format(value) is_valid = False elif not PROJECT_NAME_REGEX.match(value): message = ( - "Project name \"{}\" contain not supported symbols" + 'Project name "{}" contain not supported symbols' ).format(value) is_valid = False @@ -237,12 +278,12 @@ class CreateProjectDialog(QtWidgets.QDialog): is_valid = False elif value in self.invalid_project_names: - message = "Project code \"{}\" already exist".format(value) + message = 'Project code "{}" already exist'.format(value) is_valid = False elif not PROJECT_NAME_REGEX.match(value): message = ( - "Project code \"{}\" contain not supported symbols" + 'Project code "{}" contain not supported symbols' ).format(value) is_valid = False @@ -264,9 +305,35 @@ class CreateProjectDialog(QtWidgets.QDialog): project_name = self.project_name_input.text() project_code = self.project_code_input.text() - library_project = self.library_project_input.isChecked() - create_project(project_name, project_code, library_project) + project_width = self.project_width_input.value() + project_height = self.project_height_input.value() + project_fps = self.project_fps_input.value() + project_aspect = self.project_aspect_input.value() + project_frame_start = self.project_frame_start_input.value() + project_frame_end = self.project_frame_end_input.value() + library_project = self.library_project_input.isChecked() + project_doc = create_project( + project_name, + project_code, + library_project, + ) + update_data = { + "data.resolutionWidth": project_width, + "data.resolutionHeight": project_height, + "data.fps": project_fps, + "data.pixelAspect": project_aspect, + "data.frameStart": project_frame_start, + "data.frameEnd": project_frame_end, + } + session = OperationsSession() + session.update_entity( + project_name, + project_doc["type"], + project_doc["_id"], + update_data, + ) + session.commit() self.done(1) def _get_existing_projects(self): @@ -288,45 +355,15 @@ class CreateProjectDialog(QtWidgets.QDialog): return project_names, project_codes -# TODO PixmapLabel should be moved to 'utils' in other future PR so should be -# imported from there -class PixmapLabel(QtWidgets.QLabel): - """Label resizing image to height of font.""" - def __init__(self, pixmap, parent): - super(PixmapLabel, self).__init__(parent) - self._empty_pixmap = QtGui.QPixmap(0, 0) - self._source_pixmap = pixmap - - def set_source_pixmap(self, pixmap): - """Change source image.""" - self._source_pixmap = pixmap - self._set_resized_pix() - +class ProjectManagerPixmapLabel(PixmapLabel): def _get_pix_size(self): size = self.fontMetrics().height() * 4 return size, size - def _set_resized_pix(self): - if self._source_pixmap is None: - self.setPixmap(self._empty_pixmap) - return - width, height = self._get_pix_size() - self.setPixmap( - self._source_pixmap.scaled( - width, - height, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) - ) - - def resizeEvent(self, event): - self._set_resized_pix() - super(PixmapLabel, self).resizeEvent(event) - class ConfirmProjectDeletion(QtWidgets.QDialog): """Dialog which confirms deletion of a project.""" + def __init__(self, project_name, parent): super(ConfirmProjectDeletion, self).__init__(parent) @@ -335,23 +372,26 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): top_widget = QtWidgets.QWidget(self) warning_pixmap = get_warning_pixmap() - warning_icon_label = PixmapLabel(warning_pixmap, top_widget) + warning_icon_label = ProjectManagerPixmapLabel( + warning_pixmap, top_widget + ) message_label = QtWidgets.QLabel(top_widget) message_label.setWordWrap(True) message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - message_label.setText(( - "WARNING: This cannot be undone.

" - "Project \"{}\" with all related data will be" - " permanently removed from the database. (This action won't remove" - " any files on disk.)" - ).format(project_name)) + message_label.setText( + ( + "WARNING: This cannot be undone.

" + 'Project "{}" with all related data will be' + " permanently removed from the database." + " (This action won't remove any files on disk.)" + ).format(project_name) + ) top_layout = QtWidgets.QHBoxLayout(top_widget) top_layout.setContentsMargins(0, 0, 0, 0) top_layout.addWidget( - warning_icon_label, 0, - QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + warning_icon_label, 0, QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter ) top_layout.addWidget(message_label, 1) @@ -359,7 +399,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): confirm_input = PlaceholderLineEdit(self) confirm_input.setPlaceholderText( - "Type \"{}\" to confirm...".format(project_name) + 'Type "{}" to confirm...'.format(project_name) ) cancel_btn = QtWidgets.QPushButton("Cancel", self) @@ -429,6 +469,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): class SpinBoxScrollFixed(QtWidgets.QSpinBox): """QSpinBox which only allow edits change with scroll wheel when active""" + def __init__(self, *args, **kwargs): super(SpinBoxScrollFixed, self).__init__(*args, **kwargs) self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -442,6 +483,7 @@ class SpinBoxScrollFixed(QtWidgets.QSpinBox): class DoubleSpinBoxScrollFixed(QtWidgets.QDoubleSpinBox): """QDoubleSpinBox which only allow edits with scroll wheel when active""" + def __init__(self, *args, **kwargs): super(DoubleSpinBoxScrollFixed, self).__init__(*args, **kwargs) self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -451,3 +493,22 @@ class DoubleSpinBoxScrollFixed(QtWidgets.QDoubleSpinBox): event.ignore() else: super(DoubleSpinBoxScrollFixed, self).wheelEvent(event) + + +class NumScrollWidget(SpinBoxScrollFixed): + def __init__(self, minimum, maximum): + super(NumScrollWidget, self).__init__() + self.setMaximum(maximum) + self.setMinimum(minimum) + self.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + +class FloatScrollWidget(DoubleSpinBoxScrollFixed): + def __init__(self, minimum, maximum, decimals, step=None): + super(FloatScrollWidget, self).__init__() + self.setMaximum(maximum) + self.setMinimum(minimum) + self.setDecimals(decimals) + if step is not None: + self.setSingleStep(step) + self.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 89c2343ef7..502e871edd 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -398,12 +398,22 @@ class PublishReportMaker: exception = result.get("error") if exception: fname, line_no, func, exc = exception.traceback + + # Conversion of exception into string may crash + try: + msg = str(exception) + except BaseException: + msg = ( + "Publisher Controller: ERROR" + " - Failed to get exception message" + ) + # Action result does not have 'is_validation_error' is_validation_error = result.get("is_validation_error", False) output.append({ "type": "error", "is_validation_error": is_validation_error, - "msg": str(exception), + "msg": msg, "filename": str(fname), "lineno": str(line_no), "func": str(func), diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py index ff10e091b8..663a67ac70 100644 --- a/openpype/tools/publisher/publish_report_viewer/model.py +++ b/openpype/tools/publisher/publish_report_viewer/model.py @@ -45,8 +45,13 @@ class InstancesModel(QtGui.QStandardItemModel): instance_items = report_item.instance_items_by_family[family] all_removed = True for instance_item in instance_items: - item = QtGui.QStandardItem(instance_item.label) - instance_label = html_escape(instance_item.label) + src_instance_label = instance_item.label + if src_instance_label is None: + # Do not cause UI crash if label is 'None' + src_instance_label = "No label" + instance_label = html_escape(src_instance_label) + + item = QtGui.QStandardItem(src_instance_label) item.setData(instance_label, ITEM_LABEL_ROLE) item.setData(instance_item.errored, ITEM_ERRORED_ROLE) item.setData(instance_item.id, ITEM_ID_ROLE) diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 30980af03d..b7605b1188 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -828,6 +828,7 @@ class CreateWidget(QtWidgets.QWidget): if success: self._set_creator(self._selected_creator) + self.variant_input.setText(variant) self._controller.emit_card_message("Creation finished...") self._last_thumbnail_path = None self._thumbnail_widget.set_current_thumbnails() diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py index 557e6559c8..3370f71701 100644 --- a/openpype/tools/publisher/widgets/list_view_widgets.py +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -116,7 +116,12 @@ class InstanceListItemWidget(QtWidgets.QWidget): self.instance = instance - instance_label = html_escape(instance.label) + instance_label = instance.label + if instance_label is None: + # Do not cause UI crash if label is 'None' + instance_label = "No label" + + instance_label = html_escape(instance_label) subset_name_label = QtWidgets.QLabel(instance_label, self) subset_name_label.setObjectName("ListViewSubsetName") diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 6ab444109e..2bda0c1cfe 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -66,8 +66,7 @@ class PublisherWindow(QtWidgets.QDialog): on_top_flag = QtCore.Qt.Dialog self.setWindowFlags( - self.windowFlags() - | QtCore.Qt.WindowTitleHint + QtCore.Qt.WindowTitleHint | QtCore.Qt.WindowMaximizeButtonHint | QtCore.Qt.WindowMinimizeButtonHint | QtCore.Qt.WindowCloseButtonHint @@ -454,7 +453,11 @@ class PublisherWindow(QtWidgets.QDialog): return save_match = event.matches(QtGui.QKeySequence.Save) - if save_match == QtGui.QKeySequence.ExactMatch: + # PySide2 and PySide6 support + if not isinstance(save_match, bool): + save_match = save_match == QtGui.QKeySequence.ExactMatch + + if save_match: if not self._controller.publish_has_started: self._save_changes(True) event.accept() diff --git a/openpype/tools/standalonepublish/widgets/widget_family.py b/openpype/tools/standalonepublish/widgets/widget_family.py index 11c5ec33b7..8c18a93a00 100644 --- a/openpype/tools/standalonepublish/widgets/widget_family.py +++ b/openpype/tools/standalonepublish/widgets/widget_family.py @@ -128,7 +128,8 @@ class FamilyWidget(QtWidgets.QWidget): 'family_preset_key': key, 'family': family, 'subset': self.input_result.text(), - 'version': self.version_spinbox.value() + 'version': self.version_spinbox.value(), + 'use_next_available_version': self.version_checkbox.isChecked(), } return data diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index fa69113ef1..c71c87f9b0 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -123,10 +123,14 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): project_name = self.dbcon.active_project() # Add all available versions to the editor parent_id = item["version_document"]["parent"] - version_docs = list(sorted( - get_versions(project_name, subset_ids=[parent_id]), - key=lambda item: item["name"] - )) + version_docs = [ + version_doc + for version_doc in sorted( + get_versions(project_name, subset_ids=[parent_id]), + key=lambda item: item["name"] + ) + if version_doc["data"].get("active", True) + ] hero_versions = list( get_hero_versions( diff --git a/openpype/tools/workfile_template_build/window.py b/openpype/tools/workfile_template_build/window.py index 24d9105223..df7aedf566 100644 --- a/openpype/tools/workfile_template_build/window.py +++ b/openpype/tools/workfile_template_build/window.py @@ -220,7 +220,6 @@ class WorkfileBuildPlaceholderDialog(QtWidgets.QDialog): # TODO much better error handling try: plugin.create_placeholder(options) - self.accept() except Exception: self.log.warning("Something went wrong", exc_info=True) dialog = QtWidgets.QMessageBox(self) diff --git a/openpype/tools/workfiles/window.py b/openpype/tools/workfiles/window.py index 31ecf50d3b..53f8894665 100644 --- a/openpype/tools/workfiles/window.py +++ b/openpype/tools/workfiles/window.py @@ -1,6 +1,7 @@ import os import datetime import copy +import platform from qtpy import QtCore, QtWidgets, QtGui from openpype.client import ( @@ -94,6 +95,19 @@ class SidePanelWidget(QtWidgets.QWidget): self._on_note_change() self.save_clicked.emit() + def get_user_name(self, file): + """Get user name from file path""" + # Only run on Unix because pwd module is not available on Windows. + # NOTE: we tried adding "win32security" module but it was not working + # on all hosts so we decided to just support Linux until migration + # to Ayon + if platform.system().lower() == "windows": + return None + import pwd + + filestat = os.stat(file) + return pwd.getpwuid(filestat.st_uid).pw_name + def set_context(self, asset_id, task_name, filepath, workfile_doc): # Check if asset, task and file are selected # NOTE workfile document is not requirement @@ -134,8 +148,14 @@ class SidePanelWidget(QtWidgets.QWidget): "Created:", creation_time.strftime(datetime_format), "Modified:", - modification_time.strftime(datetime_format) + modification_time.strftime(datetime_format), ) + username = self.get_user_name(filepath) + if username: + lines += ( + "User:", + username, + ) self._details_input.appendHtml("
".join(lines)) def get_workfile_data(self): diff --git a/openpype/version.py b/openpype/version.py index dd23138dee..3a218f3a06 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.9" +__version__ = "3.15.11-nightly.4" diff --git a/pyproject.toml b/pyproject.toml index 633899d3a0..56c130982c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.15.9" # OpenPype +version = "3.15.10" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py index 88e0095e34..aace8cf7e3 100644 --- a/tests/unit/openpype/pipeline/publish/test_publish_plugins.py +++ b/tests/unit/openpype/pipeline/publish/test_publish_plugins.py @@ -26,7 +26,7 @@ log = logging.getLogger(__name__) class TestPipelinePublishPlugins(TestPipeline): - """ Testing Pipeline pubish_plugins.py + """ Testing Pipeline publish_plugins.py Example: cd to OpenPype repo root dir @@ -37,7 +37,7 @@ class TestPipelinePublishPlugins(TestPipeline): # files are the same as those used in `test_pipeline_colorspace` TEST_FILES = [ ( - "1d7t9_cVKeZRVF0ppCHiE5MJTTtTlJgBe", + "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", "test_pipeline_colorspace.zip", "" ) @@ -140,7 +140,7 @@ class TestPipelinePublishPlugins(TestPipeline): config_data, file_rules = plugin.get_colorspace_settings(context) assert config_data["template"] == expected_config_template, ( - "Returned config tempate is not " + "Returned config template is not " f"matching {expected_config_template}" ) assert file_rules == expected_file_rules, ( @@ -193,11 +193,11 @@ class TestPipelinePublishPlugins(TestPipeline): colorspace_data_hiero = representation_hiero.get("colorspaceData") assert colorspace_data_nuke, ( - "Colorspace data were not created in prepresentation" + "Colorspace data were not created in representation" f"matching {representation_nuke}" ) assert colorspace_data_hiero, ( - "Colorspace data were not created in prepresentation" + "Colorspace data were not created in representation" f"matching {representation_hiero}" ) diff --git a/tests/unit/openpype/pipeline/test_colorspace.py b/tests/unit/openpype/pipeline/test_colorspace.py index d064ca2be4..c22acee2d4 100644 --- a/tests/unit/openpype/pipeline/test_colorspace.py +++ b/tests/unit/openpype/pipeline/test_colorspace.py @@ -31,7 +31,7 @@ class TestPipelineColorspace(TestPipeline): TEST_FILES = [ ( - "1d7t9_cVKeZRVF0ppCHiE5MJTTtTlJgBe", + "1Lf-mFxev7xiwZCWfImlRcw7Fj8XgNQMh", "test_pipeline_colorspace.zip", "" ) @@ -120,7 +120,7 @@ class TestPipelineColorspace(TestPipeline): ) assert config_data["template"] == expected_template, ( f"Config template \'{config_data['template']}\' doesn't match " - f"expected tempalte \'{expected_template}\'" + f"expected template \'{expected_template}\'" ) def test_parse_colorspace_from_filepath( diff --git a/tools/build.sh b/tools/build.sh index 753a9c55b8..e828cc149e 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -196,6 +196,8 @@ if [ "$disable_submodule_update" == 1 ]; then echo -e "${BIGreen}>>>${RST} Fixing libs ..." mv "$openpype_root/build/OpenPype $openpype_version.app/Contents/MacOS/dependencies/cx_Freeze" "$openpype_root/build/OpenPype $openpype_version.app/Contents/MacOS/lib/" || { echo -e "${BIRed}!!!>${RST} ${BIYellow}Can't move cx_Freeze libs${RST}"; return 1; } + # force hide icon from Dock + defaults write "$openpype_root/build/OpenPype $openpype_version.app/Contents/Info" LSUIElement 1 # fix code signing issue echo -e "${BIGreen}>>>${RST} Fixing code signatures ...\c" diff --git a/website/docs/artist_hosts_houdini.md b/website/docs/artist_hosts_houdini.md index 8874a0b5cf..0471765365 100644 --- a/website/docs/artist_hosts_houdini.md +++ b/website/docs/artist_hosts_houdini.md @@ -14,7 +14,7 @@ sidebar_label: Houdini - [Library Loader](artist_tools_library-loader) ## Publishing Alembic Cameras -You can publish baked camera in Alembic format. +You can publish baked camera in Alembic format. Select your camera and go **OpenPype -> Create** and select **Camera (abc)**. This will create Alembic ROP in **out** with path and frame range already set. This node will have a name you've @@ -30,7 +30,7 @@ You can use any COP node and publish the image sequence generated from it. For e ![Noise COP](assets/houdini_imagesequence_cop.png) To publish the output of the `radialblur1` go to **OpenPype -> Create** and -select **Composite (Image Sequence)**. If you name the variant *Noise* this will create the `/out/imagesequenceNoise` Composite ROP with the frame range set. +select **Composite (Image Sequence)**. If you name the variant *Noise* this will create the `/out/imagesequenceNoise` Composite ROP with the frame range set. When you hit **Publish** it will render image sequence from selected node. @@ -56,14 +56,14 @@ Now select the `output0` node and go **OpenPype -> Create** and select **Point C Alembic ROP `/out/pointcacheStrange` ## Publishing Reviews (OpenGL) -To generate a review output from Houdini you need to create a **review** instance. +To generate a review output from Houdini you need to create a **review** instance. Go to **OpenPype -> Create** and select **Review**. ![Houdini Create Review](assets/houdini_review_create_attrs.png) -On create, with the **Use Selection** checkbox enabled it will set up the first -camera found in your selection as the camera for the OpenGL ROP node and other -non-cameras are set in **Force Objects**. It will then render those even if +On create, with the **Use Selection** checkbox enabled it will set up the first +camera found in your selection as the camera for the OpenGL ROP node and other +non-cameras are set in **Force Objects**. It will then render those even if their display flag is disabled in your scene. ## Redshift @@ -71,6 +71,18 @@ their display flag is disabled in your scene. This part of documentation is still work in progress. ::: +## Publishing Render to Deadline +Five Renderers(Arnold, Redshift, Mantra, Karma, VRay) are supported for Render Publishing. +They are named with the suffix("_ROP") +To submit render to deadline, you need to create a **Render** instance. +Go to **Openpype -> Create** and select **Publish**. Before clicking **Create** button, +you need select your preferred image rendering format. You can also enable the **Use selection** to +select your render camera. +![Houdini Create Render](assets/houdini_render_publish_creator.png) + +All the render outputs are stored in the pyblish/render directory within your project path.\ +For Karma-specific render, it also outputs the USD render as default. + ## USD (experimental support) ### Publishing USD You can publish your Solaris Stage as USD file. diff --git a/website/docs/assets/houdini_render_publish_creator.png b/website/docs/assets/houdini_render_publish_creator.png new file mode 100644 index 0000000000..5dd73d296a Binary files /dev/null and b/website/docs/assets/houdini_render_publish_creator.png differ diff --git a/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png b/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png index 80e00702e6..76dd9b372a 100644 Binary files a/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png and b/website/docs/project_settings/assets/global_extract_review_letter_box_settings.png differ diff --git a/website/docs/project_settings/settings_project_global.md b/website/docs/project_settings/settings_project_global.md index 7bd24a5773..5ddf247d98 100644 --- a/website/docs/project_settings/settings_project_global.md +++ b/website/docs/project_settings/settings_project_global.md @@ -170,12 +170,10 @@ A profile may generate multiple outputs from a single input. Each output must de - **`Letter Box`** - **Enabled** - Enable letter boxes - - **Ratio** - Ratio of letter boxes - - **Type** - **Letterbox** (horizontal bars) or **Pillarbox** (vertical bars) + - **Ratio** - Ratio of letter boxes. Ratio type is calculated from output image dimensions. If letterbox ratio > image ratio, _letterbox_ is applied. Otherwise _pillarbox_ will be rendered. - **Fill color** - Fill color of boxes (RGBA: 0-255) - **Line Thickness** - Line thickness on the edge of box (set to `0` to turn off) - - **Fill color** - Line color on the edge of box (RGBA: 0-255) - - **Example** + - **Line color** - Line color on the edge of box (RGBA: 0-255) ![global_extract_review_letter_box_settings](assets/global_extract_review_letter_box_settings.png) ![global_extract_review_letter_box](assets/global_extract_review_letter_box.png)