diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 73505368dd..5d4db81a77 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,10 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.6-nightly.1 + - 3.17.5 + - 3.17.5-nightly.3 + - 3.17.5-nightly.2 - 3.17.5-nightly.1 - 3.17.4 - 3.17.4-nightly.2 @@ -131,10 +135,6 @@ body: - 3.15.1 - 3.15.1-nightly.6 - 3.15.1-nightly.5 - - 3.15.1-nightly.4 - - 3.15.1-nightly.3 - - 3.15.1-nightly.2 - - 3.15.1-nightly.1 validations: required: true - type: dropdown diff --git a/CHANGELOG.md b/CHANGELOG.md index 7432b33e24..b3daf581ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,463 @@ # Changelog +## [3.17.5](https://github.com/ynput/OpenPype/tree/3.17.5) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.4...3.17.5) + +### **🆕 New features** + + +
+Fusion: Add USD loader #4896 + +Add an OpenPype managed USD loader (`uLoader`) for Fusion. + + +___ + +
+ + +
+Fusion: Resolution validator #5325 + +Added a resolution validator.The code is from my old PR (https://github.com/ynput/OpenPype/pull/4921) that I closed because the PR also contained a frame range validator that no longer is needed. + + +___ + +
+ + +
+Context Selection tool: Refactor Context tool (for AYON) #5766 + +Context selection tool has AYON variant. + + +___ + +
+ + +
+AYON: Use AYON username for user in template data #5842 + +Use ayon username for template data in AYON mode. + + +___ + +
+ + +
+Testing: app_group flag #5869 + +`app_group` command flag. This is for changing which flavour of the host to launch. In the case of Maya, you can launch Maya and MayaPy, but it can be used for the Nuke family as well.Split from #5644 + + +___ + +
+ +### **🚀 Enhancements** + + +
+Enhancement: Fusion fix saver creation + minor Blender/Fusion logging tweaks #5558 + +- Blender change logs to `debug` level in preparation for new publisher artist facing reports (note that it currently still uses the old publisher) +- Fusion: Create Saver fix redeclaration of default_variants +- Fusion: Fix saver being created in incorrect state without saving directly after create +- Fusion: Allow reset frame range on render family +- Fusion: Tweak logging level for artist-facing report + + +___ + +
+ + +
+Resolve: load clip to timeline at set time #5665 + +It is possible to load clip to correct place on timeline. + + +___ + +
+ + +
+Nuke: Optional Deadline workfile dependency. #5732 + +Adds option to add the workfile as dependency for the Deadline job.Think it used to have something like this, but it disappeared. Usecase is for remote workflow where the Nuke script needs to be synced before the job can start. + + +___ + +
+ + +
+Enhancement/houdini rearrange ayon houdini settings files #5748 + +Rearranging Houdini Settings to be more readable, easier to edit, update settings (include all families/product types)This PR is mainly for Ayon Settings to have more organized files. For Openpype, I'll make sure that each Houdini setting in Ayon has an equivalent in Openpype. +- [x] update Ayon settings, fix typos and remove deprecated settings. +- [x] Sync with Openpype +- [x] Test in Openpype +- [x] Test in Ayon + + +___ + +
+ + +
+Chore: updating create ayon addon script #5822 + +Adding developers environment options. + + +___ + +
+ + +
+Max: Implement Validator for Properties/Attributes Value Check #5824 + +Add optional validator which can check if the property attributes are valid in Max + + +___ + +
+ + +
+Nuke: Remove unused 'get_render_path' function #5826 + +Remove unused function `get_render_path` from nuke integration. + + +___ + +
+ + +
+Chore: Limit current context template data function #5845 + +Current implementation of `get_current_context_template_data` does return the same values as base template data function `get_template_data`. + + +___ + +
+ + +
+Max: Make sure Collect Render not ignoring instance asset #5847 + +- Make sure Collect Render is not always using asset from context. +- Make sure Scene version being collected +- Clean up unnecessary uses of code in the collector. + + +___ + +
+ + +
+Ftrack: Events are not processed if project is not available in OpenPype #5853 + +Events that happened on project which is not in OpenPype is not processed. + + +___ + +
+ + +
+Nuke: Add Nuke 11.0 as default setting #5855 + +Found I needed Nuke 11.0 in the default settings to help with unit testing. + + +___ + +
+ + +
+TVPaint: Code cleanup #5857 + +Removed unused import. Use `AYON` label in ayon mode. Removed unused data in publish context `"previous_context"`. + + +___ + +
+ + +
+AYON settings: Use correct label for follow workfile version #5874 + +Follow workfile version label was marked as Collect Anatomy Instance Data label. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Nuke: Fix workfile template builder so representations get loaded next to each other #5061 + +Refactor when the cleanup of the placeholder happens for the cases where multiple representations are loaded by a single placeholder.The existing code didn't take into account the case where a template placeholder can load multiple representations so it was trying to do the cleanup of the placeholder node and the re-arrangement of the imported nodes too early. I assume this was designed only for the cases where a single representation can load multiple nodes. + + +___ + +
+ + +
+Nuke: Dont update node name on update #5704 + +When updating `Image` containers the code is trying to set the name of the node. This results in a warning message from Nuke shown below;Suggesting to not change the node name when updating. + + +___ + +
+ + +
+UIDefLabel can be unique #5827 + +`UILabelDef` have implemented comparison and uniqueness. + + +___ + +
+ + +
+AYON: Skip kitsu module when creating ayon addons #5828 + +Create AYON packages is skipping kitsu module in creation of modules/addons and kitsu module is not loaded from modules on start. The addon already has it's repository https://github.com/ynput/ayon-kitsu. + + +___ + +
+ + +
+Bugfix: Collect Rendered Files only collecting first instance #5832 + +Collect all instances from the metadata file - don't return on first instance iteration. + + +___ + +
+ + +
+Houdini: set frame range for the created composite ROP #5833 + +Quick bug fix for created composite ROP, set its frame range to the frame range of the playbar. + + +___ + +
+ + +
+Fix registering launcher actions from OpenPypeModules #5843 + +Fix typo `actions_dir` -> `path` to fix register launcher actions fromm OpenPypeModule + + +___ + +
+ + +
+Bugfix in houdini shelves manager and beautify settings #5844 + +This PR fixes the problem in this PR https://github.com/ynput/OpenPype/issues/5457 by using the right function to load a pre-made houdini `.shelf` fileAlso, it beautifies houdini shelves settings to provide better guidance for users which helps with other issue https://github.com/ynput/OpenPype/issues/5458 , Rather adding default shelf and set names, I'll educate users how to use the tool correctly.Users now are able to select between the two options.| OpenPype | Ayon || -- | -- || | | + + +___ + +
+ + +
+Blender: Fix missing Grease Pencils in review #5848 + +Fix Grease Pencil missing in review when isolating objects. + + +___ + +
+ + +
+Blender: Fix Render Settings in Ayon #5849 + +Fix Render Settings in Ayon for Blender. + + +___ + +
+ + +
+Bugfix: houdini tab menu working as expected #5850 + +This PR:Tab menu name changes to Ayon when using ayon get_network_categories is checked in all creator plugins. | Product | Network Category | | -- | -- | | Alembic camera | rop, obj | | Arnold Ass | rop | | Arnold ROP | rop | | Bgeo | rop, sop | | composite sequence | cop2, rop | | hda | obj | | Karma ROP | rop | | Mantra ROP | rop | | ABC | rop, sop | | RS proxy | rop, sop| | RS ROP | rop | | Review | rop | | Static mesh | rop, obj, sop | | USD | lop, rop | | USD Render | rop | | VDB | rop, obj, sop | | V Ray | rop | + + +___ + +
+ + +
+Bigfix: Houdini skip frame_range_validator if node has no 'trange' parameter #5851 + +I faced a bug when publishing HDA instance as it has no `trange` parameter. As this PR title says : skip frame_range_validator if node has no 'trange' parameter + + +___ + +
+ + +
+Bugfix: houdini image sequence loading and missing frames #5852 + +I made this PR in to fix issues mentioned here https://github.com/ynput/OpenPype/pull/5833#issuecomment-1789207727in short: +- image load doesn't work +- publisher only publish one frame + + +___ + +
+ + +
+Nuke: loaders' containers updating as nodes #5854 + +Nuke loaded containers are updating correctly even they have been duplicating of originally loaded nodes. This had previously been removed duplicated nodes. + + +___ + +
+ + +
+deadline: settings are not blocking extension input #5864 + +Settings are not blocking user input. + + +___ + +
+ + +
+Blender: Fix loading of blend layouts #5866 + +Fix a problem with loading blend layouts. + + +___ + +
+ + +
+AYON: Launcher refresh issues #5867 + +Fixed refresh of projects issue in launcher tool. And renamed Qt models to contain `Qt` in their name (it was really hard to find out where were used). It is not possible to click on disabled item in launcher's projects view. + + +___ + +
+ + +
+Fix the Wrong key words for tycache workfile template settings in AYON #5870 + +Fix the wrong key words for the tycache workfile template settings in AYON(i.e. Instead of families, product_types should be used) + + +___ + +
+ + +
+AYON tools: Handle empty icon definition #5876 + +Ignore if passed icon definition is `None`. + + +___ + +
+ +### **🔀 Refactored code** + + +
+Houdini: Remove on instance toggled callback #5860 + +Remove on instance toggled callback which isn't relevant to the new publisher + + +___ + +
+ + +
+Chore: Remove unused `instanceToggled` callbacks #5862 + +The `instanceToggled` callbacks should be irrelevant for new publisher. + + +___ + +
+ + + + ## [3.17.4](https://github.com/ynput/OpenPype/tree/3.17.4) diff --git a/openpype/cli.py b/openpype/cli.py index 7422f32f13..f0fe550a1f 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -282,6 +282,9 @@ def run(script): "--app_variant", help="Provide specific app variant for test, empty for latest", default=None) +@click.option("--app_group", + help="Provide specific app group for test, empty for default", + default=None) @click.option("-t", "--timeout", help="Provide specific timeout value for test case", @@ -294,11 +297,11 @@ def run(script): help="MongoDB for testing.", default=None) def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, - timeout, setup_only, mongo_url): + timeout, setup_only, mongo_url, app_group): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, - mongo_url) + mongo_url, app_group) @main.command(help="DEPRECATED - run sync server") diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 8fc7a70dd8..e059f7c272 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -74,11 +74,6 @@ class AfterEffectsHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", application_launch) @@ -186,11 +181,6 @@ def application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active AfterEffects document. diff --git a/openpype/hosts/blender/plugins/load/load_blend.py b/openpype/hosts/blender/plugins/load/load_blend.py index 0719c5c97d..f7bbc630de 100644 --- a/openpype/hosts/blender/plugins/load/load_blend.py +++ b/openpype/hosts/blender/plugins/load/load_blend.py @@ -32,7 +32,7 @@ class BlendLoader(plugin.AssetLoader): empties = [obj for obj in objects if obj.type == 'EMPTY'] for empty in empties: - if empty.get(AVALON_PROPERTY): + if empty.get(AVALON_PROPERTY) and empty.parent is None: return empty return None @@ -90,6 +90,7 @@ class BlendLoader(plugin.AssetLoader): members.append(data) container = self._get_asset_container(data_to.objects) + print(container) assert container, "No asset group found" container.name = group_name @@ -100,8 +101,11 @@ class BlendLoader(plugin.AssetLoader): # Link all the container children to the collection for obj in container.children_recursive: + print(obj) bpy.context.scene.collection.objects.link(obj) + print("") + # Remove the library from the blend file library = bpy.data.libraries.get(bpy.path.basename(libpath)) bpy.data.libraries.remove(library) diff --git a/openpype/hosts/blender/plugins/publish/collect_review.py b/openpype/hosts/blender/plugins/publish/collect_review.py index 3bf2e39e24..2760ab9811 100644 --- a/openpype/hosts/blender/plugins/publish/collect_review.py +++ b/openpype/hosts/blender/plugins/publish/collect_review.py @@ -31,11 +31,12 @@ class CollectReview(pyblish.api.InstancePlugin): focal_length = cameras[0].data.lens - # get isolate objects list from meshes instance members . + # get isolate objects list from meshes instance members. + types = {"MESH", "GPENCIL"} isolate_objects = [ obj for obj in instance - if isinstance(obj, bpy.types.Object) and obj.type == "MESH" + if isinstance(obj, bpy.types.Object) and obj.type in types ] if not instance.data.get("remove"): diff --git a/openpype/hosts/blender/plugins/publish/extract_blend.py b/openpype/hosts/blender/plugins/publish/extract_blend.py index 4b6d9e7c69..f29dae7f69 100644 --- a/openpype/hosts/blender/plugins/publish/extract_blend.py +++ b/openpype/hosts/blender/plugins/publish/extract_blend.py @@ -27,17 +27,23 @@ class ExtractBlend(publish.Extractor): for data in instance: data_blocks.add(data) - if isinstance(data, bpy.types.Object) and data.type == 'MESH': - # Pack used images in the blend files. - for material_slot in data.material_slots: - mat = material_slot.material - if mat and mat.use_nodes: - tree = mat.node_tree - if tree.type == 'SHADER': - for node in tree.nodes: - if node.bl_idname == 'ShaderNodeTexImage': - if node.image: - node.image.pack() + # Pack used images in the blend files. + if not(isinstance(data, bpy.types.Object) and data.type == 'MESH'): + continue + for material_slot in data.material_slots: + mat = material_slot.material + if not(mat and mat.use_nodes): + continue + tree = mat.node_tree + if tree.type != 'SHADER': + continue + for node in tree.nodes: + if node.bl_idname != 'ShaderNodeTexImage': + continue + # Check if image is not packed already + # and pack it if not. + if node.image and node.image.packed_file is None: + node.image.pack() bpy.data.libraries.write(filepath, data_blocks) diff --git a/openpype/hosts/fusion/plugins/load/load_usd.py b/openpype/hosts/fusion/plugins/load/load_usd.py new file mode 100644 index 0000000000..4f1813a646 --- /dev/null +++ b/openpype/hosts/fusion/plugins/load/load_usd.py @@ -0,0 +1,87 @@ +from openpype.pipeline import ( + load, + get_representation_path, +) +from openpype.hosts.fusion.api import ( + imprint_container, + get_current_comp, + comp_lock_and_undo_chunk +) +from openpype.hosts.fusion.api.lib import get_fusion_module + + +class FusionLoadUSD(load.LoaderPlugin): + """Load USD into Fusion + + Support for USD was added since Fusion 18.5 + """ + + families = ["*"] + representations = ["*"] + extensions = {"usd", "usda", "usdz"} + + label = "Load USD" + order = -10 + icon = "code-fork" + color = "orange" + + tool_type = "uLoader" + + @classmethod + def apply_settings(cls, project_settings, system_settings): + super(FusionLoadUSD, cls).apply_settings(project_settings, + system_settings) + if cls.enabled: + # Enable only in Fusion 18.5+ + fusion = get_fusion_module() + version = fusion.GetVersion() + major = version[1] + minor = version[2] + is_usd_supported = (major, minor) >= (18, 5) + cls.enabled = is_usd_supported + + def load(self, context, name, namespace, data): + # Fallback to asset name when namespace is None + if namespace is None: + namespace = context['asset']['name'] + + # Create the Loader with the filename path set + comp = get_current_comp() + with comp_lock_and_undo_chunk(comp, "Create tool"): + + path = self.fname + + args = (-32768, -32768) + tool = comp.AddTool(self.tool_type, *args) + tool["Filename"] = path + + imprint_container(tool, + name=name, + namespace=namespace, + context=context, + loader=self.__class__.__name__) + + def switch(self, container, representation): + self.update(container, representation) + + def update(self, container, representation): + + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + path = get_representation_path(representation) + + with comp_lock_and_undo_chunk(comp, "Update tool"): + tool["Filename"] = path + + # Update the imprinted representation + tool.SetData("avalon.representation", str(representation["_id"])) + + def remove(self, container): + tool = container["_tool"] + assert tool.ID == self.tool_type, f"Must be {self.tool_type}" + comp = tool.Comp() + + with comp_lock_and_undo_chunk(comp, "Remove tool"): + tool.Delete() diff --git a/openpype/hosts/houdini/api/creator_node_shelves.py b/openpype/hosts/houdini/api/creator_node_shelves.py index 1f9fef7417..14662dc419 100644 --- a/openpype/hosts/houdini/api/creator_node_shelves.py +++ b/openpype/hosts/houdini/api/creator_node_shelves.py @@ -173,6 +173,7 @@ def install(): os.remove(filepath) icon = get_openpype_icon_filepath() + tab_menu_label = os.environ.get("AVALON_LABEL") or "AYON" # Create context only to get creator plugins, so we don't reset and only # populate what we need to retrieve the list of creator plugins @@ -197,14 +198,14 @@ def install(): if not network_categories: continue - key = "openpype_create.{}".format(identifier) + key = "ayon_create.{}".format(identifier) log.debug(f"Registering {key}") script = CREATE_SCRIPT.format(identifier=identifier) data = { "script": script, "language": hou.scriptLanguage.Python, "icon": icon, - "help": "Create OpenPype publish instance for {}".format( + "help": "Create Ayon publish instance for {}".format( creator.label ), "help_url": None, @@ -213,7 +214,7 @@ def install(): "cop_viewer_categories": [], "network_op_type": None, "viewer_op_type": None, - "locations": ["OpenPype"] + "locations": [tab_menu_label] } label = "Create {}".format(creator.label) tool = hou.shelves.tool(key) diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index cadeaa8ed4..8b058e605d 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -11,20 +11,21 @@ import json import six from openpype.lib import StringTemplate -from openpype.client import get_asset_by_name +from openpype.client import get_project, get_asset_by_name from openpype.settings import get_current_project_settings from openpype.pipeline import ( + Anatomy, get_current_project_name, get_current_asset_name, - registered_host -) -from openpype.pipeline.context_tools import ( - get_current_context_template_data, - get_current_project_asset + registered_host, + get_current_context, + get_current_host_name, ) +from openpype.pipeline.create import CreateContext +from openpype.pipeline.template_data import get_template_data +from openpype.pipeline.context_tools import get_current_project_asset from openpype.widgets import popup from openpype.tools.utils.host_tools import get_tool_by_name -from openpype.pipeline.create import CreateContext import hou @@ -568,9 +569,9 @@ def get_template_from_value(key, value): return parm -def get_frame_data(node, handle_start=0, handle_end=0, log=None): - """Get the frame data: start frame, end frame, steps, - start frame with start handle and end frame with end handle. +def get_frame_data(node, log=None): + """Get the frame data: `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. This function uses Houdini node's `trange`, `t1, `t2` and `t3` parameters as the source of truth for the full inclusive frame @@ -578,20 +579,17 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): range including the handles. The non-inclusive frame start and frame end without handles - are computed by subtracting the handles from the inclusive + can be computed by subtracting the handles from the inclusive frame range. Args: node (hou.Node): ROP node to retrieve frame range from, the frame range is assumed to be the frame range *including* the start and end handles. - handle_start (int): Start handles. - handle_end (int): End handles. - log (logging.Logger): Logger to log to. Returns: - dict: frame data for start, end, steps, - start with handle and end with handle + dict: frame data for `frameStartHandle`, `frameEndHandle` + and `byFrameStep`. """ @@ -622,11 +620,6 @@ def get_frame_data(node, handle_start=0, handle_end=0, log=None): data["frameEndHandle"] = int(node.evalParm("f2")) data["byFrameStep"] = node.evalParm("f3") - data["handleStart"] = handle_start - data["handleEnd"] = handle_end - data["frameStart"] = data["frameStartHandle"] + data["handleStart"] - data["frameEnd"] = data["frameEndHandle"] - data["handleEnd"] - return data @@ -804,6 +797,45 @@ def get_camera_from_container(container): return cameras[0] +def get_current_context_template_data_with_asset_data(): + """ + TODOs: + Support both 'assetData' and 'folderData' in future. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + anatomy = Anatomy(project_name) + project_doc = get_project(project_name) + asset_doc = get_asset_by_name(project_name, asset_name) + + # get context specific vars + asset_data = asset_doc["data"] + + # compute `frameStartHandle` and `frameEndHandle` + frame_start = asset_data.get("frameStart") + frame_end = asset_data.get("frameEnd") + handle_start = asset_data.get("handleStart") + handle_end = asset_data.get("handleEnd") + if frame_start is not None and handle_start is not None: + asset_data["frameStartHandle"] = frame_start - handle_start + + if frame_end is not None and handle_end is not None: + asset_data["frameEndHandle"] = frame_end + handle_end + + template_data = get_template_data( + project_doc, asset_doc, task_name, host_name + ) + template_data["root"] = anatomy.roots + template_data["assetData"] = asset_data + + return template_data + + def get_context_var_changes(): """get context var changes.""" @@ -823,7 +855,7 @@ def get_context_var_changes(): return houdini_vars_to_update # Get Template data - template_data = get_current_context_template_data() + template_data = get_current_context_template_data_with_asset_data() # Set Houdini Vars for item in houdini_vars: @@ -978,7 +1010,7 @@ def self_publish(): def add_self_publish_button(node): """Adds a self publish button to the rop node.""" - label = os.environ.get("AVALON_LABEL") or "OpenPype" + label = os.environ.get("AVALON_LABEL") or "AYON" button_parm = hou.ButtonParmTemplate( "ayon_self_publish", diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index f8db45c56b..11135e20b2 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -3,7 +3,6 @@ import os import sys import logging -import contextlib import hou # noqa @@ -66,10 +65,6 @@ class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_event_callback("open", on_open) register_event_callback("new", on_new) - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) - self._has_been_setup = True # add houdini vendor packages hou_pythonpath = os.path.join(HOUDINI_HOST_DIR, "vendor") @@ -406,54 +401,3 @@ def _set_context_settings(): lib.reset_framerange() lib.update_houdini_vars_context() - - -def on_pyblish_instance_toggled(instance, new_value, old_value): - """Toggle saver tool passthrough states on instance toggles.""" - @contextlib.contextmanager - def main_take(no_update=True): - """Enter root take during context""" - original_take = hou.takes.currentTake() - original_update_mode = hou.updateModeSetting() - root = hou.takes.rootTake() - has_changed = False - try: - if original_take != root: - has_changed = True - if no_update: - hou.setUpdateMode(hou.updateMode.Manual) - hou.takes.setCurrentTake(root) - yield - finally: - if has_changed: - if no_update: - hou.setUpdateMode(original_update_mode) - hou.takes.setCurrentTake(original_take) - - if not instance.data.get("_allowToggleBypass", True): - return - - nodes = instance[:] - if not nodes: - return - - # Assume instance node is first node - instance_node = nodes[0] - - if not hasattr(instance_node, "isBypassed"): - # Likely not a node that can actually be bypassed - log.debug("Can't bypass node: %s", instance_node.path()) - return - - if instance_node.isBypassed() != (not old_value): - print("%s old bypass state didn't match old instance state, " - "updating anyway.." % instance_node.path()) - - try: - # Go into the main take, because when in another take changing - # the bypass state of a note cannot be done due to it being locked - # by default. - with main_take(no_update=True): - instance_node.bypass(not new_value) - except hou.PermissionError as exc: - log.warning("%s - %s", instance_node.path(), exc) diff --git a/openpype/hosts/houdini/api/shelves.py b/openpype/hosts/houdini/api/shelves.py index 4b5ebd4202..5093a90988 100644 --- a/openpype/hosts/houdini/api/shelves.py +++ b/openpype/hosts/houdini/api/shelves.py @@ -7,10 +7,11 @@ from openpype.settings import get_project_settings from openpype.pipeline import get_current_project_name from openpype.lib import StringTemplate -from openpype.pipeline.context_tools import get_current_context_template_data import hou +from .lib import get_current_context_template_data_with_asset_data + log = logging.getLogger("openpype.hosts.houdini.shelves") @@ -23,29 +24,33 @@ def generate_shelves(): # load configuration of houdini shelves project_name = get_current_project_name() project_settings = get_project_settings(project_name) - shelves_set_config = project_settings["houdini"]["shelves"] + shelves_configs = project_settings["houdini"]["shelves"] - if not shelves_set_config: + if not shelves_configs: log.debug("No custom shelves found in project settings.") return # Get Template data - template_data = get_current_context_template_data() + template_data = get_current_context_template_data_with_asset_data() + + for config in shelves_configs: + selected_option = config["options"] + shelf_set_config = config[selected_option] - for shelf_set_config in shelves_set_config: shelf_set_filepath = shelf_set_config.get('shelf_set_source_path') - shelf_set_os_filepath = shelf_set_filepath[current_os] - if shelf_set_os_filepath: - shelf_set_os_filepath = get_path_using_template_data( - shelf_set_os_filepath, template_data - ) - if not os.path.isfile(shelf_set_os_filepath): - log.error("Shelf path doesn't exist - " - "{}".format(shelf_set_os_filepath)) - continue + if shelf_set_filepath: + shelf_set_os_filepath = shelf_set_filepath[current_os] + if shelf_set_os_filepath: + shelf_set_os_filepath = get_path_using_template_data( + shelf_set_os_filepath, template_data + ) + if not os.path.isfile(shelf_set_os_filepath): + log.error("Shelf path doesn't exist - " + "{}".format(shelf_set_os_filepath)) + continue - hou.shelves.newShelfSet(file_path=shelf_set_os_filepath) - continue + hou.shelves.loadFile(shelf_set_os_filepath) + continue shelf_set_name = shelf_set_config.get('shelf_set_name') if not shelf_set_name: diff --git a/openpype/hosts/houdini/plugins/create/create_bgeo.py b/openpype/hosts/houdini/plugins/create/create_bgeo.py index a3f31e7e94..0f629cf9c9 100644 --- a/openpype/hosts/houdini/plugins/create/create_bgeo.py +++ b/openpype/hosts/houdini/plugins/create/create_bgeo.py @@ -3,6 +3,7 @@ from openpype.hosts.houdini.api import plugin from openpype.pipeline import CreatedInstance, CreatorError from openpype.lib import EnumDef +import hou class CreateBGEO(plugin.HoudiniCreator): @@ -13,7 +14,6 @@ class CreateBGEO(plugin.HoudiniCreator): icon = "gears" def create(self, subset_name, instance_data, pre_create_data): - import hou instance_data.pop("active", None) @@ -90,3 +90,9 @@ class CreateBGEO(plugin.HoudiniCreator): return attrs + [ EnumDef("bgeo_type", bgeo_enum, label="BGEO Options"), ] + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_composite.py b/openpype/hosts/houdini/plugins/create/create_composite.py index 9d4f7969bb..52ea6fa054 100644 --- a/openpype/hosts/houdini/plugins/create/create_composite.py +++ b/openpype/hosts/houdini/plugins/create/create_composite.py @@ -45,6 +45,11 @@ class CreateCompositeSequence(plugin.HoudiniCreator): instance_node.setParms(parms) + # Manually set f1 & f2 to $FSTART and $FEND respectively + # to match other Houdini nodes default. + instance_node.parm("f1").setExpression("$FSTART") + instance_node.parm("f2").setExpression("$FEND") + # Lock any parameters in this list to_lock = ["prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) diff --git a/openpype/hosts/houdini/plugins/create/create_hda.py b/openpype/hosts/houdini/plugins/create/create_hda.py index c4093bfbc6..ac075d2072 100644 --- a/openpype/hosts/houdini/plugins/create/create_hda.py +++ b/openpype/hosts/houdini/plugins/create/create_hda.py @@ -5,6 +5,7 @@ from openpype.client import ( get_subsets, ) from openpype.hosts.houdini.api import plugin +import hou class CreateHDA(plugin.HoudiniCreator): @@ -35,7 +36,6 @@ class CreateHDA(plugin.HoudiniCreator): def create_instance_node( self, node_name, parent, node_type="geometry"): - import hou parent_node = hou.node("/obj") if self.selected_nodes: @@ -81,3 +81,8 @@ class CreateHDA(plugin.HoudiniCreator): pre_create_data) # type: plugin.CreatedInstance return instance + + def get_network_categories(self): + return [ + hou.objNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py index b814dd9d57..3a4ab7008b 100644 --- a/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/create/create_redshift_proxy.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """Creator plugin for creating Redshift proxies.""" from openpype.hosts.houdini.api import plugin -from openpype.pipeline import CreatedInstance +import hou class CreateRedshiftProxy(plugin.HoudiniCreator): @@ -12,7 +12,7 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): icon = "magic" def create(self, subset_name, instance_data, pre_create_data): - import hou # noqa + # Remove the active, we are checking the bypass flag of the nodes instance_data.pop("active", None) @@ -28,7 +28,7 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): instance = super(CreateRedshiftProxy, self).create( subset_name, instance_data, - pre_create_data) # type: CreatedInstance + pre_create_data) instance_node = hou.node(instance.get("instance_node")) @@ -44,3 +44,9 @@ class CreateRedshiftProxy(plugin.HoudiniCreator): # Lock some Avalon attributes to_lock = ["family", "id", "prim_to_detail_pattern"] self.lock_parameters(instance_node, to_lock) + + def get_network_categories(self): + return [ + hou.ropNodeTypeCategory(), + hou.sopNodeTypeCategory() + ] diff --git a/openpype/hosts/houdini/plugins/create/create_staticmesh.py b/openpype/hosts/houdini/plugins/create/create_staticmesh.py index ea0b36f03f..d0985198bd 100644 --- a/openpype/hosts/houdini/plugins/create/create_staticmesh.py +++ b/openpype/hosts/houdini/plugins/create/create_staticmesh.py @@ -54,6 +54,7 @@ class CreateStaticMesh(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] diff --git a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py index 9c96e48e3a..69418f9575 100644 --- a/openpype/hosts/houdini/plugins/create/create_vbd_cache.py +++ b/openpype/hosts/houdini/plugins/create/create_vbd_cache.py @@ -40,6 +40,7 @@ class CreateVDBCache(plugin.HoudiniCreator): def get_network_categories(self): return [ hou.ropNodeTypeCategory(), + hou.objNodeTypeCategory(), hou.sopNodeTypeCategory() ] diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index 663a93e48b..cff2b74e52 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -119,7 +119,8 @@ class ImageLoader(load.LoaderPlugin): if not parent.children(): parent.destroy() - def _get_file_sequence(self, root): + def _get_file_sequence(self, file_path): + root = os.path.dirname(file_path) files = sorted(os.listdir(root)) first_fname = files[0] diff --git a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py index b489f83b29..d95f763826 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_arnold_rop.py @@ -21,8 +21,8 @@ class CollectArnoldROPRenderProducts(pyblish.api.InstancePlugin): label = "Arnold ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["arnold_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py new file mode 100644 index 0000000000..67a281639d --- /dev/null +++ b/openpype/hosts/houdini/plugins/publish/collect_asset_handles.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +"""Collector plugin for frames data on ROP instances.""" +import hou # noqa +import pyblish.api +from openpype.lib import BoolDef +from openpype.pipeline import OpenPypePyblishPluginMixin + + +class CollectAssetHandles(pyblish.api.InstancePlugin, + OpenPypePyblishPluginMixin): + """Apply asset handles. + + If instance does not have: + - frameStart + - frameEnd + - handleStart + - handleEnd + But it does have: + - frameStartHandle + - frameEndHandle + + Then we will retrieve the asset's handles to compute + the exclusive frame range and actual handle ranges. + """ + + hosts = ["houdini"] + + # This specific order value is used so that + # this plugin runs after CollectAnatomyInstanceData + order = pyblish.api.CollectorOrder + 0.499 + + label = "Collect Asset Handles" + use_asset_handles = True + + def process(self, instance): + # Only process instances without already existing handles data + # but that do have frameStartHandle and frameEndHandle defined + # like the data collected from CollectRopFrameRange + if "frameStartHandle" not in instance.data: + return + if "frameEndHandle" not in instance.data: + return + + has_existing_data = { + "handleStart", + "handleEnd", + "frameStart", + "frameEnd" + }.issubset(instance.data) + if has_existing_data: + return + + attr_values = self.get_attr_values_from_data(instance.data) + if attr_values.get("use_handles", self.use_asset_handles): + asset_data = instance.data["assetEntity"]["data"] + handle_start = asset_data.get("handleStart", 0) + handle_end = asset_data.get("handleEnd", 0) + else: + handle_start = 0 + handle_end = 0 + + frame_start = instance.data["frameStartHandle"] + handle_start + frame_end = instance.data["frameEndHandle"] - handle_end + + instance.data.update({ + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStart": frame_start, + "frameEnd": frame_end + }) + + # Log debug message about the collected frame range + if attr_values.get("use_handles", self.use_asset_handles): + self.log.debug( + "Full Frame range with Handles " + "[{frame_start_handle} - {frame_end_handle}]" + .format( + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] + ) + ) + else: + self.log.debug( + "Use handles is deactivated for this instance, " + "start and end handles are set to 0." + ) + + # Log collected frame range to the user + message = "Frame range [{frame_start} - {frame_end}]".format( + frame_start=frame_start, + frame_end=frame_end + ) + if handle_start or handle_end: + message += " with handles [{handle_start}]-[{handle_end}]".format( + handle_start=handle_start, + handle_end=handle_end + ) + self.log.info(message) + + if instance.data.get("byFrameStep", 1.0) != 1.0: + self.log.info( + "Frame steps {}".format(instance.data["byFrameStep"])) + + # Add frame range to label if the instance has a frame range. + label = instance.data.get("label", instance.data["name"]) + instance.data["label"] = ( + "{label} [{frame_start_handle} - {frame_end_handle}]" + .format( + label=label, + frame_start_handle=instance.data["frameStartHandle"], + frame_end_handle=instance.data["frameEndHandle"] + ) + ) + + @classmethod + def get_attribute_defs(cls): + return [ + BoolDef("use_handles", + tooltip="Disable this if you want the publisher to" + " ignore start and end handles specified in the" + " asset data for this publish instance", + default=cls.use_asset_handles, + label="Use asset handles") + ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 01df809d4c..cdef642174 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -11,7 +11,9 @@ 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 + 0.01 + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange + order = pyblish.api.CollectorOrder + 0.1 label = "Collect Frames" families = ["vdbcache", "imagesequence", "ass", "redshiftproxy", "review", "bgeo"] @@ -20,8 +22,8 @@ class CollectFrames(pyblish.api.InstancePlugin): ropnode = hou.node(instance.data["instance_node"]) - start_frame = instance.data.get("frameStart", None) - end_frame = instance.data.get("frameEnd", None) + start_frame = instance.data.get("frameStartHandle", None) + end_frame = instance.data.get("frameEndHandle", None) output_parm = lib.get_output_parameter(ropnode) if start_frame is not None: diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py index 0600730d00..d154cdc7c0 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances_usd_layered.py @@ -122,10 +122,6 @@ class CollectInstancesUsdLayered(pyblish.api.ContextPlugin): instance.data.update(save_data) instance.data["usdLayer"] = layer - # Don't allow the Pyblish `instanceToggled` we have installed - # to set this node to bypass. - instance.data["_allowToggleBypass"] = False - instances.append(instance) # Store the collected ROP node dependencies diff --git a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py index fe0b8711fc..dac350a6ef 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_karma_rop.py @@ -25,8 +25,8 @@ class CollectKarmaROPRenderProducts(pyblish.api.InstancePlugin): label = "Karma ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["karma_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py index cc412f30a1..a3e7927807 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_mantra_rop.py @@ -25,8 +25,8 @@ class CollectMantraROPRenderProducts(pyblish.api.InstancePlugin): label = "Mantra ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["mantra_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py index deb9eac971..0acddab011 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_redshift_rop.py @@ -25,8 +25,8 @@ class CollectRedshiftROPRenderProducts(pyblish.api.InstancePlugin): label = "Redshift ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["redshift_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/collect_review_data.py b/openpype/hosts/houdini/plugins/publish/collect_review_data.py index 3efb75e66c..9671945b9a 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_review_data.py +++ b/openpype/hosts/houdini/plugins/publish/collect_review_data.py @@ -6,6 +6,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): """Collect Review Data.""" label = "Collect Review Data" + # This specific order value is used so that + # this plugin runs after CollectRopFrameRange order = pyblish.api.CollectorOrder + 0.1 hosts = ["houdini"] families = ["review"] @@ -41,8 +43,8 @@ class CollectHoudiniReviewData(pyblish.api.InstancePlugin): return if focal_length_parm.isTimeDependent(): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + 1 + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + 1 focal_length = [ focal_length_parm.evalAsFloatAtFrame(t) for t in range(int(start), int(end)) diff --git a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py index 186244fedd..1e6bc3b16e 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/collect_rop_frame_range.py @@ -2,22 +2,15 @@ """Collector plugin for frames data on ROP instances.""" import hou # noqa import pyblish.api -from openpype.lib import BoolDef from openpype.hosts.houdini.api import lib -from openpype.pipeline import OpenPypePyblishPluginMixin -class CollectRopFrameRange(pyblish.api.InstancePlugin, - OpenPypePyblishPluginMixin): - +class CollectRopFrameRange(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" hosts = ["houdini"] - # This specific order value is used so that - # this plugin runs after CollectAnatomyInstanceData - order = pyblish.api.CollectorOrder + 0.499 + order = pyblish.api.CollectorOrder label = "Collect RopNode Frame Range" - use_asset_handles = True def process(self, instance): @@ -30,78 +23,16 @@ class CollectRopFrameRange(pyblish.api.InstancePlugin, return ropnode = hou.node(node_path) - - attr_values = self.get_attr_values_from_data(instance.data) - - if attr_values.get("use_handles", self.use_asset_handles): - asset_data = instance.data["assetEntity"]["data"] - handle_start = asset_data.get("handleStart", 0) - handle_end = asset_data.get("handleEnd", 0) - else: - handle_start = 0 - handle_end = 0 - frame_data = lib.get_frame_data( - ropnode, handle_start, handle_end, self.log + ropnode, self.log ) if not frame_data: return # Log debug message about the collected frame range - frame_start = frame_data["frameStart"] - frame_end = frame_data["frameEnd"] - - if attr_values.get("use_handles", self.use_asset_handles): - self.log.debug( - "Full Frame range with Handles " - "[{frame_start_handle} - {frame_end_handle}]" - .format( - frame_start_handle=frame_data["frameStartHandle"], - frame_end_handle=frame_data["frameEndHandle"] - ) - ) - else: - self.log.debug( - "Use handles is deactivated for this instance, " - "start and end handles are set to 0." - ) - - # Log collected frame range to the user - message = "Frame range [{frame_start} - {frame_end}]".format( - frame_start=frame_start, - frame_end=frame_end + self.log.debug( + "Collected frame_data: {}".format(frame_data) ) - if handle_start or handle_end: - message += " with handles [{handle_start}]-[{handle_end}]".format( - handle_start=handle_start, - handle_end=handle_end - ) - self.log.info(message) - - if frame_data.get("byFrameStep", 1.0) != 1.0: - self.log.info("Frame steps {}".format(frame_data["byFrameStep"])) 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"] = ( - "{label} [{frame_start} - {frame_end}]" - .format( - label=label, - frame_start=frame_start, - frame_end=frame_end - ) - ) - - @classmethod - def get_attribute_defs(cls): - return [ - BoolDef("use_handles", - tooltip="Disable this if you want the publisher to" - " ignore start and end handles specified in the" - " asset data for this publish instance", - default=cls.use_asset_handles, - label="Use asset handles") - ] diff --git a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py index 53072aebc6..64de2079cd 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py +++ b/openpype/hosts/houdini/plugins/publish/collect_vray_rop.py @@ -25,8 +25,8 @@ class CollectVrayROPRenderProducts(pyblish.api.InstancePlugin): label = "VRay ROP Render Products" # This specific order value is used so that - # this plugin runs after CollectRopFrameRange - order = pyblish.api.CollectorOrder + 0.4999 + # this plugin runs after CollectFrames + order = pyblish.api.CollectorOrder + 0.11 hosts = ["houdini"] families = ["vray_rop"] diff --git a/openpype/hosts/houdini/plugins/publish/extract_ass.py b/openpype/hosts/houdini/plugins/publish/extract_ass.py index 0d246625ba..be60217055 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_ass.py +++ b/openpype/hosts/houdini/plugins/publish/extract_ass.py @@ -56,7 +56,7 @@ class ExtractAss(publish.Extractor): 'ext': ext, "files": files, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py index c9625ec880..d13141b426 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_bgeo.py +++ b/openpype/hosts/houdini/plugins/publish/extract_bgeo.py @@ -47,7 +47,7 @@ class ExtractBGEO(publish.Extractor): "ext": ext.lstrip("."), "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"] + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"] } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_composite.py b/openpype/hosts/houdini/plugins/publish/extract_composite.py index 7a1ab36b93..11cf83a46d 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_composite.py +++ b/openpype/hosts/houdini/plugins/publish/extract_composite.py @@ -41,8 +41,8 @@ class ExtractComposite(publish.Extractor): "ext": ext, "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } from pprint import pformat diff --git a/openpype/hosts/houdini/plugins/publish/extract_fbx.py b/openpype/hosts/houdini/plugins/publish/extract_fbx.py index 7993b3352f..7dc193c6a9 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_fbx.py +++ b/openpype/hosts/houdini/plugins/publish/extract_fbx.py @@ -40,9 +40,9 @@ class ExtractFBX(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStart" in instance.data and "frameEnd" in instance.data: - representation["frameStart"] = instance.data["frameStart"] - representation["frameEnd"] = instance.data["frameEnd"] + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] # set value type for 'representations' key to list if "representations" not in instance.data: diff --git a/openpype/hosts/houdini/plugins/publish/extract_opengl.py b/openpype/hosts/houdini/plugins/publish/extract_opengl.py index 6c36dec5f5..38808089ac 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_opengl.py +++ b/openpype/hosts/houdini/plugins/publish/extract_opengl.py @@ -39,8 +39,8 @@ class ExtractOpenGL(publish.Extractor): "ext": instance.data["imageFormat"], "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": instance.data.get("review_camera") diff --git a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py index 1d99ac665c..ef5991924f 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/houdini/plugins/publish/extract_redshift_proxy.py @@ -44,8 +44,8 @@ class ExtractRedshiftProxy(publish.Extractor): } # A single frame may also be rendered without start/end frame. - if "frameStart" in instance.data and "frameEnd" in instance.data: - representation["frameStart"] = instance.data["frameStart"] - representation["frameEnd"] = instance.data["frameEnd"] + if "frameStartHandle" in instance.data and "frameEndHandle" in instance.data: # noqa + representation["frameStart"] = instance.data["frameStartHandle"] + representation["frameEnd"] = instance.data["frameEndHandle"] instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py index 4bca758f08..89af8e1756 100644 --- a/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py +++ b/openpype/hosts/houdini/plugins/publish/extract_vdb_cache.py @@ -40,7 +40,7 @@ class ExtractVDBCache(publish.Extractor): "ext": "vdb", "files": output, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], } instance.data["representations"].append(representation) diff --git a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py index 6a66f3de9f..1b12fa7096 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/houdini/plugins/publish/validate_frame_range.py @@ -57,7 +57,17 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): return rop_node = hou.node(instance.data["instance_node"]) - if instance.data["frameStart"] > instance.data["frameEnd"]: + frame_start = instance.data.get("frameStart") + frame_end = instance.data.get("frameEnd") + + if frame_start is None or frame_end is None: + cls.log.debug( + "Skipping frame range validation for " + "instance without frame data: {}".format(rop_node.path()) + ) + return + + if frame_start > frame_end: cls.log.info( "The ROP node render range is set to " "{0[frameStartHandle]} - {0[frameEndHandle]} " @@ -89,7 +99,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin): .format(instance)) return - created_instance.publish_attributes["CollectRopFrameRange"]["use_handles"] = False # noqa + created_instance.publish_attributes["CollectAssetHandles"]["use_handles"] = False # noqa create_context.save_changes() cls.log.debug("use asset handles is turned off for '{}'" diff --git a/openpype/hosts/houdini/startup/MainMenuCommon.xml b/openpype/hosts/houdini/startup/MainMenuCommon.xml index b2e32a70f9..0903aef7bc 100644 --- a/openpype/hosts/houdini/startup/MainMenuCommon.xml +++ b/openpype/hosts/houdini/startup/MainMenuCommon.xml @@ -4,7 +4,7 @@ - + - + - + 1 - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] if is_sequence: representation = self._representation_with_hash_in_frame( @@ -299,9 +297,6 @@ class LoadClip(plugin.NukeLoader): "Representation id `{}` is failing to load".format(repre_id)) return - read_name = self._get_node_name(representation) - - read_node["name"].setValue(read_name) read_node["file"].setValue(filepath) # to avoid multiple undo steps for rest of process @@ -356,7 +351,7 @@ class LoadClip(plugin.NukeLoader): self.set_as_member(read_node) def remove(self, container): - read_node = nuke.toNode(container['objectName']) + read_node = container["node"] assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index cacc00854e..cc048372d4 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -62,11 +62,12 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -159,7 +160,7 @@ class LoadEffects(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -175,12 +176,13 @@ class LoadEffects(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -212,7 +214,7 @@ class LoadEffects(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -346,6 +348,6 @@ class LoadEffects(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index bdf3cd6965..cdfdfef3b8 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -63,11 +63,12 @@ class LoadEffectsInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -98,7 +99,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -164,28 +165,26 @@ class LoadEffectsInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - GN = nuke.toNode(container['objectName']) + GN = container["node"] file = get_representation_path(representation).replace("\\", "/") - name = container['name'] version_data = version_doc.get("data", {}) vname = version_doc.get("name", None) first = version_data.get("frameStart", None) last = version_data.get("frameEnd", None) workfile_first_frame = int(nuke.root()["first_frame"].getValue()) - namespace = container['namespace'] colorspace = version_data.get("colorspace", None) - object_name = "{}_{}".format(name, namespace) add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace, + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -217,7 +216,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): pre_node = nuke.createNode("Input") pre_node["name"].setValue("rgb") - for ef_name, ef_val in nodes_order.items(): + for _, ef_val in nodes_order.items(): node = nuke.createNode(ef_val["class"]) for k, v in ef_val["node"].items(): if k in self.ignore_attr: @@ -251,11 +250,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): output = nuke.createNode("Output") output.setInput(0, pre_node) - # # try to place it under Viewer1 - # if not self.connect_active_viewer(GN): - # nuke.delete(GN) - # return - # get all versions in list last_version_doc = get_last_version_by_subset_id( project_name, version_doc["parent"], fields=["_id"] @@ -365,6 +359,6 @@ class LoadEffectsInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo.py b/openpype/hosts/nuke/plugins/load/load_gizmo.py index 23cf4d7741..19b5cca74e 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo.py @@ -64,11 +64,12 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -111,7 +112,7 @@ class LoadGizmo(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - group_node = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -126,12 +127,13 @@ class LoadGizmo(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -175,6 +177,6 @@ class LoadGizmo(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index ce0a1615f1..5b4877678a 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -66,11 +66,12 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -118,7 +119,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): version_doc = get_version_by_id(project_name, representation["parent"]) # get corresponding node - group_node = nuke.toNode(container['objectName']) + group_node = container["node"] file = get_representation_path(representation).replace("\\", "/") name = container['name'] @@ -133,12 +134,13 @@ class LoadGizmoInputProcess(load.LoaderPlugin): add_keys = ["frameStart", "frameEnd", "handleStart", "handleEnd", "source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "colorspaceInput": colorspace, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname, + "colorspaceInput": colorspace + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -256,6 +258,6 @@ class LoadGizmoInputProcess(load.LoaderPlugin): self.update(container, representation) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 6bffb97e6f..411a61d77b 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -146,8 +146,6 @@ class LoadImage(load.LoaderPlugin): data_imprint.update( {k: context["version"]['data'].get(k, str(None))}) - data_imprint.update({"objectName": read_name}) - r["tile_color"].setValue(int("0x4ecd25ff", 16)) return containerise(r, @@ -168,7 +166,7 @@ class LoadImage(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container["objectName"]) + node = container["node"] frame_number = node["first"].value() assert node.Class() == "Read", "Must be Read" @@ -237,7 +235,7 @@ class LoadImage(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] assert node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): diff --git a/openpype/hosts/nuke/plugins/load/load_model.py b/openpype/hosts/nuke/plugins/load/load_model.py index b9b8a0f4c0..3fe92b74d0 100644 --- a/openpype/hosts/nuke/plugins/load/load_model.py +++ b/openpype/hosts/nuke/plugins/load/load_model.py @@ -46,10 +46,11 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -114,9 +115,9 @@ class AlembicModelLoader(load.LoaderPlugin): # Get version from io project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + # get corresponding node - model_node = nuke.toNode(object_name) + model_node = container["node"] # get main variables version_data = version_doc.get("data", {}) @@ -129,11 +130,12 @@ class AlembicModelLoader(load.LoaderPlugin): # add additional metadata from the version to imprint to Avalon knob add_keys = ["source", "author", "fps"] - data_imprint = {"representation": str(representation["_id"]), - "frameStart": first, - "frameEnd": last, - "version": vname, - "objectName": object_name} + data_imprint = { + "representation": str(representation["_id"]), + "frameStart": first, + "frameEnd": last, + "version": vname + } for k in add_keys: data_imprint.update({k: version_data[k]}) @@ -142,7 +144,6 @@ class AlembicModelLoader(load.LoaderPlugin): file = get_representation_path(representation).replace("\\", "/") with maintained_selection(): - model_node = nuke.toNode(object_name) model_node['selected'].setValue(True) # collect input output dependencies @@ -163,8 +164,10 @@ class AlembicModelLoader(load.LoaderPlugin): ypos = model_node.ypos() nuke.nodeCopy("%clipboard%") nuke.delete(model_node) + + # paste the node back and set the position nuke.nodePaste("%clipboard%") - model_node = nuke.toNode(object_name) + model_node = nuke.selectedNode() model_node.setXYpos(xpos, ypos) # link to original input nodes diff --git a/openpype/hosts/nuke/plugins/load/load_ociolook.py b/openpype/hosts/nuke/plugins/load/load_ociolook.py index 18c8cdba35..c0f8235253 100644 --- a/openpype/hosts/nuke/plugins/load/load_ociolook.py +++ b/openpype/hosts/nuke/plugins/load/load_ociolook.py @@ -55,7 +55,7 @@ class LoadOcioLookNodes(load.LoaderPlugin): """ namespace = namespace or context['asset']['name'] suffix = secrets.token_hex(nbytes=4) - object_name = "{}_{}_{}".format( + node_name = "{}_{}_{}".format( name, namespace, suffix) # getting file path @@ -64,7 +64,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): json_f = self._load_json_data(filepath) group_node = self._create_group_node( - object_name, filepath, json_f["data"]) + filepath, json_f["data"]) + # renaming group node + group_node["name"].setValue(node_name) self._node_version_color(context["version"], group_node) @@ -76,17 +78,14 @@ class LoadOcioLookNodes(load.LoaderPlugin): name=name, namespace=namespace, context=context, - loader=self.__class__.__name__, - data={ - "objectName": object_name, - } + loader=self.__class__.__name__ ) def _create_group_node( self, - object_name, filepath, - data + data, + group_node=None ): """Creates group node with all the nodes inside. @@ -94,9 +93,9 @@ class LoadOcioLookNodes(load.LoaderPlugin): in between - in case those are needed. Arguments: - object_name (str): name of the group node filepath (str): path to json file data (dict): data from json file + group_node (Optional[nuke.Node]): group node or None Returns: nuke.Node: group node with all the nodes inside @@ -117,7 +116,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): input_node = None output_node = None - group_node = nuke.toNode(object_name) if group_node: # remove all nodes between Input and Output nodes for node in group_node.nodes(): @@ -130,7 +128,6 @@ class LoadOcioLookNodes(load.LoaderPlugin): else: group_node = nuke.createNode( "Group", - "name {}_1".format(object_name), inpanel=False ) @@ -227,16 +224,16 @@ class LoadOcioLookNodes(load.LoaderPlugin): project_name = get_current_project_name() version_doc = get_version_by_id(project_name, representation["parent"]) - object_name = container['objectName'] + group_node = container["node"] filepath = get_representation_path(representation) json_f = self._load_json_data(filepath) group_node = self._create_group_node( - object_name, filepath, - json_f["data"] + json_f["data"], + group_node ) self._node_version_color(version_doc, group_node) diff --git a/openpype/hosts/nuke/plugins/load/load_script_precomp.py b/openpype/hosts/nuke/plugins/load/load_script_precomp.py index d5f9d24765..cbe19d217b 100644 --- a/openpype/hosts/nuke/plugins/load/load_script_precomp.py +++ b/openpype/hosts/nuke/plugins/load/load_script_precomp.py @@ -46,8 +46,6 @@ class LinkAsGroup(load.LoaderPlugin): file = self.filepath_from_context(context).replace("\\", "/") self.log.info("file: {}\n".format(file)) - precomp_name = context["representation"]["context"]["subset"] - self.log.info("versionData: {}\n".format(context["version"]["data"])) # add additional metadata from the version to imprint to Avalon knob @@ -62,7 +60,6 @@ class LinkAsGroup(load.LoaderPlugin): } for k in add_keys: data_imprint.update({k: context["version"]['data'][k]}) - data_imprint.update({"objectName": precomp_name}) # group context is set to precomp, so back up one level. nuke.endGroup() @@ -118,7 +115,7 @@ class LinkAsGroup(load.LoaderPlugin): inputs: """ - node = nuke.toNode(container['objectName']) + node = container["node"] root = get_representation_path(representation).replace("\\", "/") @@ -159,6 +156,6 @@ class LinkAsGroup(load.LoaderPlugin): self.log.info("updated to version: {}".format(version_doc.get("name"))) def remove(self, container): - node = nuke.toNode(container['objectName']) + node = container["node"] with viewer_update_and_undo_stop(): nuke.delete(node) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 56ae2a4c25..4e0dbcad06 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -48,11 +48,6 @@ class PhotoshopHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) register_creator_plugin_path(CREATE_PATH) - log.info(PUBLISH_PATH) - - pyblish.api.register_callback( - "instanceToggled", on_pyblish_instance_toggled - ) register_event_callback("application.launched", on_application_launch) @@ -177,11 +172,6 @@ def on_application_launch(): check_inventory() -def on_pyblish_instance_toggled(instance, old_value, new_value): - """Toggle layer visibility on instance toggles.""" - instance[0].Visible = new_value - - def ls(): """Yields containers from active Photoshop document diff --git a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt similarity index 89% rename from openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt rename to openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt index 98597a12cb..7d1d6edf61 100644 --- a/openpype/hosts/resolve/RESOLVE_API_v18.0.4.txt +++ b/openpype/hosts/resolve/RESOLVE_API_v18.5.1-build6.txt @@ -1,4 +1,4 @@ -Updated as of 9 May 2022 +Updated as of 26 May 2023 ---------------------------- In this package, you will find a brief introduction to the Scripting API for DaVinci Resolve Studio. Apart from this README.txt file, this package contains folders containing the basic import modules for scripting access (DaVinciResolve.py) and some representative examples. @@ -19,7 +19,7 @@ DaVinci Resolve scripting requires one of the following to be installed (for all Lua 5.1 Python 2.7 64-bit - Python 3.6 64-bit + Python >= 3.6 64-bit Using a script @@ -171,6 +171,10 @@ Project GetRenderResolutions(format, codec) --> [{Resolution}] # Returns list of resolutions applicable for the given render format (string) and render codec (string). Returns full list of resolutions if no argument is provided. Each element in the list is a dictionary with 2 keys "Width" and "Height". RefreshLUTList() --> Bool # Refreshes LUT List GetUniqueId() --> string # Returns a unique ID for the project item + InsertAudioToCurrentTrackAtPlayhead(mediaPath, --> Bool # Inserts the media specified by mediaPath (string) with startOffsetInSamples (int) and durationInSamples (int) at the playhead on a selected track on the Fairlight page. Returns True if successful, otherwise False. + startOffsetInSamples, durationInSamples) + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for project when supplied presetName (string). Returns true if successful. + ExportCurrentFrameAsStill(filePath) --> Bool # Exports current frame as still to supplied filePath. filePath must end in valid export file format. Returns True if succssful, False otherwise. MediaStorage GetMountedVolumeList() --> [paths...] # Returns list of folder paths corresponding to mounted volumes displayed in Resolve’s Media Storage. @@ -179,6 +183,7 @@ MediaStorage RevealInStorage(path) --> Bool # Expands and displays given file/folder path in Resolve’s Media Storage. AddItemListToMediaPool(item1, item2, ...) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is one or more file/folder paths. Returns a list of the MediaPoolItems created. AddItemListToMediaPool([items...]) --> [clips...] # Adds specified file/folder paths from Media Storage into current Media Pool folder. Input is an array of file/folder paths. Returns a list of the MediaPoolItems created. + AddItemListToMediaPool([{itemInfo}, ...]) --> [clips...] # Adds list of itemInfos specified as dict of "media", "startFrame" (int), "endFrame" (int) from Media Storage into current Media Pool folder. Returns a list of the MediaPoolItems created. AddClipMattesToMediaPool(MediaPoolItem, [paths], stereoEye) --> Bool # Adds specified media files as mattes for the specified MediaPoolItem. StereoEye is an optional argument for specifying which eye to add the matte to for stereo clips ("left" or "right"). Returns True if successful. AddTimelineMattesToMediaPool([paths]) --> [MediaPoolItems] # Adds specified media files as timeline mattes in current media pool folder. Returns a list of created MediaPoolItems. @@ -189,20 +194,22 @@ MediaPool CreateEmptyTimeline(name) --> Timeline # Adds new timeline with given name. AppendToTimeline(clip1, clip2, ...) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. AppendToTimeline([clips]) --> [TimelineItem] # Appends specified MediaPoolItem objects in the current timeline. Returns the list of appended timelineItems. - AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only). Returns the list of appended timelineItems. + AppendToTimeline([{clipInfo}, ...]) --> [TimelineItem] # Appends list of clipInfos specified as dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), (optional) "mediaType" (int; 1 - Video only, 2 - Audio only), "trackIndex" (int) and "recordFrame" (int). Returns the list of appended timelineItems. CreateTimelineFromClips(name, clip1, clip2,...) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. CreateTimelineFromClips(name, [clips]) --> Timeline # Creates new timeline with specified name, and appends the specified MediaPoolItem objects. - CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int). - ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file and optional importOptions dict, with support for the keys: - # "timelineName": string, specifies the name of the timeline to be created - # "importSourceClips": Bool, specifies whether source clips should be imported, True by default + CreateTimelineFromClips(name, [{clipInfo}]) --> Timeline # Creates new timeline with specified name, appending the list of clipInfos specified as a dict of "mediaPoolItem", "startFrame" (int), "endFrame" (int), "recordFrame" (int). + ImportTimelineFromFile(filePath, {importOptions}) --> Timeline # Creates timeline based on parameters within given file (AAF/EDL/XML/FCPXML/DRT/ADL) and optional importOptions dict, with support for the keys: + # "timelineName": string, specifies the name of the timeline to be created. Not valid for DRT import + # "importSourceClips": Bool, specifies whether source clips should be imported, True by default. Not valid for DRT import # "sourceClipsPath": string, specifies a filesystem path to search for source clips if the media is inaccessible in their original path and if "importSourceClips" is True - # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False + # "sourceClipsFolders": List of Media Pool folder objects to search for source clips if the media is not present in current folder and if "importSourceClips" is False. Not valid for DRT import # "interlaceProcessing": Bool, specifies whether to enable interlace processing on the imported timeline being created. valid only for AAF import DeleteTimelines([timeline]) --> Bool # Deletes specified timelines in the media pool. GetCurrentFolder() --> Folder # Returns currently selected Folder. SetCurrentFolder(Folder) --> Bool # Sets current folder by given Folder. DeleteClips([clips]) --> Bool # Deletes specified clips or timeline mattes in the media pool + ImportFolderFromFile(filePath, sourceClipsPath="") --> Bool # Returns true if import from given DRB filePath is successful, false otherwise + # sourceClipsPath is a string that specifies a filesystem path to search for source clips if the media is inaccessible in their original path, empty by default DeleteFolders([subfolders]) --> Bool # Deletes specified subfolders in the media pool MoveClips([clips], targetFolder) --> Bool # Moves specified clips to target folder. MoveFolders([folders], targetFolder) --> Bool # Moves specified folders to target folder. @@ -225,6 +232,7 @@ Folder GetSubFolderList() --> [folders...] # Returns a list of subfolders in the folder. GetIsFolderStale() --> bool # Returns true if folder is stale in collaboration mode, false otherwise GetUniqueId() --> string # Returns a unique ID for the media pool folder + Export(filePath) --> bool # Returns true if export of DRB folder to filePath is successful, false otherwise MediaPoolItem GetName() --> string # Returns the clip name. @@ -257,6 +265,8 @@ MediaPoolItem UnlinkProxyMedia() --> Bool # Unlinks any proxy media associated with clip. ReplaceClip(filePath) --> Bool # Replaces the underlying asset and metadata of MediaPoolItem with the specified absolute clip path. GetUniqueId() --> string # Returns a unique ID for the media pool item + TranscribeAudio() --> Bool # Transcribes audio of the MediaPoolItem. Returns True if successful; False otherwise + ClearTranscription() --> Bool # Clears audio transcription of the MediaPoolItem. Returns True if successful; False otherwise. Timeline GetName() --> string # Returns the timeline name. @@ -266,6 +276,23 @@ Timeline SetStartTimecode(timecode) --> Bool # Set the start timecode of the timeline to the string 'timecode'. Returns true when the change is successful, false otherwise. GetStartTimecode() --> string # Returns the start timecode for the timeline. GetTrackCount(trackType) --> int # Returns the number of tracks for the given track type ("audio", "video" or "subtitle"). + AddTrack(trackType, optionalSubTrackType) --> Bool # Adds track of trackType ("video", "subtitle", "audio"). Second argument optionalSubTrackType is required for "audio" + # optionalSubTrackType can be one of {"mono", "stereo", "5.1", "5.1film", "7.1", "7.1film", "adaptive1", ... , "adaptive24"} + DeleteTrack(trackType, trackIndex) --> Bool # Deletes track of trackType ("video", "subtitle", "audio") and given trackIndex. 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackEnable(trackType, trackIndex, Bool) --> Bool # Enables/Disables track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackEnabled(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is enabled and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + SetTrackLock(trackType, trackIndex, Bool) --> Bool # Locks/Unlocks track with given trackType and trackIndex + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + GetIsTrackLocked(trackType, trackIndex) --> Bool # Returns True if track with given trackType and trackIndex is locked and False otherwise. + # trackType is one of {"audio", "video", "subtitle"} + # 1 <= trackIndex <= GetTrackCount(trackType). + DeleteClips([timelineItems], Bool) --> Bool # Deletes specified TimelineItems from the timeline, performing ripple delete if the second argument is True. Second argument is optional (The default for this is False) + SetClipsLinked([timelineItems], Bool) --> Bool # Links or unlinks the specified TimelineItems depending on second argument. GetItemListInTrack(trackType, index) --> [items...] # Returns a list of timeline items on that track (based on trackType and index). 1 <= index <= GetTrackCount(trackType). AddMarker(frameId, color, name, note, duration, --> Bool # Creates a new marker at given frameId position and with given marker information. 'customData' is optional and helps to attach user specific data to the marker. customData) @@ -301,7 +328,7 @@ Timeline # "sourceClipsFolders": string, list of Media Pool folder objects to search for source clips if the media is not present in current folder Export(fileName, exportType, exportSubtype) --> Bool # Exports timeline to 'fileName' as per input exportType & exportSubtype format. - # Refer to section "Looking up timeline exports properties" for information on the parameters. + # Refer to section "Looking up timeline export properties" for information on the parameters. GetSetting(settingName) --> string # Returns value of timeline setting (indicated by settingName : string). Check the section below for more information. SetSetting(settingName, settingValue) --> Bool # Sets timeline setting (indicated by settingName : string) to the value (settingValue : string). Check the section below for more information. InsertGeneratorIntoTimeline(generatorName) --> TimelineItem # Inserts a generator (indicated by generatorName : string) into the timeline. @@ -313,6 +340,8 @@ Timeline GrabStill() --> galleryStill # Grabs still from the current video clip. Returns a GalleryStill object. GrabAllStills(stillFrameSource) --> [galleryStill] # Grabs stills from all the clips of the timeline at 'stillFrameSource' (1 - First frame, 2 - Middle frame). Returns the list of GalleryStill objects. GetUniqueId() --> string # Returns a unique ID for the timeline + CreateSubtitlesFromAudio() --> Bool # Creates subtitles from audio for the timeline. Returns True on success, False otherwise. + DetectSceneCuts() --> Bool # Detects and makes scene cuts along the timeline. Returns True if successful, False otherwise. TimelineItem GetName() --> string # Returns the item name. @@ -362,6 +391,7 @@ TimelineItem GetStereoLeftFloatingWindowParams() --> {keyframes...} # For the LEFT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetStereoRightFloatingWindowParams() --> {keyframes...} # For the RIGHT eye -> returns a dict (offset -> dict) of keyframe offsets and respective floating window params. Value at particular offset includes the left, right, top and bottom floating window values. GetNumNodes() --> int # Returns the number of nodes in the current graph for the timeline item + ApplyArriCdlLut() --> Bool # Applies ARRI CDL and LUT. Returns True if successful, False otherwise. SetLUT(nodeIndex, lutPath) --> Bool # Sets LUT on the node mapping the node index provided, 1 <= nodeIndex <= total number of nodes. # The lutPath can be an absolute path, or a relative path (based off custom LUT paths or the master LUT path). # The operation is successful for valid lut paths that Resolve has already discovered (see Project.RefreshLUTList). @@ -376,8 +406,16 @@ TimelineItem SelectTakeByIndex(idx) --> Bool # Selects a take by index, 1 <= idx <= number of takes. FinalizeTake() --> Bool # Finalizes take selection. CopyGrades([tgtTimelineItems]) --> Bool # Copies the current grade to all the items in tgtTimelineItems list. Returns True on success and False if any error occurred. + SetClipEnabled(Bool) --> Bool # Sets clip enabled based on argument. + GetClipEnabled() --> Bool # Gets clip enabled status. UpdateSidecar() --> Bool # Updates sidecar file for BRAW clips or RMD file for R3D clips. GetUniqueId() --> string # Returns a unique ID for the timeline item + LoadBurnInPreset(presetName) --> Bool # Loads user defined data burn in preset for clip when supplied presetName (string). Returns true if successful. + GetNodeLabel(nodeIndex) --> string # Returns the label of the node at nodeIndex. + CreateMagicMask(mode) --> Bool # Returns True if magic mask was created successfully, False otherwise. mode can "F" (forward), "B" (backward), or "BI" (bidirection) + RegenerateMagicMask() --> Bool # Returns True if magic mask was regenerated successfully, False otherwise. + Stabilize() --> Bool # Returns True if stabilization was successful, False otherwise + SmartReframe() --> Bool # Performs Smart Reframe. Returns True if successful, False otherwise. Gallery GetAlbumName(galleryStillAlbum) --> string # Returns the name of the GalleryStillAlbum object 'galleryStillAlbum'. @@ -422,9 +460,11 @@ Invoke "Project:SetSetting", "Timeline:SetSetting" or "MediaPoolItem:SetClipProp ensure the success of the operation. You can troubleshoot the validity of keys and values by setting the desired result from the UI and checking property snapshots before and after the change. The following Project properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 0 and 3 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"superScale" - the property value is an enumerated integer between 0 and 4 with these meanings: 0=Auto, 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = Project:GetSetting('superScale') and Project:SetSetting('superScale', x) +• for '2x Enhanced' --> Project:SetSetting('superScale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] "timelineFrameRate" - the property value is one of the frame rates available to the user in project settings under "Timeline frame rate" option. Drop Frame can be configured for supported frame rates by appending the frame rate with "DF", e.g. "29.97 DF" will enable drop frame and "29.97" will disable drop frame @@ -432,9 +472,11 @@ Affects: • x = Project:GetSetting('timelineFrameRate') and Project:SetSetting('timelineFrameRate', x) The following Clip properties have specifically enumerated values: -"superScale" - the property value is an enumerated integer between 1 and 3 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. +"Super Scale" - the property value is an enumerated integer between 1 and 4 with these meanings: 1=no scaling, and 2, 3 and 4 represent the Super Scale multipliers 2x, 3x and 4x. + for super scale multiplier '2x Enhanced', exactly 4 arguments must be passed as outlined below. If less than 4 arguments are passed, it will default to 2x. Affects: • x = MediaPoolItem:GetClipProperty('Super Scale') and MediaPoolItem:SetClipProperty('Super Scale', x) +• for '2x Enhanced' --> MediaPoolItem:SetClipProperty('Super Scale', 2, sharpnessValue, noiseReductionValue), where sharpnessValue is a float in the range [0.0, 1.0] and noiseReductionValue is a float in the range [0.0, 1.0] Looking up Render Settings @@ -478,11 +520,6 @@ exportType can be one of the following constants: - resolve.EXPORT_DRT - resolve.EXPORT_EDL - resolve.EXPORT_FCP_7_XML - - resolve.EXPORT_FCPXML_1_3 - - resolve.EXPORT_FCPXML_1_4 - - resolve.EXPORT_FCPXML_1_5 - - resolve.EXPORT_FCPXML_1_6 - - resolve.EXPORT_FCPXML_1_7 - resolve.EXPORT_FCPXML_1_8 - resolve.EXPORT_FCPXML_1_9 - resolve.EXPORT_FCPXML_1_10 @@ -492,6 +529,8 @@ exportType can be one of the following constants: - resolve.EXPORT_TEXT_TAB - resolve.EXPORT_DOLBY_VISION_VER_2_9 - resolve.EXPORT_DOLBY_VISION_VER_4_0 + - resolve.EXPORT_DOLBY_VISION_VER_5_1 + - resolve.EXPORT_OTIO exportSubtype can be one of the following enums: - resolve.EXPORT_NONE - resolve.EXPORT_AAF_NEW @@ -504,6 +543,16 @@ When exportType is resolve.EXPORT_AAF, valid exportSubtype values are resolve.EX When exportType is resolve.EXPORT_EDL, valid exportSubtype values are resolve.EXPORT_CDL, resolve.EXPORT_SDL, resolve.EXPORT_MISSING_CLIPS and resolve.EXPORT_NONE. Note: Replace 'resolve.' when using the constants above, if a different Resolve class instance name is used. +Unsupported exportType types +--------------------------------- +Starting with DaVinci Resolve 18.1, the following export types are not supported: + - resolve.EXPORT_FCPXML_1_3 + - resolve.EXPORT_FCPXML_1_4 + - resolve.EXPORT_FCPXML_1_5 + - resolve.EXPORT_FCPXML_1_6 + - resolve.EXPORT_FCPXML_1_7 + + Looking up Timeline item properties ----------------------------------- This section covers additional notes for the function "TimelineItem:SetProperty" and "TimelineItem:GetProperty". These functions are used to get and set properties mentioned. diff --git a/openpype/hosts/resolve/api/lib.py b/openpype/hosts/resolve/api/lib.py index 37410c9727..aef9caca78 100644 --- a/openpype/hosts/resolve/api/lib.py +++ b/openpype/hosts/resolve/api/lib.py @@ -6,7 +6,10 @@ import contextlib from opentimelineio import opentime from openpype.lib import Logger -from openpype.pipeline.editorial import is_overlapping_otio_ranges +from openpype.pipeline.editorial import ( + is_overlapping_otio_ranges, + frames_to_timecode +) from ..otio import davinci_export as otio_export @@ -246,18 +249,22 @@ def get_media_pool_item(filepath, root: object = None) -> object: return None -def create_timeline_item(media_pool_item: object, - timeline: object = None, - source_start: int = None, - source_end: int = None) -> object: +def create_timeline_item( + media_pool_item: object, + timeline: object = None, + timeline_in: int = None, + source_start: int = None, + source_end: int = None, +) -> object: """ Add media pool item to current or defined timeline. Args: media_pool_item (resolve.MediaPoolItem): resolve's object - timeline (resolve.Timeline)[optional]: resolve's object - source_start (int)[optional]: media source input frame (sequence frame) - source_end (int)[optional]: media source output frame (sequence frame) + timeline (Optional[resolve.Timeline]): resolve's object + timeline_in (Optional[int]): timeline input frame (sequence frame) + source_start (Optional[int]): media source input frame (sequence frame) + source_end (Optional[int]): media source output frame (sequence frame) Returns: object: resolve.TimelineItem @@ -269,16 +276,29 @@ def create_timeline_item(media_pool_item: object, clip_name = _clip_property("File Name") timeline = timeline or get_current_timeline() + # timing variables + if all([timeline_in, source_start, source_end]): + fps = timeline.GetSetting("timelineFrameRate") + duration = source_end - source_start + timecode_in = frames_to_timecode(timeline_in, fps) + timecode_out = frames_to_timecode(timeline_in + duration, fps) + else: + timecode_in = None + timecode_out = None + # if timeline was used then switch it to current timeline with maintain_current_timeline(timeline): # Add input mediaPoolItem to clip data - clip_data = {"mediaPoolItem": media_pool_item} + clip_data = { + "mediaPoolItem": media_pool_item, + } - # add source time range if input was given - if source_start is not None: - clip_data.update({"startFrame": source_start}) - if source_end is not None: - clip_data.update({"endFrame": source_end}) + if source_start: + clip_data["startFrame"] = source_start + if source_end: + clip_data["endFrame"] = source_end + if timecode_in: + clip_data["recordFrame"] = timecode_in # add to timeline media_pool.AppendToTimeline([clip_data]) @@ -286,10 +306,15 @@ def create_timeline_item(media_pool_item: object, output_timeline_item = get_timeline_item( media_pool_item, timeline) - assert output_timeline_item, AssertionError( - "Track Item with name `{}` doesn't exist on the timeline: `{}`".format( - clip_name, timeline.GetName() - )) + assert output_timeline_item, AssertionError(( + "Clip name '{}' was't created on the timeline: '{}' \n\n" + "Please check if correct track position is activated, \n" + "or if a clip is not already at the timeline in \n" + "position: '{}' out: '{}'. \n\n" + "Clip data: {}" + ).format( + clip_name, timeline.GetName(), timecode_in, timecode_out, clip_data + )) return output_timeline_item @@ -490,7 +515,7 @@ def imprint(timeline_item, data=None): Arguments: timeline_item (hiero.core.TrackItem): hiero track item object - data (dict): Any data which needst to be imprinted + data (dict): Any data which needs to be imprinted Examples: data = { diff --git a/openpype/hosts/resolve/api/plugin.py b/openpype/hosts/resolve/api/plugin.py index 8381f81acb..5c4a92df89 100644 --- a/openpype/hosts/resolve/api/plugin.py +++ b/openpype/hosts/resolve/api/plugin.py @@ -306,11 +306,18 @@ class ClipLoader: self.active_project = lib.get_current_project() # try to get value from options or evaluate key value for `handles` - self.with_handles = options.get("handles") or bool( - options.get("handles") is True) + self.with_handles = options.get("handles") is True + # try to get value from options or evaluate key value for `load_to` - self.new_timeline = options.get("newTimeline") or bool( - "New timeline" in options.get("load_to", "")) + self.new_timeline = ( + options.get("newTimeline") or + options.get("load_to") == "New timeline" + ) + # try to get value from options or evaluate key value for `load_how` + self.sequential_load = ( + options.get("sequentially") or + options.get("load_how") == "Sequentially in order" + ) assert self._populate_data(), str( "Cannot Load selected data, look into database " @@ -391,30 +398,70 @@ class ClipLoader: # create project bin for the media to be imported into self.active_bin = lib.create_bin(self.data["binPath"]) - handle_start = self.data["versionData"].get("handleStart") or 0 - handle_end = self.data["versionData"].get("handleEnd") or 0 - + # create mediaItem in active project bin + # create clip media media_pool_item = lib.create_media_pool_item( files, self.active_bin ) _clip_property = media_pool_item.GetClipProperty + # get handles + handle_start = self.data["versionData"].get("handleStart") + handle_end = self.data["versionData"].get("handleEnd") + if handle_start is None: + handle_start = int(self.data["assetData"]["handleStart"]) + if handle_end is None: + handle_end = int(self.data["assetData"]["handleEnd"]) + + # check frame duration from versionData or assetData + frame_start = self.data["versionData"].get("frameStart") + if frame_start is None: + frame_start = self.data["assetData"]["frameStart"] + + # check frame duration from versionData or assetData + frame_end = self.data["versionData"].get("frameEnd") + if frame_end is None: + frame_end = self.data["assetData"]["frameEnd"] + + db_frame_duration = int(frame_end) - int(frame_start) + 1 + + # get timeline in + timeline_start = self.active_timeline.GetStartFrame() + if self.sequential_load: + # set timeline start frame + timeline_in = int(timeline_start) + else: + # set timeline start frame + original clip in frame + timeline_in = int( + timeline_start + self.data["assetData"]["clipIn"]) + source_in = int(_clip_property("Start")) source_out = int(_clip_property("End")) + source_duration = int(_clip_property("Frames")) - if _clip_property("Type") == "Video": + # check if source duration is shorter than db frame duration + source_with_handles = True + if source_duration < db_frame_duration: + source_with_handles = False + + # only exclude handles if source has no handles or + # if user wants to load without handles + if ( + not self.with_handles + or not source_with_handles + ): source_in += handle_start source_out -= handle_end - # include handles - if self.with_handles: - source_in -= handle_start - source_out += handle_end - # make track item from source in bin as item timeline_item = lib.create_timeline_item( - media_pool_item, self.active_timeline, source_in, source_out) + media_pool_item, + self.active_timeline, + timeline_in, + source_in, + source_out, + ) print("Loading clips: `{}`".format(self.data["clip_name"])) return timeline_item @@ -455,7 +502,7 @@ class TimelineItemLoader(LoaderPlugin): """ options = [ - qargparse.Toggle( + qargparse.Boolean( "handles", label="Include handles", default=0, @@ -470,6 +517,16 @@ class TimelineItemLoader(LoaderPlugin): ], default=0, help="Where do you want clips to be loaded?" + ), + qargparse.Choice( + "load_how", + label="How to load clips", + items=[ + "Original timing", + "Sequentially in order" + ], + default="Original timing", + help="Would you like to place it at original timing?" ) ] diff --git a/openpype/hosts/tvpaint/api/communication_server.py b/openpype/hosts/tvpaint/api/communication_server.py index d67ef8f798..2c4d8160a6 100644 --- a/openpype/hosts/tvpaint/api/communication_server.py +++ b/openpype/hosts/tvpaint/api/communication_server.py @@ -21,6 +21,7 @@ from aiohttp_json_rpc.protocol import ( ) from aiohttp_json_rpc.exceptions import RpcError +from openpype import AYON_SERVER_ENABLED from openpype.lib import emit_event from openpype.hosts.tvpaint.tvpaint_plugin import get_plugin_files_path @@ -834,8 +835,12 @@ class BaseCommunicator: class QtCommunicator(BaseCommunicator): + label = os.getenv("AVALON_LABEL") + if not label: + label = "AYON" if AYON_SERVER_ENABLED else "OpenPype" + title = "{} Tools".format(label) menu_definitions = { - "title": "OpenPype Tools", + "title": title, "menu_items": [ { "callback": "workfiles_tool", diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 58fbd09545..c125da1533 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -7,7 +7,7 @@ import requests import pyblish.api -from openpype.client import get_project, get_asset_by_name +from openpype.client import get_asset_by_name from openpype.host import HostBase, IWorkfileHost, ILoadHost, IPublishHost from openpype.hosts.tvpaint import TVPAINT_ROOT_DIR from openpype.settings import get_current_project_settings @@ -84,10 +84,6 @@ class TVPaintHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost): register_loader_plugin_path(load_dir) register_creator_plugin_path(create_dir) - registered_callbacks = ( - pyblish.api.registered_callbacks().get("instanceToggled") or [] - ) - register_event_callback("application.launched", self.initial_launch) register_event_callback("application.exit", self.application_exit) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py index 95a5cd77bd..56b51c812a 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py @@ -69,7 +69,6 @@ class CollectWorkfileData(pyblish.api.ContextPlugin): "asset_name": context.data["asset"], "task_name": context.data["task"] } - context.data["previous_context"] = current_context self.log.debug("Current context is: {}".format(current_context)) # Collect context from workfile metadata diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py index dae6e074af..9b780fd88a 100644 --- a/openpype/lib/local_settings.py +++ b/openpype/lib/local_settings.py @@ -611,6 +611,12 @@ def get_openpype_username(): settings and last option is to use `getpass.getuser()` which returns machine username. """ + + if AYON_SERVER_ENABLED: + import ayon_api + + return ayon_api.get_user()["name"] + username = os.environ.get("OPENPYPE_USERNAME") if not username: local_settings = get_local_settings() diff --git a/openpype/modules/base.py b/openpype/modules/base.py index e8b85d0e93..457e29905d 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -106,7 +106,7 @@ class _ModuleClass(object): if attr_name in self.__attributes__: self.log.warning( "Duplicated name \"{}\" in {}. Overriding.".format( - self.name, attr_name + attr_name, self.name ) ) self.__attributes__[attr_name] = value @@ -408,6 +408,10 @@ def _load_ayon_addons(openpype_modules, modules_key, log): addon_name = addon_info["name"] addon_version = addon_info["version"] + # OpenPype addon does not have any addon object + if addon_name == "openpype": + continue + dev_addon_info = dev_addons_info.get(addon_name, {}) use_dev_path = dev_addon_info.get("enabled", False) @@ -438,7 +442,7 @@ def _load_ayon_addons(openpype_modules, modules_key, log): # Ignore of files is implemented to be able to run code from code # where usually is more files than just the addon # Ignore start and setup scripts - if name in ("setup.py", "start.py"): + if name in ("setup.py", "start.py", "__pycache__"): continue path = os.path.join(addon_dir, name) @@ -454,7 +458,15 @@ def _load_ayon_addons(openpype_modules, modules_key, log): try: mod = __import__(basename, fromlist=("",)) - imported_modules.append(mod) + for attr_name in dir(mod): + attr = getattr(mod, attr_name) + if ( + inspect.isclass(attr) + and issubclass(attr, OpenPypeModule) + ): + imported_modules.append(mod) + break + except BaseException: log.warning( "Failed to import \"{}\"".format(basename), @@ -467,19 +479,26 @@ def _load_ayon_addons(openpype_modules, modules_key, log): )) continue - if len(imported_modules) == 1: - mod = imported_modules[0] - addon_alias = getattr(mod, "V3_ALIAS", None) - if not addon_alias: - addon_alias = addon_name - v3_addons_to_skip.append(addon_alias) - new_import_str = "{}.{}".format(modules_key, addon_alias) + if len(imported_modules) > 1: + log.warning(( + "Skipping addon '{}'." + " Multiple modules were found ({}) in dir {}." + ).format( + addon_name, + ", ".join([m.__name__ for m in imported_modules]), + addon_dir, + )) + continue - sys.modules[new_import_str] = mod - setattr(openpype_modules, addon_alias, mod) + mod = imported_modules[0] + addon_alias = getattr(mod, "V3_ALIAS", None) + if not addon_alias: + addon_alias = addon_name + v3_addons_to_skip.append(addon_alias) + new_import_str = "{}.{}".format(modules_key, addon_alias) - else: - log.info("More then one module was imported") + sys.modules[new_import_str] = mod + setattr(openpype_modules, addon_alias, mod) return v3_addons_to_skip @@ -997,7 +1016,18 @@ class ModulesManager: continue method = getattr(module, method_name) - paths = method(*args, **kwargs) + try: + paths = method(*args, **kwargs) + except Exception: + self.log.warning( + ( + "Failed to get plugin paths from module" + " '{}' using '{}'." + ).format(module.__class__.__name__, method_name), + exc_info=True + ) + continue + if paths: # Convert to list if value is not list if not isinstance(paths, (list, tuple, set)): 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 9b4f89c129..1d3dad769f 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,8 +5,6 @@ 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 from openpype.pipeline.publish import KnownPublishError @@ -44,7 +42,8 @@ class CollectDeadlineServerFromInstance(pyblish.api.InstancePlugin): str: Selected Deadline Webservice URL. """ - + # Not all hosts can import this module. + from maya import cmds deadline_settings = ( render_instance.context.data ["system_settings"] diff --git a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py index 4a7497b075..094f2b1821 100644 --- a/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_blender_deadline.py @@ -6,8 +6,6 @@ import getpass import attr from datetime import datetime -import bpy - from openpype.lib import is_running_from_build from openpype.pipeline import legacy_io from openpype.pipeline.farm.tools import iter_expected_files @@ -142,6 +140,9 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return job_info def get_plugin_info(self): + # Not all hosts can import this module. + import bpy + plugin_info = BlenderPluginInfo( SceneFile=self.scene_path, Version=bpy.app.version_string, diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py index 39c0c3afe4..0bee42c4cb 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py @@ -3,7 +3,6 @@ import json from datetime import datetime import requests -import hou import pyblish.api @@ -31,6 +30,8 @@ class HoudiniSubmitPublishDeadline(pyblish.api.ContextPlugin): targets = ["deadline"] def process(self, context): + # Not all hosts can import this module. + import hou # Ensure no errors so far assert all( 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 6f885c578a..abc650204b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -1,9 +1,8 @@ -import hou - import os import attr import getpass from datetime import datetime + import pyblish.api from openpype.pipeline import legacy_io @@ -119,6 +118,8 @@ class HoudiniSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): return job_info def get_plugin_info(self): + # Not all hosts can import this module. + import hou instance = self._instance context = instance.context diff --git a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py index 073da3019a..23d4183132 100644 --- a/openpype/modules/deadline/plugins/publish/submit_max_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_max_deadline.py @@ -1,8 +1,8 @@ import os import getpass import copy - import attr + from openpype.lib import ( TextDef, BoolDef, @@ -15,11 +15,6 @@ from openpype.pipeline import ( from openpype.pipeline.publish.lib import ( replace_with_published_scene_path ) -from openpype.hosts.max.api.lib import ( - get_current_renderer, - get_multipass_setting -) -from openpype.hosts.max.api.lib_rendersettings import RenderSettings 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 @@ -191,6 +186,13 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, self.submit(self.assemble_payload(job_info, plugin_info)) def _use_published_name(self, data, project_settings): + # Not all hosts can import these modules. + from openpype.hosts.max.api.lib import ( + get_current_renderer, + get_multipass_setting + ) + from openpype.hosts.max.api.lib_rendersettings import RenderSettings + instance = self._instance job_info = copy.deepcopy(self.job_info) plugin_info = copy.deepcopy(self.plugin_info) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 7775191b12..7d532923ff 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -28,8 +28,6 @@ from collections import OrderedDict import attr -from maya import cmds - from openpype.pipeline import ( legacy_io, OpenPypePyblishPluginMixin @@ -246,6 +244,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return job_info def get_plugin_info(self): + # Not all hosts can import this module. + from maya import cmds instance = self._instance context = instance.context @@ -288,7 +288,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, return plugin_payload def process_submission(self): - + from maya import cmds instance = self._instance filepath = self.scene_path # publish if `use_publish` else workfile @@ -675,7 +675,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline, str """ - + from maya import cmds # "vrayscene//_/" vray_settings = cmds.ls(type="VRaySettingsNode") node = vray_settings[0] diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py index 0d23f44333..41a2a64ab5 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_remote_publish_deadline.py @@ -2,8 +2,6 @@ import os import attr from datetime import datetime -from maya import cmds - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io, PublishXmlValidationError from openpype.tests.lib import is_in_tests @@ -127,7 +125,8 @@ class MayaSubmitRemotePublishDeadline( job_info.EnvironmentKeyValue[key] = value def get_plugin_info(self): - + # Not all hosts can import this module. + from maya import cmds scene = self._instance.context.data["currentFile"] plugin_info = MayaPluginInfo() diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 0e57c54959..fb3ab2710d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -7,8 +7,6 @@ from datetime import datetime import requests import pyblish.api -import nuke - from openpype import AYON_SERVER_ENABLED from openpype.pipeline import legacy_io from openpype.pipeline.publish import ( @@ -498,6 +496,9 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, Returning: list: captured groups list """ + # Not all hosts can import this module. + import nuke + captured_groups = [] for lg_name, list_node_class in self.limit_groups.items(): for node_class in list_node_class: diff --git a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py index 8ef333effd..2ac02f233e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_first_version_status.py @@ -1,3 +1,6 @@ +import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -73,8 +76,21 @@ class FirstVersionStatus(BaseEvent): if not self.task_status_map: return - entities_info = self.filter_event_ents(event) - if not entities_info: + filtered_entities_info = self.filter_entities_info(event) + if not filtered_entities_info: + return + + for project_id, entities_info in filtered_entities_info.items(): + self.process_by_project(session, event, project_id, entities_info) + + def process_by_project(self, session, event, project_id, entities_info): + project_name = self.get_project_name_from_event( + session, event, project_id + ) + if get_project(project_name) is None: + self.log.debug( + f"Project '{project_name}' not found in OpenPype. Skipping" + ) return entity_ids = [] @@ -154,18 +170,18 @@ class FirstVersionStatus(BaseEvent): exc_info=True ) - def filter_event_ents(self, event): - filtered_ents = [] - for entity in event["data"].get("entities", []): + def filter_entities_info(self, event): + filtered_entities_info = collections.defaultdict(list) + for entity_info in event["data"].get("entities", []): # Care only about add actions - if entity.get("action") != "add": + if entity_info.get("action") != "add": continue # Filter AssetVersions - if entity["entityType"] != "assetversion": + if entity_info["entityType"] != "assetversion": continue - entity_changes = entity.get("changes") or {} + entity_changes = entity_info.get("changes") or {} # Check if version of Asset Version is `1` version_num = entity_changes.get("version", {}).get("new") @@ -177,9 +193,18 @@ class FirstVersionStatus(BaseEvent): if not task_id: continue - filtered_ents.append(entity) + project_id = None + for parent_item in reversed(entity_info["parents"]): + if parent_item["entityType"] == "show": + project_id = parent_item["entityId"] + break - return filtered_ents + if project_id is None: + continue + + filtered_entities_info[project_id].append(entity_info) + + return filtered_entities_info def register(session): diff --git a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py index a100c34f67..07a8ff433e 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py +++ b/openpype/modules/ftrack/event_handlers_server/event_next_task_update.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -99,6 +101,10 @@ class NextTaskUpdate(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index ed630ad59d..65c3c1a69a 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -3,6 +3,8 @@ import copy from typing import Any import ftrack_api + +from openpype.client import get_project from openpype_modules.ftrack.lib import ( BaseEvent, query_custom_attributes, @@ -139,6 +141,10 @@ class PushHierValuesToNonHierEvent(BaseEvent): project_name: str = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return set(), set() + # Load settings project_settings: dict[str, Any] = ( self.get_project_settings_from_event(event, project_name) diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py index 25fa3b0535..d2b395a1a3 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_parent_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -60,6 +62,10 @@ class TaskStatusToParent(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py index b77849c678..91ee2410d7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py +++ b/openpype/modules/ftrack/event_handlers_server/event_task_to_version_status.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -102,6 +104,10 @@ class TaskToVersionStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py index 64673f792c..318e69f414 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py +++ b/openpype/modules/ftrack/event_handlers_server/event_thumbnail_updates.py @@ -1,4 +1,6 @@ import collections + +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -22,6 +24,10 @@ class ThumbnailEvents(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py index fb40fd6417..fbe44bcba7 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py +++ b/openpype/modules/ftrack/event_handlers_server/event_version_to_task_statuses.py @@ -1,3 +1,4 @@ +from openpype.client import get_project from openpype_modules.ftrack.lib import BaseEvent @@ -50,6 +51,10 @@ class VersionToTaskStatus(BaseEvent): project_name = self.get_project_name_from_event( session, event, project_id ) + if get_project(project_name) is None: + self.log.debug("Project not found in OpenPype. Skipping") + return + # Load settings project_settings = self.get_project_settings_from_event( event, project_name diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py index 5e14f25f76..4f0674c94f 100644 --- a/openpype/modules/launcher_action.py +++ b/openpype/modules/launcher_action.py @@ -40,7 +40,7 @@ class LauncherAction(OpenPypeModule, ITrayAction): actions_paths = self.manager.collect_plugin_paths()["actions"] for path in actions_paths: if path and os.path.exists(path): - register_launcher_action_path(actions_dir) + register_launcher_action_path(path) paths_str = os.environ.get("AVALON_ACTIONS") or "" if paths_str: diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index 13630ae7ca..5afdb30f7b 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -25,10 +25,7 @@ from openpype.tests.lib import is_in_tests from .publish.lib import filter_pyblish_plugins from .anatomy import Anatomy -from .template_data import ( - get_template_data_with_names, - get_template_data -) +from .template_data import get_template_data_with_names from .workfile import ( get_workfile_template_key, get_custom_workfile_template_by_string_context, @@ -483,6 +480,27 @@ def get_template_data_from_session(session=None, system_settings=None): ) +def get_current_context_template_data(system_settings=None): + """Prepare template data for current context. + + Args: + system_settings (Optional[Dict[str, Any]]): Prepared system settings. + + Returns: + Dict[str, Any] Template data for current context. + """ + + context = get_current_context() + project_name = context["project_name"] + asset_name = context["asset_name"] + task_name = context["task_name"] + host_name = get_current_host_name() + + return get_template_data_with_names( + project_name, asset_name, task_name, host_name, system_settings + ) + + def get_workdir_from_session(session=None, template_key=None): """Template data for template fill from session keys. @@ -661,70 +679,3 @@ def get_process_id(): if _process_id is None: _process_id = str(uuid.uuid4()) return _process_id - - -def get_current_context_template_data(): - """Template data for template fill from current context - - Returns: - Dict[str, Any] of the following tokens and their values - Supported Tokens: - - Regular Tokens - - app - - user - - asset - - parent - - hierarchy - - folder[name] - - root[work, ...] - - studio[code, name] - - project[code, name] - - task[type, name, short] - - - Context Specific Tokens - - assetData[frameStart] - - assetData[frameEnd] - - assetData[handleStart] - - assetData[handleEnd] - - assetData[frameStartHandle] - - assetData[frameEndHandle] - - assetData[resolutionHeight] - - assetData[resolutionWidth] - - """ - - # pre-prepare get_template_data args - current_context = get_current_context() - project_name = current_context["project_name"] - asset_name = current_context["asset_name"] - anatomy = Anatomy(project_name) - - # prepare get_template_data args - project_doc = get_project(project_name) - asset_doc = get_asset_by_name(project_name, asset_name) - task_name = current_context["task_name"] - host_name = get_current_host_name() - - # get regular template data - template_data = get_template_data( - project_doc, asset_doc, task_name, host_name - ) - - template_data["root"] = anatomy.roots - - # get context specific vars - asset_data = asset_doc["data"].copy() - - # compute `frameStartHandle` and `frameEndHandle` - if "frameStart" in asset_data and "handleStart" in asset_data: - asset_data["frameStartHandle"] = \ - asset_data["frameStart"] - asset_data["handleStart"] - - if "frameEnd" in asset_data and "handleEnd" in asset_data: - asset_data["frameEndHandle"] = \ - asset_data["frameEnd"] + asset_data["handleEnd"] - - # add assetData - template_data["assetData"] = asset_data - - return template_data diff --git a/openpype/plugins/publish/collect_current_pype_user.py b/openpype/plugins/publish/collect_current_pype_user.py index 2d507ba292..5c0c4fc82e 100644 --- a/openpype/plugins/publish/collect_current_pype_user.py +++ b/openpype/plugins/publish/collect_current_pype_user.py @@ -1,4 +1,6 @@ import pyblish.api + +from openpype import AYON_SERVER_ENABLED from openpype.lib import get_openpype_username @@ -7,7 +9,11 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin): # Order must be after default pyblish-base CollectCurrentUser order = pyblish.api.CollectorOrder + 0.001 - label = "Collect Pype User" + label = ( + "Collect AYON User" + if AYON_SERVER_ENABLED + else "Collect OpenPype User" + ) def process(self, context): user = get_openpype_username() diff --git a/openpype/plugins/publish/collect_otio_frame_ranges.py b/openpype/plugins/publish/collect_otio_frame_ranges.py index 9a68b6e43d..4b130b0e03 100644 --- a/openpype/plugins/publish/collect_otio_frame_ranges.py +++ b/openpype/plugins/publish/collect_otio_frame_ranges.py @@ -5,15 +5,9 @@ Requires: masterLayer -> instance data attribute otioClipRange -> instance data attribute """ -# import os -import opentimelineio as otio -import pyblish.api from pprint import pformat -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - otio_range_to_frame_range, - otio_range_with_handles -) + +import pyblish.api class CollectOtioFrameRanges(pyblish.api.InstancePlugin): @@ -27,6 +21,14 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame", "traypublisher"] def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + otio_range_to_frame_range, + otio_range_with_handles + ) + # get basic variables otio_clip = instance.data["otioClip"] workfile_start = instance.data["workfileFrameStart"] diff --git a/openpype/plugins/publish/collect_otio_review.py b/openpype/plugins/publish/collect_otio_review.py index f0157282a1..0e4d596213 100644 --- a/openpype/plugins/publish/collect_otio_review.py +++ b/openpype/plugins/publish/collect_otio_review.py @@ -11,10 +11,10 @@ Provides: instance -> families (adding ["review", "ftrack"]) """ -import opentimelineio as otio -import pyblish.api from pprint import pformat +import pyblish.api + class CollectOtioReview(pyblish.api.InstancePlugin): """Get matching otio track from defined review layer""" @@ -25,6 +25,9 @@ class CollectOtioReview(pyblish.api.InstancePlugin): hosts = ["resolve", "hiero", "flame"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + # get basic variables otio_review_clips = [] otio_timeline = instance.context.data["otioTimeline"] diff --git a/openpype/plugins/publish/collect_otio_subset_resources.py b/openpype/plugins/publish/collect_otio_subset_resources.py index f659791d95..739f5bb726 100644 --- a/openpype/plugins/publish/collect_otio_subset_resources.py +++ b/openpype/plugins/publish/collect_otio_subset_resources.py @@ -6,18 +6,15 @@ Provides: instance -> otioReviewClips """ import os + import clique -import opentimelineio as otio import pyblish.api -from openpype.pipeline.editorial import ( - get_media_range_with_retimes, - range_from_frames, - make_sequence_collection -) + from openpype.pipeline.publish import ( get_publish_template_name ) + class CollectOtioSubsetResources(pyblish.api.InstancePlugin): """Get Resources for a subset version""" @@ -26,8 +23,14 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin): families = ["clip"] hosts = ["resolve", "hiero", "flame"] - def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + get_media_range_with_retimes, + range_from_frames, + make_sequence_collection + ) if "audio" in instance.data["family"]: return diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 8a5a5a83f1..a249b3acda 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -56,6 +56,17 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): data_object["stagingDir"] = anatomy.fill_root(staging_dir) def _process_path(self, data, anatomy): + """Process data of a single JSON publish metadata file. + + Args: + data: The loaded metadata from the JSON file + anatomy: Anatomy for the current context + + Returns: + bool: Whether any instance of this particular metadata file + has a persistent staging dir. + + """ # validate basic necessary data data_err = "invalid json file - missing data" required = ["asset", "user", "comment", @@ -89,6 +100,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): os.environ["FTRACK_SERVER"] = ftrack["FTRACK_SERVER"] # now we can just add instances from json file and we are done + any_staging_dir_persistent = False for instance_data in data.get("instances"): self.log.debug(" - processing instance for {}".format( @@ -106,6 +118,9 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): staging_dir_persistent = instance.data.get( "stagingDir_persistent", False ) + if staging_dir_persistent: + any_staging_dir_persistent = True + representations = [] for repre_data in instance_data.get("representations") or []: self._fill_staging_dir(repre_data, anatomy) @@ -127,7 +142,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self.log.debug( f"Adding audio to instance: {instance.data['audio']}") - return staging_dir_persistent + return any_staging_dir_persistent def process(self, context): self._context = context diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index 7920c1e82b..f870ae9ad7 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -24,6 +24,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): "hiero", "houdini", "maya", + "max", "nuke", "photoshop", "resolve", diff --git a/openpype/plugins/publish/extract_otio_audio_tracks.py b/openpype/plugins/publish/extract_otio_audio_tracks.py index 4f17731452..4b73321f02 100644 --- a/openpype/plugins/publish/extract_otio_audio_tracks.py +++ b/openpype/plugins/publish/extract_otio_audio_tracks.py @@ -1,11 +1,12 @@ import os +import tempfile + import pyblish + from openpype.lib import ( get_ffmpeg_tool_args, run_subprocess ) -import tempfile -import opentimelineio as otio class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): @@ -155,6 +156,9 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin): Returns: list: list of audio clip dictionaries """ + # Not all hosts can import this module. + import opentimelineio as otio + output = [] # go trough all audio tracks for otio_track in otio_timeline.tracks: diff --git a/openpype/plugins/publish/extract_otio_file.py b/openpype/plugins/publish/extract_otio_file.py index 1a6a82117d..7f1cac33d7 100644 --- a/openpype/plugins/publish/extract_otio_file.py +++ b/openpype/plugins/publish/extract_otio_file.py @@ -1,6 +1,6 @@ import os + import pyblish.api -import opentimelineio as otio from openpype.pipeline import publish @@ -16,6 +16,9 @@ class ExtractOTIOFile(publish.Extractor): hosts = ["resolve", "hiero", "traypublisher"] def process(self, instance): + # Not all hosts can import this module. + import opentimelineio as otio + if not instance.context.data.get("otioTimeline"): return # create representation data diff --git a/openpype/plugins/publish/extract_otio_review.py b/openpype/plugins/publish/extract_otio_review.py index 699207df8a..ad4c807091 100644 --- a/openpype/plugins/publish/extract_otio_review.py +++ b/openpype/plugins/publish/extract_otio_review.py @@ -15,8 +15,8 @@ Provides: """ import os + import clique -import opentimelineio as otio from pyblish import api from openpype.lib import ( @@ -24,13 +24,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import ( - otio_range_to_frame_range, - trim_media_range, - range_from_frames, - frames_to_seconds, - make_sequence_collection -) class ExtractOTIOReview(publish.Extractor): @@ -62,6 +55,13 @@ class ExtractOTIOReview(publish.Extractor): output_ext = ".jpg" def process(self, instance): + # Not all hosts can import these modules. + import opentimelineio as otio + from openpype.pipeline.editorial import ( + otio_range_to_frame_range, + make_sequence_collection + ) + # TODO: convert resulting image sequence to mp4 # get otio clip and other time info from instance clip @@ -281,6 +281,12 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import these modules. + from openpype.pipeline.editorial import ( + trim_media_range, + range_from_frames + ) + avl_start = int(avl_range.start_time.value) src_start = int(avl_start + start) avl_durtation = int(avl_range.duration.value) @@ -338,6 +344,8 @@ class ExtractOTIOReview(publish.Extractor): Returns: otio.time.TimeRange: trimmed available range """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path and frame start to destination output_path, out_frame_start = self._get_ffmpeg_output() diff --git a/openpype/plugins/publish/extract_otio_trimming_video.py b/openpype/plugins/publish/extract_otio_trimming_video.py index 67ff6c538c..2020fcde93 100644 --- a/openpype/plugins/publish/extract_otio_trimming_video.py +++ b/openpype/plugins/publish/extract_otio_trimming_video.py @@ -15,7 +15,6 @@ from openpype.lib import ( run_subprocess, ) from openpype.pipeline import publish -from openpype.pipeline.editorial import frames_to_seconds class ExtractOTIOTrimmingVideo(publish.Extractor): @@ -75,6 +74,8 @@ class ExtractOTIOTrimmingVideo(publish.Extractor): otio_range (opentime.TimeRange): range to trim to """ + # Not all hosts can import this module. + from openpype.pipeline.editorial import frames_to_seconds # create path to destination output_path = self._get_ffmpeg_output(input_file_path) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 071ecfffd2..b5828d3dfe 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -214,7 +214,7 @@ class PypeCommands: def run_tests(self, folder, mark, pyargs, test_data_folder, persist, app_variant, timeout, setup_only, - mongo_url): + mongo_url, app_group): """ Runs tests from 'folder' @@ -260,6 +260,9 @@ class PypeCommands: if persist: args.extend(["--persist", persist]) + if app_group: + args.extend(["--app_group", app_group]) + if app_variant: args.extend(["--app_variant", app_variant]) diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py index 8d4683490b..5eb68e3972 100644 --- a/openpype/settings/ayon_settings.py +++ b/openpype/settings/ayon_settings.py @@ -639,6 +639,16 @@ def _convert_3dsmax_project_settings(ayon_settings, output): for item in point_cloud_attribute } ayon_max["PointCloud"]["attribute"] = new_point_cloud_attribute + # --- Publish (START) --- + ayon_publish = ayon_max["publish"] + if "ValidateAttributes" in ayon_publish: + try: + attributes = json.loads( + ayon_publish["ValidateAttributes"]["attributes"] + ) + except ValueError: + attributes = {} + ayon_publish["ValidateAttributes"]["attributes"] = attributes output["max"] = ayon_max @@ -1436,7 +1446,10 @@ class _AyonSettingsCache: def _use_bundles(cls): if _AyonSettingsCache.use_bundles is None: major, minor, _, _, _ = ayon_api.get_server_version_tuple() - _AyonSettingsCache.use_bundles = major == 0 and minor >= 3 + use_bundles = True + if (major, minor) < (0, 3): + use_bundles = False + _AyonSettingsCache.use_bundles = use_bundles return _AyonSettingsCache.use_bundles @classmethod @@ -1467,7 +1480,7 @@ class _AyonSettingsCache: bundles = ayon_api.get_bundles() user = ayon_api.get_user() username = user["name"] - for bundle in bundles: + for bundle in bundles["bundles"]: if ( bundle.get("isDev") and bundle.get("activeUser") == username diff --git a/openpype/settings/defaults/project_settings/houdini.json b/openpype/settings/defaults/project_settings/houdini.json index 87983620ec..0dd8443e44 100644 --- a/openpype/settings/defaults/project_settings/houdini.json +++ b/openpype/settings/defaults/project_settings/houdini.json @@ -137,7 +137,7 @@ } }, "publish": { - "CollectRopFrameRange": { + "CollectAssetHandles": { "use_asset_handles": true }, "ValidateContainers": { diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index bfb1aa4aeb..24a87020bb 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -36,6 +36,10 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateAttributes": { + "enabled": false, + "attributes": {} } } } diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 6a0ddb398e..a5283751e9 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -344,13 +344,30 @@ }, "environment": {} }, + "11-0": { + "use_python_2": true, + "executables": { + "windows": [ + "C:\\Program Files\\Nuke11.0v4\\Nuke11.0.exe" + ], + "darwin": [], + "linux": [] + }, + "arguments": { + "windows": [], + "darwin": [], + "linux": [] + }, + "environment": {} + }, "__dynamic_keys_labels__": { "13-2": "13.2", "13-0": "13.0", "12-2": "12.2", "12-0": "12.0", "11-3": "11.3", - "11-2": "11.2" + "11-2": "11.2", + "11-0": "11.0" } } }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json index 0de9f21c9f..324cfd8d58 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_publish.json @@ -11,8 +11,8 @@ { "type": "dict", "collapsible": true, - "key": "CollectRopFrameRange", - "label": "Collect Rop Frame Range", + "key": "CollectAssetHandles", + "label": "Collect Asset Handles", "children": [ { "type": "label", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json index 35d768843d..cee04b73e5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_houdini_scriptshelf.json @@ -5,70 +5,99 @@ "is_group": true, "use_label_wrap": true, "object_type": { - "type": "dict", - "children": [ + "type": "dict-conditional", + "enum_key": "options", + "enum_label": "Options", + "enum_children": [ { - "type": "text", - "key": "shelf_set_name", - "label": "Shelf Set Name" - }, - { - "type": "path", - "key": "shelf_set_source_path", - "label": "Shelf Set Path (optional)", - "multipath": false, - "multiplatform": true - }, - { - "type": "list", - "key": "shelf_definition", - "label": "Shelves", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "text", - "key": "shelf_name", - "label": "Shelf Name" - }, - { - "type": "list", - "key": "tools_list", - "label": "Tools", - "use_label_wrap": true, - "object_type": { - "type": "dict", - "children": [ - { - "type": "label", - "label": "Name and Script Path are mandatory." - }, - { - "type": "text", - "key": "label", - "label": "Name" - }, - { - "type": "path", - "key": "script", - "label": "Script" - }, - { - "type": "path", - "key": "icon", - "label": "Icon" - }, - { - "type": "text", - "key": "help", - "label": "Help" - } - ] + + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "dict", + "key": "add_shelf_file", + "label": "Add a .shelf file", + "children": [ + { + "type": "path", + "key": "shelf_set_source_path", + "label": "Shelf Set Path", + "multipath": false, + "multiplatform": true } - } - ] - } + ] + } + ] + }, + { + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "children": [ + { + "key": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions", + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_set_name", + "label": "Shelf Set Name" + }, + { + "type": "list", + "key": "shelf_definition", + "label": "Shelves Definitions", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "shelf_name", + "label": "Shelf Name" + }, + { + "type": "list", + "key": "tools_list", + "label": "Tools", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "type": "label", + "label": "Name and Script Path are mandatory." + }, + { + "type": "text", + "key": "label", + "label": "Name" + }, + { + "type": "path", + "key": "script", + "label": "Script" + }, + { + "type": "path", + "key": "icon", + "label": "Icon" + }, + { + "type": "text", + "key": "help", + "label": "Help" + } + ] + } + } + ] + } + } + ] + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json index ea08c735a6..c3b56bae5e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_max_publish.json @@ -28,6 +28,25 @@ "label": "Active" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateAttributes", + "label": "ValidateAttributes", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "raw-json", + "key": "attributes", + "label": "Attributes" + } + ] } ] } diff --git a/openpype/tools/ayon_launcher/ui/projects_widget.py b/openpype/tools/ayon_launcher/ui/projects_widget.py index 7dbaec5147..38c7f62bd5 100644 --- a/openpype/tools/ayon_launcher/ui/projects_widget.py +++ b/openpype/tools/ayon_launcher/ui/projects_widget.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets, QtCore from openpype.tools.flickcharm import FlickCharm from openpype.tools.utils import PlaceholderLineEdit, RefreshButton from openpype.tools.ayon_utils.widgets import ( - ProjectsModel, + ProjectsQtModel, ProjectSortFilterProxy, ) from openpype.tools.ayon_utils.models import PROJECTS_MODEL_SENDER @@ -95,7 +95,7 @@ class ProjectsWidget(QtWidgets.QWidget): projects_view.setSelectionMode(QtWidgets.QListView.NoSelection) flick = FlickCharm(parent=self) flick.activateOn(projects_view) - projects_model = ProjectsModel(controller) + projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() projects_proxy_model.setSourceModel(projects_model) @@ -133,9 +133,14 @@ class ProjectsWidget(QtWidgets.QWidget): return self._projects_model.has_content() def _on_view_clicked(self, index): - if index.isValid(): - project_name = index.data(QtCore.Qt.DisplayRole) - self._controller.set_selected_project(project_name) + if not index.isValid(): + return + model = index.model() + flags = model.flags(index) + if not flags & QtCore.Qt.ItemIsEnabled: + return + project_name = index.data(QtCore.Qt.DisplayRole) + self._controller.set_selected_project(project_name) def _on_project_filter_change(self, text): self._projects_proxy_model.setFilterFixedString(text) diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py index 53351f76d9..eaaf7ca617 100644 --- a/openpype/tools/ayon_loader/ui/folders_widget.py +++ b/openpype/tools/ayon_loader/ui/folders_widget.py @@ -8,7 +8,7 @@ from openpype.tools.utils import ( from openpype.style import get_objected_colors from openpype.tools.ayon_utils.widgets import ( - FoldersModel, + FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, ) from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE @@ -182,7 +182,7 @@ class UnderlinesFolderDelegate(QtWidgets.QItemDelegate): painter.restore() -class LoaderFoldersModel(FoldersModel): +class LoaderFoldersModel(FoldersQtModel): def __init__(self, *args, **kwargs): super(LoaderFoldersModel, self).__init__(*args, **kwargs) diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py index 4ad53fbbfa..36d53edc24 100644 --- a/openpype/tools/ayon_utils/models/projects.py +++ b/openpype/tools/ayon_utils/models/projects.py @@ -87,7 +87,7 @@ def _get_project_items_from_entitiy(projects): class ProjectsModel(object): def __init__(self, controller): - self._projects_cache = CacheItem(default_factory=dict) + self._projects_cache = CacheItem(default_factory=list) self._project_items_by_name = {} self._projects_by_name = {} @@ -103,8 +103,18 @@ class ProjectsModel(object): self._refresh_projects_cache() def get_project_items(self, sender): + """ + + Args: + sender (str): Name of sender who asked for items. + + Returns: + Union[list[ProjectItem], None]: List of project items, or None + if model is refreshing. + """ + if not self._projects_cache.is_valid: - self._refresh_projects_cache(sender) + return self._refresh_projects_cache(sender) return self._projects_cache.get_data() def get_project_entity(self, project_name): @@ -136,11 +146,12 @@ class ProjectsModel(object): def _refresh_projects_cache(self, sender=None): if self._is_refreshing: - return + return None with self._project_refresh_event_manager(sender): project_items = self._query_projects() self._projects_cache.update_data(project_items) + return self._projects_cache.get_data() def _query_projects(self): projects = ayon_api.get_projects(fields=["name", "active", "library"]) diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py index 432a249a73..f58de17c4a 100644 --- a/openpype/tools/ayon_utils/widgets/__init__.py +++ b/openpype/tools/ayon_utils/widgets/__init__.py @@ -1,19 +1,19 @@ from .projects_widget import ( # ProjectsWidget, ProjectsCombobox, - ProjectsModel, + ProjectsQtModel, ProjectSortFilterProxy, ) from .folders_widget import ( FoldersWidget, - FoldersModel, + FoldersQtModel, FOLDERS_MODEL_SENDER_NAME, ) from .tasks_widget import ( TasksWidget, - TasksModel, + TasksQtModel, TASKS_MODEL_SENDER_NAME, ) from .utils import ( @@ -25,15 +25,15 @@ from .utils import ( __all__ = ( # "ProjectsWidget", "ProjectsCombobox", - "ProjectsModel", + "ProjectsQtModel", "ProjectSortFilterProxy", "FoldersWidget", - "FoldersModel", + "FoldersQtModel", "FOLDERS_MODEL_SENDER_NAME", "TasksWidget", - "TasksModel", + "TasksQtModel", "TASKS_MODEL_SENDER_NAME", "get_qt_icon", diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py index 322553c51c..44323a192c 100644 --- a/openpype/tools/ayon_utils/widgets/folders_widget.py +++ b/openpype/tools/ayon_utils/widgets/folders_widget.py @@ -16,7 +16,7 @@ FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3 FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4 -class FoldersModel(QtGui.QStandardItemModel): +class FoldersQtModel(QtGui.QStandardItemModel): """Folders model which cares about refresh of folders. Args: @@ -26,7 +26,7 @@ class FoldersModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(FoldersModel, self).__init__() + super(FoldersQtModel, self).__init__() self._controller = controller self._items_by_id = {} @@ -104,8 +104,8 @@ class FoldersModel(QtGui.QStandardItemModel): if not project_name: self._last_project_name = project_name - self._current_refresh_thread = None self._fill_items({}) + self._current_refresh_thread = None return self._is_refreshing = True @@ -152,6 +152,7 @@ class FoldersModel(QtGui.QStandardItemModel): return self._fill_items(thread.get_result()) + self._current_refresh_thread = None def _fill_item_data(self, item, folder_item): """ @@ -281,7 +282,7 @@ class FoldersWidget(QtWidgets.QWidget): folders_view = TreeView(self) folders_view.setHeaderHidden(True) - folders_model = FoldersModel(controller) + folders_model = FoldersQtModel(controller) folders_proxy_model = RecursiveSortFilterProxyModel() folders_proxy_model.setSourceModel(folders_model) folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py index be18cfe3ed..f98bfcdf8a 100644 --- a/openpype/tools/ayon_utils/widgets/projects_widget.py +++ b/openpype/tools/ayon_utils/widgets/projects_widget.py @@ -10,11 +10,11 @@ PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4 LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5 -class ProjectsModel(QtGui.QStandardItemModel): +class ProjectsQtModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(ProjectsModel, self).__init__() + super(ProjectsQtModel, self).__init__() self._controller = controller self._project_items = {} @@ -35,12 +35,11 @@ class ProjectsModel(QtGui.QStandardItemModel): self._selected_project = None - self._is_refreshing = False self._refresh_thread = None @property def is_refreshing(self): - return self._is_refreshing + return self._refresh_thread is not None def refresh(self): self._refresh() @@ -169,28 +168,33 @@ class ProjectsModel(QtGui.QStandardItemModel): return self._select_item def _refresh(self): - if self._is_refreshing: + if self._refresh_thread is not None: return - self._is_refreshing = True + refresh_thread = RefreshThread( "projects", self._query_project_items ) refresh_thread.refresh_finished.connect(self._refresh_finished) - refresh_thread.start() + self._refresh_thread = refresh_thread + refresh_thread.start() def _query_project_items(self): - return self._controller.get_project_items() + return self._controller.get_project_items( + sender=PROJECTS_MODEL_SENDER + ) def _refresh_finished(self): # TODO check if failed result = self._refresh_thread.get_result() + if result is not None: + self._fill_items(result) + self._refresh_thread = None - - self._fill_items(result) - - self._is_refreshing = False - self.refreshed.emit() + if result is None: + self._refresh() + else: + self.refreshed.emit() def _fill_items(self, project_items): new_project_names = { @@ -403,7 +407,7 @@ class ProjectsCombobox(QtWidgets.QWidget): projects_combobox = QtWidgets.QComboBox(self) combobox_delegate = QtWidgets.QStyledItemDelegate(projects_combobox) projects_combobox.setItemDelegate(combobox_delegate) - projects_model = ProjectsModel(controller) + projects_model = ProjectsQtModel(controller) projects_proxy_model = ProjectSortFilterProxy() projects_proxy_model.setSourceModel(projects_model) projects_combobox.setModel(projects_proxy_model) diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py index d01b3a7917..f27711acdd 100644 --- a/openpype/tools/ayon_utils/widgets/tasks_widget.py +++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py @@ -12,7 +12,7 @@ ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3 TASK_TYPE_ROLE = QtCore.Qt.UserRole + 4 -class TasksModel(QtGui.QStandardItemModel): +class TasksQtModel(QtGui.QStandardItemModel): """Tasks model which cares about refresh of tasks by folder id. Args: @@ -22,7 +22,7 @@ class TasksModel(QtGui.QStandardItemModel): refreshed = QtCore.Signal() def __init__(self, controller): - super(TasksModel, self).__init__() + super(TasksQtModel, self).__init__() self._controller = controller @@ -185,28 +185,7 @@ class TasksModel(QtGui.QStandardItemModel): thread.refresh_finished.connect(self._on_refresh_thread) thread.start() - def _on_refresh_thread(self, thread_id): - """Callback when refresh thread is finished. - - Technically can be running multiple refresh threads at the same time, - to avoid using values from wrong thread, we check if thread id is - current refresh thread id. - - Tasks are stored by name, so if a folder has same task name as - previously selected folder it keeps the selection. - - Args: - thread_id (str): Thread id. - """ - - # Make sure to remove thread from '_refresh_threads' dict - thread = self._refresh_threads.pop(thread_id) - if ( - self._current_refresh_thread is None - or thread_id != self._current_refresh_thread.id - ): - return - + def _fill_data_from_thread(self, thread): task_items = thread.get_result() # Task items are refreshed if task_items is None: @@ -247,7 +226,33 @@ class TasksModel(QtGui.QStandardItemModel): if new_items: root_item.appendRows(new_items) + def _on_refresh_thread(self, thread_id): + """Callback when refresh thread is finished. + + Technically can be running multiple refresh threads at the same time, + to avoid using values from wrong thread, we check if thread id is + current refresh thread id. + + Tasks are stored by name, so if a folder has same task name as + previously selected folder it keeps the selection. + + Args: + thread_id (str): Thread id. + """ + + # Make sure to remove thread from '_refresh_threads' dict + thread = self._refresh_threads.pop(thread_id) + if ( + self._current_refresh_thread is None + or thread_id != self._current_refresh_thread.id + ): + return + + self._fill_data_from_thread(thread) + + root_item = self.invisibleRootItem() self._has_content = root_item.rowCount() > 0 + self._current_refresh_thread = None self._is_refreshing = False self.refreshed.emit() @@ -280,7 +285,7 @@ class TasksModel(QtGui.QStandardItemModel): if section == 0: return "Tasks" - return super(TasksModel, self).headerData( + return super(TasksQtModel, self).headerData( section, orientation, role ) @@ -305,7 +310,7 @@ class TasksWidget(QtWidgets.QWidget): tasks_view = DeselectableTreeView(self) tasks_view.setIndentation(0) - tasks_model = TasksModel(controller) + tasks_model = TasksQtModel(controller) tasks_proxy_model = QtCore.QSortFilterProxyModel() tasks_proxy_model.setSourceModel(tasks_model) tasks_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) diff --git a/openpype/tools/ayon_utils/widgets/utils.py b/openpype/tools/ayon_utils/widgets/utils.py index 8bc3b1ea9b..2817b5efc0 100644 --- a/openpype/tools/ayon_utils/widgets/utils.py +++ b/openpype/tools/ayon_utils/widgets/utils.py @@ -54,6 +54,8 @@ class _IconsCache: @classmethod def get_icon(cls, icon_def): + if not icon_def: + return None icon_type = icon_def["type"] cache_key = cls._get_cache_key(icon_def) cache = cls._cache.get(cache_key) diff --git a/openpype/tools/context_dialog/__init__.py b/openpype/tools/context_dialog/__init__.py index 9b10baf903..15b90463da 100644 --- a/openpype/tools/context_dialog/__init__.py +++ b/openpype/tools/context_dialog/__init__.py @@ -1,10 +1,12 @@ -from .window import ( - ContextDialog, - main -) +from openpype import AYON_SERVER_ENABLED + +if AYON_SERVER_ENABLED: + from ._ayon_window import ContextDialog, main +else: + from ._openpype_window import ContextDialog, main __all__ = ( "ContextDialog", - "main" + "main", ) diff --git a/openpype/tools/context_dialog/_ayon_window.py b/openpype/tools/context_dialog/_ayon_window.py new file mode 100644 index 0000000000..f347978392 --- /dev/null +++ b/openpype/tools/context_dialog/_ayon_window.py @@ -0,0 +1,799 @@ +import os +import json + +import ayon_api +from qtpy import QtWidgets, QtCore, QtGui + +from openpype import style +from openpype.lib.events import QueuedEventSystem +from openpype.tools.ayon_utils.models import ( + ProjectsModel, + HierarchyModel, +) +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) +from openpype.tools.utils.lib import ( + center_window, + get_openpype_qt_app, +) + + +class SelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_id = None + self._task_name = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) + + +class ExpectedSelection: + def __init__(self, controller): + self._project_name = None + self._folder_id = None + + self._project_selected = True + self._folder_selected = True + + self._controller = controller + + def _emit_change(self): + self._controller.emit_event( + "expected_selection_changed", + self.get_expected_selection_data(), + ) + + def set_expected_selection(self, project_name, folder_id): + self._project_name = project_name + self._folder_id = folder_id + + self._project_selected = False + self._folder_selected = False + self._emit_change() + + def get_expected_selection_data(self): + project_current = False + folder_current = False + if not self._project_selected: + project_current = True + elif not self._folder_selected: + folder_current = True + return { + "project": { + "name": self._project_name, + "current": project_current, + "selected": self._project_selected, + }, + "folder": { + "id": self._folder_id, + "current": folder_current, + "selected": self._folder_selected, + }, + } + + def is_expected_project_selected(self, project_name): + return project_name == self._project_name and self._project_selected + + def is_expected_folder_selected(self, folder_id): + return folder_id == self._folder_id and self._folder_selected + + def expected_project_selected(self, project_name): + if project_name != self._project_name: + return False + self._project_selected = True + self._emit_change() + return True + + def expected_folder_selected(self, folder_id): + if folder_id != self._folder_id: + return False + self._folder_selected = True + self._emit_change() + return True + + +class ContextDialogController: + def __init__(self): + self._event_system = None + + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + self._selection_model = SelectionModel(self) + self._expected_selection = ExpectedSelection(self) + + self._confirmed = False + self._is_strict = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + def reset(self): + self._emit_event("controller.reset.started") + + self._confirmed = False + self._output_path = None + + self._initial_project_name = None + self._initial_folder_id = None + self._initial_folder_label = None + self._initial_project_found = True + self._initial_folder_found = True + self._initial_tasks_found = True + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.reset.finished") + + def refresh(self): + self._emit_event("controller.refresh.started") + + self._projects_model.reset() + self._hierarchy_model.reset() + + self._emit_event("controller.refresh.finished") + + # Event handling + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._get_event_system().emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._get_event_system().add_callback(topic, callback) + + def set_output_json_path(self, output_path): + self._output_path = output_path + + def is_strict(self): + return self._is_strict + + def set_strict(self, enabled): + if self._is_strict is enabled: + return + self._is_strict = enabled + self._emit_event("strict.changed", {"strict": enabled}) + + # Data model functions + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + # Expected selection helpers + def set_expected_selection(self, project_name, folder_id): + return self._expected_selection.set_expected_selection( + project_name, folder_id + ) + + def get_expected_selection_data(self): + return self._expected_selection.get_expected_selection_data() + + def expected_project_selected(self, project_name): + self._expected_selection.expected_project_selected(project_name) + + def expected_folder_selected(self, folder_id): + self._expected_selection.expected_folder_selected(folder_id) + + # Selection handling + def get_selected_project_name(self): + return self._selection_model.get_selected_project_name() + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def get_selected_folder_id(self): + return self._selection_model.get_selected_folder_id() + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def get_selected_task_name(self): + return self._selection_model.get_selected_task_name() + + def get_selected_task_id(self): + return self._selection_model.get_selected_task_id() + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + def is_initial_context_valid(self): + return self._initial_folder_found and self._initial_project_found + + def set_initial_context(self, project_name=None, asset_name=None): + result = self._prepare_initial_context(project_name, asset_name) + + self._initial_project_name = project_name + self._initial_folder_id = result["folder_id"] + self._initial_folder_label = result["folder_label"] + self._initial_project_found = result["project_found"] + self._initial_folder_found = result["folder_found"] + self._initial_tasks_found = result["tasks_found"] + self._emit_event( + "initial.context.changed", + self.get_initial_context() + ) + + def get_initial_context(self): + return { + "project_name": self._initial_project_name, + "folder_id": self._initial_folder_id, + "folder_label": self._initial_folder_label, + "project_found": self._initial_project_found, + "folder_found": self._initial_folder_found, + "tasks_found": self._initial_tasks_found, + "valid": ( + self._initial_project_found + and self._initial_folder_found + and self._initial_tasks_found + ) + } + + # Result of this tool + def get_selected_context(self): + project_name = None + folder_id = None + task_id = None + task_name = None + folder_path = None + folder_name = None + if self._confirmed: + project_name = self.get_selected_project_name() + folder_id = self.get_selected_folder_id() + task_id = self.get_selected_task_id() + task_name = self.get_selected_task_name() + + folder_item = None + if folder_id: + folder_item = self._hierarchy_model.get_folder_item( + project_name, folder_id) + + if folder_item: + folder_path = folder_item.path + folder_name = folder_item.name + return { + "project": project_name, + "project_name": project_name, + "asset": folder_name, + "folder_id": folder_id, + "folder_path": folder_path, + "task": task_name, + "task_name": task_name, + "task_id": task_id, + "initial_context_valid": self.is_initial_context_valid(), + } + + def confirm_selection(self): + self._confirmed = True + + def store_output(self): + if not self._output_path: + return + + dirpath = os.path.dirname(self._output_path) + os.makedirs(dirpath, exist_ok=True) + with open(self._output_path, "w") as stream: + json.dump(self.get_selected_context(), stream, indent=4) + + def _prepare_initial_context(self, project_name, asset_name): + project_found = True + output = { + "project_found": project_found, + "folder_id": None, + "folder_label": None, + "folder_found": True, + "tasks_found": True, + } + if project_name is None: + asset_name = None + else: + project = ayon_api.get_project(project_name) + project_found = project is not None + output["project_found"] = project_found + if not project_found or not asset_name: + return output + + output["folder_label"] = asset_name + + folder_id = None + folder_found = False + # First try to find by path + folder = ayon_api.get_folder_by_path(project_name, asset_name) + # Try to find by name if folder was not found by path + # - prevent to query by name if 'asset_name' contains '/' + if not folder and "/" not in asset_name: + folder = next( + ayon_api.get_folders( + project_name, folder_names=[asset_name], fields=["id"]), + None + ) + + if folder: + folder_id = folder["id"] + folder_found = True + + output["folder_id"] = folder_id + output["folder_found"] = folder_found + if not folder_found: + return output + + tasks = list(ayon_api.get_tasks( + project_name, folder_ids=[folder_id], fields=["id"] + )) + output["tasks_found"] = bool(tasks) + return output + + def _get_event_system(self): + """Inner event system for workfiles tool controller. + + Is used for communication with UI. Event system is created on demand. + + Returns: + QueuedEventSystem: Event system which can trigger callbacks + for topics. + """ + + if self._event_system is None: + self._event_system = QueuedEventSystem() + return self._event_system + + def _emit_event(self, topic, data=None): + self.emit_event(topic, data, "controller") + + +class InvalidContextOverlay(QtWidgets.QFrame): + confirmed = QtCore.Signal() + + def __init__(self, parent): + super(InvalidContextOverlay, self).__init__(parent) + self.setObjectName("OverlayFrame") + + mid_widget = QtWidgets.QWidget(self) + label_widget = QtWidgets.QLabel( + "Requested context was not found...", + mid_widget + ) + + confirm_btn = QtWidgets.QPushButton("Close", mid_widget) + + mid_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + mid_layout = QtWidgets.QVBoxLayout(mid_widget) + mid_layout.setContentsMargins(0, 0, 0, 0) + mid_layout.addWidget(label_widget, 0) + mid_layout.addSpacing(30) + mid_layout.addWidget(confirm_btn, 0) + + main_layout = QtWidgets.QGridLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(mid_widget, 1, 1) + main_layout.setRowStretch(0, 1) + main_layout.setRowStretch(1, 0) + main_layout.setRowStretch(2, 1) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 0) + main_layout.setColumnStretch(2, 1) + + confirm_btn.clicked.connect(self.confirmed) + + self._label_widget = label_widget + self._confirm_btn = confirm_btn + + def set_context( + self, + project_name, + folder_label, + project_found, + folder_found, + tasks_found, + ): + lines = [] + if not project_found: + lines.extend([ + "Requested project '{}' was not found...".format( + project_name), + ]) + + elif not folder_found: + lines.extend([ + "Requested folder was not found...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + elif not tasks_found: + lines.extend([ + "Requested folder does not have any tasks...", + "", + "Project: {}".format(project_name), + "Folder: {}".format(folder_label), + ]) + else: + lines.append("Requested context was not found...") + self._label_widget.setText("
".join(lines)) + + +class ContextDialog(QtWidgets.QDialog): + """Dialog to select a context. + + Context has 3 parts: + - Project + - Asset + - Task + + It is possible to predefine project and asset. In that case their widgets + will have passed preselected values and will be disabled. + """ + def __init__(self, controller=None, parent=None): + super(ContextDialog, self).__init__(parent) + + self.setWindowTitle("Select Context") + self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + + if controller is None: + controller = ContextDialogController() + + # Enable minimize and maximize for app + window_flags = QtCore.Qt.Window + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + # UI initialization + main_splitter = QtWidgets.QSplitter(self) + + # Left side widget contains project combobox and asset widget + left_side_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = ProjectsCombobox( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + project_combobox.set_select_item_visible(True) + + # Assets widget + folders_widget = FoldersWidget( + controller, + parent=left_side_widget, + handle_expected_selection=True + ) + + left_side_layout = QtWidgets.QVBoxLayout(left_side_widget) + left_side_layout.setContentsMargins(0, 0, 0, 0) + left_side_layout.addWidget(project_combobox, 0) + left_side_layout.addWidget(folders_widget, 1) + + # Right side of window contains only tasks + tasks_widget = TasksWidget(controller, parent=main_splitter) + + # Add widgets to main splitter + main_splitter.addWidget(left_side_widget) + main_splitter.addWidget(tasks_widget) + + # Set stretch of both sides + main_splitter.setStretchFactor(0, 7) + main_splitter.setStretchFactor(1, 3) + + # Add confimation button to bottom right + ok_btn = QtWidgets.QPushButton("OK", self) + + buttons_layout = QtWidgets.QHBoxLayout() + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(ok_btn, 0) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(main_splitter, 1) + main_layout.addLayout(buttons_layout, 0) + + overlay_widget = InvalidContextOverlay(self) + overlay_widget.setVisible(False) + + ok_btn.clicked.connect(self._on_ok_click) + project_combobox.refreshed.connect(self._on_projects_refresh) + overlay_widget.confirmed.connect(self._on_overlay_confirm) + + controller.register_event_callback( + "selection.project.changed", + self._on_project_selection_change + ) + controller.register_event_callback( + "selection.folder.changed", + self._on_folder_selection_change + ) + controller.register_event_callback( + "selection.task.changed", + self._on_task_selection_change + ) + controller.register_event_callback( + "initial.context.changed", + self._on_init_context_change + ) + controller.register_event_callback( + "strict.changed", + self._on_strict_changed + ) + controller.register_event_callback( + "controller.reset.finished", + self._on_controller_reset + ) + controller.register_event_callback( + "controller.refresh.finished", + self._on_controller_refresh + ) + + # Set stylehseet and resize window on first show + self._first_show = True + self._visible = False + + self._controller = controller + + self._project_combobox = project_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget + + self._ok_btn = ok_btn + + self._overlay_widget = overlay_widget + + self._apply_strict_changes(self.is_strict()) + + def is_strict(self): + return self._controller.is_strict() + + def showEvent(self, event): + """Override show event to do some callbacks.""" + super(ContextDialog, self).showEvent(event) + self._visible = True + + if self._first_show: + self._first_show = False + # Set stylesheet and resize + self.setStyleSheet(style.load_stylesheet()) + self.resize(600, 700) + center_window(self) + self._controller.refresh() + + initial_context = self._controller.get_initial_context() + self._set_init_context(initial_context) + self._overlay_widget.resize(self.size()) + + def resizeEvent(self, event): + super(ContextDialog, self).resizeEvent(event) + self._overlay_widget.resize(self.size()) + + def closeEvent(self, event): + """Ignore close event if is in strict state and context is not done.""" + if self.is_strict() and not self._ok_btn.isEnabled(): + # Allow to close window when initial context is not valid + if self._controller.is_initial_context_valid(): + event.ignore() + return + + if self.is_strict(): + self._confirm_selection() + self._visible = False + super(ContextDialog, self).closeEvent(event) + + def set_strict(self, enabled): + """Change strictness of dialog.""" + + self._controller.set_strict(enabled) + + def refresh(self): + """Refresh all widget one by one. + + When asset refresh is triggered we have to wait when is done so + this method continues with `_on_asset_widget_refresh_finished`. + """ + + self._controller.reset() + + def get_context(self): + """Result of dialog.""" + return self._controller.get_selected_context() + + def set_context(self, project_name=None, asset_name=None): + """Set context which will be used and locked in dialog.""" + + self._controller.set_initial_context(project_name, asset_name) + + def _on_projects_refresh(self): + initial_context = self._controller.get_initial_context() + self._controller.set_expected_selection( + initial_context["project_name"], + initial_context["folder_id"] + ) + + def _on_overlay_confirm(self): + self.close() + + def _on_ok_click(self): + # Store values to output + self._confirm_selection() + # Close dialog + self.accept() + + def _confirm_selection(self): + self._controller.confirm_selection() + + def _on_project_selection_change(self, event): + self._on_selection_change( + event["project_name"], + ) + + def _on_folder_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + ) + + def _on_task_selection_change(self, event): + self._on_selection_change( + event["project_name"], + event["folder_id"], + event["task_name"], + ) + + def _on_selection_change( + self, project_name, folder_id=None, task_name=None + ): + self._validate_strict(project_name, folder_id, task_name) + + def _on_init_context_change(self, event): + self._set_init_context(event.data) + if self._visible: + self._controller.set_expected_selection( + event["project_name"], event["folder_id"] + ) + + def _set_init_context(self, init_context): + project_name = init_context["project_name"] + if not init_context["valid"]: + self._overlay_widget.setVisible(True) + self._overlay_widget.set_context( + project_name, + init_context["folder_label"], + init_context["project_found"], + init_context["folder_found"], + init_context["tasks_found"] + ) + return + + self._overlay_widget.setVisible(False) + if project_name: + self._project_combobox.setEnabled(False) + if init_context["folder_id"]: + self._folders_widget.setEnabled(False) + else: + self._project_combobox.setEnabled(True) + self._folders_widget.setEnabled(True) + + def _on_strict_changed(self, event): + self._apply_strict_changes(event["strict"]) + + def _on_controller_reset(self): + self._apply_strict_changes(self.is_strict()) + self._project_combobox.refresh() + + def _on_controller_refresh(self): + self._project_combobox.refresh() + + def _apply_strict_changes(self, is_strict): + if not is_strict: + if not self._ok_btn.isEnabled(): + self._ok_btn.setEnabled(True) + return + context = self._controller.get_selected_context() + self._validate_strict( + context["project_name"], + context["folder_id"], + context["task_name"] + ) + + def _validate_strict(self, project_name, folder_id, task_name): + if not self.is_strict(): + return + + enabled = True + if not project_name or not folder_id or not task_name: + enabled = False + self._ok_btn.setEnabled(enabled) + + +def main( + path_to_store, + project_name=None, + asset_name=None, + strict=True +): + # Run Qt application + app = get_openpype_qt_app() + controller = ContextDialogController() + controller.set_strict(strict) + controller.set_initial_context(project_name, asset_name) + controller.set_output_json_path(path_to_store) + window = ContextDialog(controller=controller) + window.show() + app.exec_() + controller.store_output() diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/_openpype_window.py similarity index 99% rename from openpype/tools/context_dialog/window.py rename to openpype/tools/context_dialog/_openpype_window.py index 4fe41c9949..d370772a7f 100644 --- a/openpype/tools/context_dialog/window.py +++ b/openpype/tools/context_dialog/_openpype_window.py @@ -22,7 +22,7 @@ class ContextDialog(QtWidgets.QDialog): Context has 3 parts: - Project - - Aseet + - Asset - Task It is possible to predefine project and asset. In that case their widgets @@ -268,7 +268,7 @@ class ContextDialog(QtWidgets.QDialog): if self._set_context_asset: self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset self._assets_widget.setEnabled(False) - self._assets_widget.select_assets(self._set_context_asset) + self._assets_widget.select_asset_by_name(self._set_context_asset) self._set_asset_to_tasks_widget() else: self._assets_widget.setEnabled(True) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index 1bbe73381f..6dbeaad821 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -579,6 +579,10 @@ class AssetsField(BaseClickableFrame): """Change to asset names set with last `set_selected_items` call.""" self.set_selected_items(self._origin_value) + def confirm_value(self): + self._origin_value = copy.deepcopy(self._selected_items) + self._has_value_changed = False + class TasksComboboxProxy(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): @@ -785,6 +789,15 @@ class TasksCombobox(QtWidgets.QComboBox): self._set_is_valid(is_valid) + def confirm_value(self, asset_names): + new_task_name = self._selected_items[0] + self._origin_value = [ + (asset_name, new_task_name) + for asset_name in asset_names + ] + self._origin_selection = copy.deepcopy(self._selected_items) + self._has_value_changed = False + def set_selected_items(self, asset_task_combinations=None): """Set items for selected instances. @@ -919,6 +932,10 @@ class VariantInputWidget(PlaceholderLineEdit): """Change text of multiselection.""" self._multiselection_text = text + def confirm_value(self): + self._origin_value = copy.deepcopy(self._current_value) + self._has_value_changed = False + def _set_is_valid(self, valid): if valid == self._is_valid: return @@ -1110,6 +1127,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): btns_layout = QtWidgets.QHBoxLayout() btns_layout.setContentsMargins(0, 0, 0, 0) btns_layout.addStretch(1) + btns_layout.setSpacing(5) btns_layout.addWidget(submit_btn) btns_layout.addWidget(cancel_btn) @@ -1160,6 +1178,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): subset_names = set() invalid_tasks = False + asset_names = [] for instance in self._current_instances: new_variant_value = instance.get("variant") new_asset_name = instance.get("asset") @@ -1173,6 +1192,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if task_name is not None: new_task_name = task_name + asset_names.append(new_asset_name) try: new_subset_name = self._controller.get_subset_name( instance.creator_identifier, @@ -1210,6 +1230,15 @@ class GlobalAttrsWidget(QtWidgets.QWidget): self._set_btns_enabled(False) self._set_btns_visible(invalid_tasks) + if variant_value is not None: + self.variant_input.confirm_value() + + if asset_name is not None: + self.asset_value_widget.confirm_value() + + if task_name is not None: + self.task_value_widget.confirm_value(asset_names) + self.instance_context_changed.emit() def _on_cancel(self): diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 312cf1dd5c..2416763c27 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -15,6 +15,7 @@ from openpype.tools.utils import ( MessageOverlayObject, PixmapLabel, ) +from openpype.tools.utils.lib import center_window from .constants import ResetKeySequence from .publish_report_viewer import PublishReportViewerWidget @@ -529,6 +530,7 @@ class PublisherWindow(QtWidgets.QDialog): def _on_first_show(self): self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) + center_window(self) self._reset_on_show = self._reset_on_first_show def _on_show_timer(self): diff --git a/openpype/version.py b/openpype/version.py index d6839c9b70..8500b78966 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.5-nightly.1" +__version__ = "3.17.6-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index 633dafece1..c6f4880cdd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.17.4" # OpenPype +version = "3.17.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/server_addon/blender/server/settings/main.py b/server_addon/blender/server/settings/main.py index 4476ea709b..5eff276ef5 100644 --- a/server_addon/blender/server/settings/main.py +++ b/server_addon/blender/server/settings/main.py @@ -41,7 +41,7 @@ class BlenderSettings(BaseSettingsModel): default_factory=BlenderImageIOModel, title="Color Management (ImageIO)" ) - render_settings: RenderSettingsModel = Field( + RenderSettings: RenderSettingsModel = Field( default_factory=RenderSettingsModel, title="Render Settings") workfile_builder: TemplateWorkfileBaseOptions = Field( default_factory=TemplateWorkfileBaseOptions, @@ -61,7 +61,7 @@ DEFAULT_VALUES = { }, "set_frames_startup": True, "set_resolution_startup": True, - "render_settings": DEFAULT_RENDER_SETTINGS, + "RenderSettings": DEFAULT_RENDER_SETTINGS, "publish": DEFAULT_BLENDER_PUBLISH_SETTINGS, "workfile_builder": { "create_first_version": False, diff --git a/server_addon/core/server/settings/publish_plugins.py b/server_addon/core/server/settings/publish_plugins.py index 69a759465e..93d8db964d 100644 --- a/server_addon/core/server/settings/publish_plugins.py +++ b/server_addon/core/server/settings/publish_plugins.py @@ -21,7 +21,7 @@ class ValidateBaseModel(BaseSettingsModel): class CollectAnatomyInstanceDataModel(BaseSettingsModel): _isGroup = True follow_workfile_version: bool = Field( - True, title="Collect Anatomy Instance Data" + True, title="Follow workfile version" ) diff --git a/server_addon/core/server/settings/tools.py b/server_addon/core/server/settings/tools.py index d7c7b367b7..0dd9d396ae 100644 --- a/server_addon/core/server/settings/tools.py +++ b/server_addon/core/server/settings/tools.py @@ -489,7 +489,7 @@ DEFAULT_TOOLS_VALUES = { "template_name": "publish_online" }, { - "families": [ + "product_types": [ "tycache" ], "hosts": [ diff --git a/server_addon/deadline/server/settings/publish_plugins.py b/server_addon/deadline/server/settings/publish_plugins.py index 8d48695a9c..54b7ff57c1 100644 --- a/server_addon/deadline/server/settings/publish_plugins.py +++ b/server_addon/deadline/server/settings/publish_plugins.py @@ -267,7 +267,7 @@ class ProcessSubmittedJobOnFarmModel(BaseSettingsModel): title="Reviewable products filter", ) - @validator("aov_filter", "skip_integration_repre_list") + @validator("aov_filter") def validate_unique_names(cls, value): ensure_unique_names(value) return value diff --git a/server_addon/deadline/server/version.py b/server_addon/deadline/server/version.py index b3f4756216..ae7362549b 100644 --- a/server_addon/deadline/server/version.py +++ b/server_addon/deadline/server/version.py @@ -1 +1 @@ -__version__ = "0.1.2" +__version__ = "0.1.3" diff --git a/server_addon/houdini/server/settings/publish.py b/server_addon/houdini/server/settings/publish.py index 6615e34ca5..342bf957c1 100644 --- a/server_addon/houdini/server/settings/publish.py +++ b/server_addon/houdini/server/settings/publish.py @@ -3,7 +3,7 @@ from ayon_server.settings import BaseSettingsModel # Publish Plugins -class CollectRopFrameRangeModel(BaseSettingsModel): +class CollectAssetHandlesModel(BaseSettingsModel): """Collect Frame Range Disable this if you want the publisher to ignore start and end handles specified in the diff --git a/server_addon/houdini/server/settings/shelves.py b/server_addon/houdini/server/settings/shelves.py index 8d0512bdeb..133c18f77c 100644 --- a/server_addon/houdini/server/settings/shelves.py +++ b/server_addon/houdini/server/settings/shelves.py @@ -22,16 +22,46 @@ class ShelfDefinitionModel(BaseSettingsModel): ) -class ShelvesModel(BaseSettingsModel): - _layout = "expanded" - shelf_set_name: str = Field("", title="Shelfs set name") - +class AddShelfFileModel(BaseSettingsModel): shelf_set_source_path: MultiplatformPathModel = Field( default_factory=MultiplatformPathModel, - title="Shelf Set Path (optional)" + title="Shelf Set Path" ) + +class AddSetAndDefinitionsModel(BaseSettingsModel): + shelf_set_name: str = Field("", title="Shelf Set Name") shelf_definition: list[ShelfDefinitionModel] = Field( default_factory=list, - title="Shelf Definitions" + title="Shelves Definitions" + ) + + +def shelves_enum_options(): + return [ + { + "value": "add_shelf_file", + "label": "Add a .shelf file" + }, + { + "value": "add_set_and_definitions", + "label": "Add Shelf Set Name and Shelves Definitions" + } + ] + + +class ShelvesModel(BaseSettingsModel): + options: str = Field( + title="Options", + description="Switch between shelves manager options", + enum_resolver=shelves_enum_options, + conditionalEnum=True + ) + add_shelf_file: AddShelfFileModel = Field( + title="Add a .shelf file", + default_factory=AddShelfFileModel + ) + add_set_and_definitions: AddSetAndDefinitionsModel = Field( + title="Add Shelf Set Name and Shelves Definitions", + default_factory=AddSetAndDefinitionsModel ) diff --git a/server_addon/houdini/server/version.py b/server_addon/houdini/server/version.py index 01ef12070d..c49a95c357 100644 --- a/server_addon/houdini/server/version.py +++ b/server_addon/houdini/server/version.py @@ -1 +1 @@ -__version__ = "0.2.6" +__version__ = "0.2.8" diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py index a695b85e89..df8412391a 100644 --- a/server_addon/max/server/settings/publishers.py +++ b/server_addon/max/server/settings/publishers.py @@ -1,6 +1,30 @@ -from pydantic import Field +import json +from pydantic import Field, validator from ayon_server.settings import BaseSettingsModel +from ayon_server.exceptions import BadRequestException + + +class ValidateAttributesModel(BaseSettingsModel): + enabled: bool = Field(title="ValidateAttributes") + attributes: str = Field( + "{}", title="Attributes", widget="textarea") + + @validator("attributes") + def validate_json(cls, value): + if not value.strip(): + return "{}" + try: + converted_value = json.loads(value) + success = isinstance(converted_value, dict) + except json.JSONDecodeError: + success = False + + if not success: + raise BadRequestException( + "The attibutes can't be parsed as json object" + ) + return value class BasicValidateModel(BaseSettingsModel): @@ -15,6 +39,10 @@ class PublishersModel(BaseSettingsModel): title="Validate Frame Range", section="Validators" ) + ValidateAttributes: ValidateAttributesModel = Field( + default_factory=ValidateAttributesModel, + title="Validate Attributes" + ) DEFAULT_PUBLISH_SETTINGS = { @@ -22,5 +50,9 @@ DEFAULT_PUBLISH_SETTINGS = { "enabled": True, "optional": True, "active": True - } + }, + "ValidateAttributes": { + "enabled": False, + "attributes": "{}" + }, } diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py index 3dc1f76bc6..485f44ac21 100644 --- a/server_addon/max/server/version.py +++ b/server_addon/max/server/version.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/tests/conftest.py b/tests/conftest.py index 6e82c9917d..a862030fff 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,11 @@ def pytest_addoption(parser): help="True - keep test_db, test_openpype, outputted test files" ) + parser.addoption( + "--app_group", action="store", default=None, + help="Keep empty to use default application or explicit" + ) + parser.addoption( "--app_variant", action="store", default=None, help="Keep empty to locate latest installed variant or explicit" @@ -45,6 +50,11 @@ def persist(request): return request.config.getoption("--persist") +@pytest.fixture(scope="module") +def app_group(request): + return request.config.getoption("--app_group") + + @pytest.fixture(scope="module") def app_variant(request): return request.config.getoption("--app_variant") diff --git a/tests/integration/hosts/maya/lib.py b/tests/integration/hosts/maya/lib.py index f27d516605..04ddb765a4 100644 --- a/tests/integration/hosts/maya/lib.py +++ b/tests/integration/hosts/maya/lib.py @@ -33,7 +33,7 @@ class MayaHostFixtures(HostFixtures): yield dest_path @pytest.fixture(scope="module") - def startup_scripts(self, monkeypatch_session): + def startup_scripts(self, monkeypatch_session, download_test_data): """Points Maya to userSetup file from input data""" startup_path = os.path.join( os.path.dirname(__file__), "input", "startup" @@ -44,6 +44,11 @@ class MayaHostFixtures(HostFixtures): "{}{}{}".format(startup_path, os.pathsep, original_pythonpath) ) + monkeypatch_session.setenv( + "MAYA_CMD_FILE_OUTPUT", + os.path.join(download_test_data, "output.log") + ) + @pytest.fixture(scope="module") def skip_compare_folders(self): yield [] diff --git a/tests/integration/hosts/maya/test_publish_in_maya.py b/tests/integration/hosts/maya/test_publish_in_maya.py index b7ee228aae..be8c74e0b8 100644 --- a/tests/integration/hosts/maya/test_publish_in_maya.py +++ b/tests/integration/hosts/maya/test_publish_in_maya.py @@ -1,3 +1,6 @@ +import re +import os + from tests.lib.assert_classes import DBAssert from tests.integration.hosts.maya.lib import MayaLocalPublishTestClass @@ -35,6 +38,32 @@ class TestPublishInMaya(MayaLocalPublishTestClass): TIMEOUT = 120 # publish timeout + def test_publish( + self, + dbcon, + publish_finished, + download_test_data + ): + """Testing Pyblish and Python logs within Maya.""" + + # All maya output via MAYA_CMD_FILE_OUTPUT env var during test run + logging_path = os.path.join(download_test_data, "output.log") + with open(logging_path, "r") as f: + logging_output = f.read() + + print(("-" * 50) + "LOGGING" + ("-" * 50)) + print(logging_output) + + # Check for pyblish errors. + error_regex = r"pyblish \(ERROR\)((.|\n)*?)((pyblish \())" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + + # Check for python errors. + error_regex = r"// Error((.|\n)*)" + matches = re.findall(error_regex, logging_output) + assert not matches, matches[0][0] + def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index 277b332e19..3b0611e2a0 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -10,6 +10,7 @@ import glob import platform import requests import re +import time from tests.lib.db_handler import DBHandler from tests.lib.file_handler import RemoteFileHandler @@ -248,19 +249,22 @@ class PublishTest(ModuleUnitTest): SETUP_ONLY = False @pytest.fixture(scope="module") - def app_name(self, app_variant): + def app_name(self, app_variant, app_group): """Returns calculated value for ApplicationManager. Eg.(nuke/12-2)""" from openpype.lib import ApplicationManager app_variant = app_variant or self.APP_VARIANT + app_group = app_group or self.APP_GROUP application_manager = ApplicationManager() if not app_variant: variant = ( application_manager.find_latest_available_variant_for_group( - self.APP_GROUP)) + app_group + ) + ) app_variant = variant.name - yield "{}/{}".format(self.APP_GROUP, app_variant) + yield "{}/{}".format(app_group, app_variant) @pytest.fixture(scope="module") def app_args(self, download_test_data): @@ -331,7 +335,7 @@ class PublishTest(ModuleUnitTest): print("Creating only setup for test, not launching app") yield False return - import time + time_start = time.time() timeout = timeout or self.TIMEOUT timeout = float(timeout) diff --git a/website/docs/artist_hosts_3dsmax.md b/website/docs/artist_hosts_3dsmax.md index fffab8ca5d..6f23a19103 100644 --- a/website/docs/artist_hosts_3dsmax.md +++ b/website/docs/artist_hosts_3dsmax.md @@ -118,4 +118,28 @@ Current OpenPype integration (ver 3.15.0) supports only ```PointCache```, ```Ca This part of documentation is still work in progress. ::: +## Validators + +Current Openpype integration supports different validators such as Frame Range and Attributes. +Some validators are mandatory while some are optional and user can choose to enable them in the setting. + +**Validate Frame Range**: Optional Validator for checking Frame Range + +**Validate Attributes**: Optional Validator for checking if object properties' attributes are valid + in MaxWrapper Class. +:::note + Users can write the properties' attributes they want to check in dict format in the setting + before validation. The attributes are then to be converted into Maxscript and do a check. + E.g. ```renderers.current.separateAovFiles``` and ```renderers.current.PrimaryGIEngine``` + User can put the attributes in the dict format below + ``` + { + "renderer.current":{ + "separateAovFiles" : True + "PrimaryGIEngine": "#RS_GIENGINE_BRUTE_FORCE" + } + } + ``` + ![Validate Attribute Setting](assets/3dsmax_validate_attributes.png) +::: ## ...to be added diff --git a/website/docs/assets/3dsmax_validate_attributes.png b/website/docs/assets/3dsmax_validate_attributes.png new file mode 100644 index 0000000000..5af8236188 Binary files /dev/null and b/website/docs/assets/3dsmax_validate_attributes.png differ