From d7b6582cd38f1dd5a74036778ad22bb36b0dc54e Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 24 Dec 2021 15:11:53 +0100 Subject: [PATCH 001/152] Fix Maya 2022 Python 3 compatibility: types.BooleanType and types.ListType don't exist in Py3+ --- .../hosts/maya/plugins/publish/validate_ass_relative_paths.py | 4 ++-- .../maya/plugins/publish/validate_vray_referenced_aovs.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py index 3625d4ab32..5fb9bd98b1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py +++ b/openpype/hosts/maya/plugins/publish/validate_ass_relative_paths.py @@ -110,9 +110,9 @@ class ValidateAssRelativePaths(pyblish.api.InstancePlugin): Maya API will return a list of values, which need to be properly handled to evaluate properly. """ - if isinstance(attr_val, types.BooleanType): + if isinstance(attr_val, bool): return attr_val - elif isinstance(attr_val, (types.ListType, types.GeneratorType)): + elif isinstance(attr_val, (list, types.GeneratorType)): return any(attr_val) else: return bool(attr_val) diff --git a/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py b/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py index 6cfbd4049b..7a48c29b7d 100644 --- a/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py +++ b/openpype/hosts/maya/plugins/publish/validate_vray_referenced_aovs.py @@ -82,9 +82,9 @@ class ValidateVrayReferencedAOVs(pyblish.api.InstancePlugin): bool: cast Maya attribute to Pythons boolean value. """ - if isinstance(attr_val, types.BooleanType): + if isinstance(attr_val, bool): return attr_val - elif isinstance(attr_val, (types.ListType, types.GeneratorType)): + elif isinstance(attr_val, (list, types.GeneratorType)): return any(attr_val) else: return bool(attr_val) From cb99b0acf791c970aeef7c04c1f01487f8e9f260 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 12:37:46 +0100 Subject: [PATCH 002/152] Ensure `server_aliases` is of type `list` instead of `dict_keys` in Py3+ This resolves an issue where otherwise `lib.imprint` will fail on `self.data` for this Creator plug-in --- openpype/hosts/maya/plugins/create/create_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 85919d1166..9e94996734 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -254,7 +254,7 @@ class CreateRender(plugin.Creator): # get pools pool_names = [] - self.server_aliases = self.deadline_servers.keys() + self.server_aliases = list(self.deadline_servers.keys()) self.data["deadlineServers"] = self.server_aliases self.data["suspendPublishJob"] = False self.data["review"] = True From 74cfde55ec44b70bbc8628e0b4762e114877f89c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 13:18:12 +0100 Subject: [PATCH 003/152] Fix `dict_keys` object is not subscriptable (Py3+) --- openpype/hosts/maya/plugins/publish/collect_render.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index ac1e495f08..745954e032 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -234,13 +234,14 @@ class CollectMayaRender(pyblish.api.ContextPlugin): publish_meta_path = None for aov in exp_files: full_paths = [] - for file in aov[aov.keys()[0]]: + aov_first_key = list(aov.keys())[0] + for file in aov[aov_first_key]: full_path = os.path.join(workspace, default_render_file, file) full_path = full_path.replace("\\", "/") full_paths.append(full_path) publish_meta_path = os.path.dirname(full_path) - aov_dict[aov.keys()[0]] = full_paths + aov_dict[aov_first_key] = full_paths frame_start_render = int(self.get_render_attribute( "startFrame", layer=layer_name)) From d09065576421d207e2f3e2cb7ab7b80b36fae3a3 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 14:52:59 +0100 Subject: [PATCH 004/152] Support OpenPype icon override in Maya Toolbox in Maya 2022+ - Also refactor import maya.cmds as `mc` to `cmds` to match with other code in the OpenPype code base --- openpype/hosts/maya/api/customize.py | 57 +++++++++++++++++----------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 8474262626..83f481f56e 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -5,7 +5,7 @@ import logging from functools import partial -import maya.cmds as mc +import maya.cmds as cmds import maya.mel as mel from avalon.maya import pipeline @@ -31,9 +31,9 @@ def override_component_mask_commands(): log.info("Installing override_component_mask_commands..") # Get all object mask buttons - buttons = mc.formLayout("objectMaskIcons", - query=True, - childArray=True) + buttons = cmds.formLayout("objectMaskIcons", + query=True, + childArray=True) # Skip the triangle list item buttons = [btn for btn in buttons if btn != "objPickMenuLayout"] @@ -44,14 +44,14 @@ def override_component_mask_commands(): # toggle the others based on whether any of the buttons # was remaining active after the toggle, if not then # enable all - if mc.getModifiers() == 4: # = CTRL + if cmds.getModifiers() == 4: # = CTRL state = True - active = [mc.iconTextCheckBox(btn, query=True, value=True) for btn - in buttons] + active = [cmds.iconTextCheckBox(btn, query=True, value=True) + for btn in buttons] if any(active): - mc.selectType(allObjects=False) + cmds.selectType(allObjects=False) else: - mc.selectType(allObjects=True) + cmds.selectType(allObjects=True) # Replace #1 with the current button state cmd = raw_command.replace(" #1", " {}".format(int(state))) @@ -64,13 +64,13 @@ def override_component_mask_commands(): # try to implement the fix. (This also allows us to # "uninstall" the behavior later) if btn not in COMPONENT_MASK_ORIGINAL: - original = mc.iconTextCheckBox(btn, query=True, cc=True) + original = cmds.iconTextCheckBox(btn, query=True, cc=True) COMPONENT_MASK_ORIGINAL[btn] = original # Assign the special callback original = COMPONENT_MASK_ORIGINAL[btn] new_fn = partial(on_changed_callback, original) - mc.iconTextCheckBox(btn, edit=True, cc=new_fn) + cmds.iconTextCheckBox(btn, edit=True, cc=new_fn) def override_toolbox_ui(): @@ -78,18 +78,29 @@ def override_toolbox_ui(): icons = resources.get_resource("icons") # Ensure the maya web icon on toolbox exists - web_button = "ToolBox|MainToolboxLayout|mayaWebButton" - if not mc.iconTextButton(web_button, query=True, exists=True): + maya_version = int(cmds.about(version=True)) + if maya_version >= 2022: + # Maya 2022+ has an updated toolbox with a different web + # button name and type + web_button = "ToolBox|MainToolboxLayout|mayaHomeToolboxButton" + button_fn = cmds.iconTextStaticLabel + else: + web_button = "ToolBox|MainToolboxLayout|mayaWebButton" + button_fn = cmds.iconTextButton + + if not button_fn(web_button, query=True, exists=True): + # Button does not exist + log.warning("Can't find Maya Home/Web button to override toolbox ui..") return - mc.iconTextButton(web_button, edit=True, visible=False) + button_fn(web_button, edit=True, visible=False) # real = 32, but 36 with padding - according to toolbox mel script icon_size = 36 parent = web_button.rsplit("|", 1)[0] # Ensure the parent is a formLayout - if not mc.objectTypeUI(parent) == "formLayout": + if not cmds.objectTypeUI(parent) == "formLayout": return # Create our controls @@ -106,7 +117,7 @@ def override_toolbox_ui(): if look_assigner is not None: controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_lookmanager", annotation="Look Manager", label="Look Manager", @@ -120,7 +131,7 @@ def override_toolbox_ui(): ) controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_workfiles", annotation="Work Files", label="Work Files", @@ -136,7 +147,7 @@ def override_toolbox_ui(): ) controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_loader", annotation="Loader", label="Loader", @@ -152,7 +163,7 @@ def override_toolbox_ui(): ) controls.append( - mc.iconTextButton( + cmds.iconTextButton( "pype_toolbox_manager", annotation="Inventory", label="Inventory", @@ -173,7 +184,7 @@ def override_toolbox_ui(): for i, control in enumerate(controls): previous = controls[i - 1] if i > 0 else web_button - mc.formLayout(parent, edit=True, - attachControl=[control, "bottom", 0, previous], - attachForm=([control, "left", 1], - [control, "right", 1])) + cmds.formLayout(parent, edit=True, + attachControl=[control, "bottom", 0, previous], + attachForm=([control, "left", 1], + [control, "right", 1])) From 19d2faac7ec6cd0dd793eae68e54563ed7b33c50 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 15:19:24 +0100 Subject: [PATCH 005/152] Fix "Set Frame Range" loaders to retrieve the correct data from the version --- openpype/hosts/houdini/plugins/load/actions.py | 13 ++++++------- openpype/hosts/maya/plugins/load/actions.py | 5 ++--- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/houdini/plugins/load/actions.py b/openpype/hosts/houdini/plugins/load/actions.py index 6e9410ff58..acdb998c16 100644 --- a/openpype/hosts/houdini/plugins/load/actions.py +++ b/openpype/hosts/houdini/plugins/load/actions.py @@ -29,8 +29,8 @@ class SetFrameRangeLoader(api.Loader): version = context["version"] version_data = version.get("data", {}) - start = version_data.get("startFrame", None) - end = version_data.get("endFrame", None) + start = version_data.get("frameStart", None) + end = version_data.get("frameEnd", None) if start is None or end is None: print( @@ -67,8 +67,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader): version = context["version"] version_data = version.get("data", {}) - start = version_data.get("startFrame", None) - end = version_data.get("endFrame", None) + start = version_data.get("frameStart", None) + end = version_data.get("frameEnd", None) if start is None or end is None: print( @@ -78,9 +78,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader): return # Include handles - handles = version_data.get("handles", 0) - start -= handles - end += handles + start -= version_data.get("handleStart", 0) + end += version_data.get("handleEnd", 0) hou.playbar.setFrameRange(start, end) hou.playbar.setPlaybackRange(start, end) diff --git a/openpype/hosts/maya/plugins/load/actions.py b/openpype/hosts/maya/plugins/load/actions.py index 1a9adf6142..f55aa80b9e 100644 --- a/openpype/hosts/maya/plugins/load/actions.py +++ b/openpype/hosts/maya/plugins/load/actions.py @@ -68,9 +68,8 @@ class SetFrameRangeWithHandlesLoader(api.Loader): return # Include handles - handles = version_data.get("handles", 0) - start -= handles - end += handles + start -= version_data.get("handleStart", 0) + end += version_data.get("handleEnd", 0) cmds.playbackOptions(minTime=start, maxTime=end, From 7cc091054957a9bc3cd6cdcdac4963ddc899aeb1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 28 Dec 2021 18:19:33 +0100 Subject: [PATCH 006/152] Python 3 compatibility + correctly find maketx.exe on Windows --- openpype/hosts/maya/api/lib.py | 9 ++++++++- openpype/hosts/maya/plugins/publish/extract_look.py | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..6dc27b459d 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -5,6 +5,7 @@ import os import platform import uuid import math +import sys import json import logging @@ -130,7 +131,13 @@ def float_round(num, places=0, direction=ceil): def pairwise(iterable): """s -> (s0,s1), (s2,s3), (s4, s5), ...""" a = iter(iterable) - return itertools.izip(a, a) + + if sys.version_info[0] == 2: + izip = itertools.izip + else: + izip = zip + + return izip(a, a) def unique(name): diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 953539f65c..3b3f17aa7f 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -4,6 +4,7 @@ import os import sys import json import tempfile +import platform import contextlib import subprocess from collections import OrderedDict @@ -58,6 +59,11 @@ def maketx(source, destination, *args): from openpype.lib import get_oiio_tools_path maketx_path = get_oiio_tools_path("maketx") + + if platform.system().lower() == "windows": + # Ensure .exe extension + maketx_path += ".exe" + if not os.path.exists(maketx_path): print( "OIIO tool not found in {}".format(maketx_path)) @@ -212,7 +218,7 @@ class ExtractLook(openpype.api.Extractor): self.log.info("Extract sets (%s) ..." % _scene_type) lookdata = instance.data["lookData"] relationships = lookdata["relationships"] - sets = relationships.keys() + sets = list(relationships.keys()) if not sets: self.log.info("No sets found") return From 4d7ff9d54274272c89ebd0438ac489b318037edd Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 09:20:30 +0100 Subject: [PATCH 007/152] Fix `xrange` doesn't exist in Py3+ --- .../maya/plugins/publish/validate_mesh_overlapping_uvs.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index 57cf0803a4..c06f48f5be 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -2,10 +2,16 @@ import pyblish.api import openpype.api import openpype.hosts.maya.api.action import math +import sys import maya.api.OpenMaya as om import pymel.core as pm +if sys.version_info[0] != 2: + # Py3+ does not have `xrange` so we mimic it to allow to use it in Py2 + xrange = range + + class GetOverlappingUVs(object): def _createBoundingCircle(self, meshfn): From bd726f8966481af7bcebbb3011cc4bb36836c4cd Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 09:23:46 +0100 Subject: [PATCH 008/152] Fix `xrange` doesn't exist in Py3+ (use `six` instead) --- .../maya/plugins/publish/validate_mesh_overlapping_uvs.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index c06f48f5be..3c1bf3cddc 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -6,10 +6,7 @@ import sys import maya.api.OpenMaya as om import pymel.core as pm - -if sys.version_info[0] != 2: - # Py3+ does not have `xrange` so we mimic it to allow to use it in Py2 - xrange = range +from six.moves import xrange class GetOverlappingUVs(object): From cf9899b0385c2824ba97973b86bd4153a4e8b496 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:19:59 +0100 Subject: [PATCH 009/152] Remove unused import of 'sys' --- .../hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py index 3c1bf3cddc..c4e823fcba 100644 --- a/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py +++ b/openpype/hosts/maya/plugins/publish/validate_mesh_overlapping_uvs.py @@ -2,7 +2,6 @@ import pyblish.api import openpype.api import openpype.hosts.maya.api.action import math -import sys import maya.api.OpenMaya as om import pymel.core as pm From 2b36eea3258d4d1ba69d1d1a0a5864037fd89f24 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 30 Dec 2021 14:39:35 +0100 Subject: [PATCH 010/152] Avoid 'dict_keys' issue in Py3+ --- openpype/hosts/maya/plugins/publish/collect_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index d39750e917..b6a76f1e21 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -320,7 +320,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Collect file nodes used by shading engines (if we have any) files = [] - look_sets = sets.keys() + look_sets = list(sets.keys()) shader_attrs = [ "surfaceShader", "volumeShader", From fa77a4934909b529ad38b79f9fa176fac8913c29 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 4 Jan 2022 14:12:21 +0100 Subject: [PATCH 011/152] Simplify logic that falls back to first valid name. - This fixes an issue with Maya 2022.0 where the maya web button was not yet renamed - Tested in Maya 2019.3.1, Maya 2020.4 and Maya 2022.1 --- openpype/hosts/maya/api/customize.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/api/customize.py b/openpype/hosts/maya/api/customize.py index 83f481f56e..3ee9475035 100644 --- a/openpype/hosts/maya/api/customize.py +++ b/openpype/hosts/maya/api/customize.py @@ -78,22 +78,22 @@ def override_toolbox_ui(): icons = resources.get_resource("icons") # Ensure the maya web icon on toolbox exists - maya_version = int(cmds.about(version=True)) - if maya_version >= 2022: - # Maya 2022+ has an updated toolbox with a different web - # button name and type - web_button = "ToolBox|MainToolboxLayout|mayaHomeToolboxButton" - button_fn = cmds.iconTextStaticLabel + button_names = [ + # Maya 2022.1+ with maya.cmds.iconTextStaticLabel + "ToolBox|MainToolboxLayout|mayaHomeToolboxButton", + # Older with maya.cmds.iconTextButton + "ToolBox|MainToolboxLayout|mayaWebButton" + ] + for name in button_names: + if cmds.control(name, query=True, exists=True): + web_button = name + break else: - web_button = "ToolBox|MainToolboxLayout|mayaWebButton" - button_fn = cmds.iconTextButton - - if not button_fn(web_button, query=True, exists=True): # Button does not exist log.warning("Can't find Maya Home/Web button to override toolbox ui..") return - button_fn(web_button, edit=True, visible=False) + cmds.control(web_button, edit=True, visible=False) # real = 32, but 36 with padding - according to toolbox mel script icon_size = 36 From d3eca27140e7068dc9a05f1336992da8b9060f56 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:07:49 +0100 Subject: [PATCH 012/152] Avoid python version check and use `from six.moves import zip` --- openpype/hosts/maya/api/lib.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 6dc27b459d..b5bbf122ea 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -9,7 +9,6 @@ import sys import json import logging -import itertools import contextlib from collections import OrderedDict, defaultdict from math import ceil @@ -130,14 +129,10 @@ def float_round(num, places=0, direction=ceil): def pairwise(iterable): """s -> (s0,s1), (s2,s3), (s4, s5), ...""" + from six.moves import zip + a = iter(iterable) - - if sys.version_info[0] == 2: - izip = itertools.izip - else: - izip = zip - - return izip(a, a) + return zip(a, a) def unique(name): From eca18fa5bbff712ba379c2946fb500f99da72b47 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:09:56 +0100 Subject: [PATCH 013/152] Fix Py3 compatibility, refactor itertools.izip_longest --- .../hosts/maya/plugins/publish/extract_camera_mayaScene.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 888dc636b2..fdd36cf0b4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -44,7 +44,8 @@ def grouper(iterable, n, fillvalue=None): """ args = [iter(iterable)] * n - return itertools.izip_longest(fillvalue=fillvalue, *args) + from six.moves import zip_longest + return zip_longest(fillvalue=fillvalue, *args) def unlock(plug): From cb5d3e29eaa4b1280ebdd9a5f19e8dcbef739411 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:11:24 +0100 Subject: [PATCH 014/152] Remove unused import sys --- openpype/hosts/maya/api/lib.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index b5bbf122ea..21d5e581a5 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -5,7 +5,6 @@ import os import platform import uuid import math -import sys import json import logging From 3b63abfa6a2b8b54e061834b3d42b5ee013a9627 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 02:17:25 +0100 Subject: [PATCH 015/152] Remove "(Testing Only)" from defaults for Maya 2022 --- openpype/settings/defaults/system_settings/applications.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 1cbe09f576..f855117c07 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -93,7 +93,7 @@ } }, "__dynamic_keys_labels__": { - "2022": "2022 (Testing Only)" + "2022": "2022" } } }, From cee20eb256eecb083988c5834c474c845f247396 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 11:25:33 +0100 Subject: [PATCH 016/152] Refactor other references to 'startFrame'/'endFrame' to 'frameStart'/'frameEnd' --- openpype/hosts/fusion/scripts/fusion_switch_shot.py | 2 +- .../plugins/publish/validate_abc_primitive_to_detail.py | 2 +- .../houdini/plugins/publish/validate_alembic_input_node.py | 2 +- .../plugins/publish/submit_houdini_render_deadline.py | 4 ++-- .../deadline/plugins/publish/submit_publish_job.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py index 05b577c8ba..fd4128d840 100644 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py @@ -176,7 +176,7 @@ def update_frame_range(comp, representations): versions = list(versions) versions = [v for v in versions - if v["data"].get("startFrame", None) is not None] + if v["data"].get("frameStart", None) is not None] if not versions: log.warning("No versions loaded to match frame range to.\n") diff --git a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py index 8fe1b44b7a..3e17d3e8de 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py +++ b/openpype/hosts/houdini/plugins/publish/validate_abc_primitive_to_detail.py @@ -65,7 +65,7 @@ class ValidateAbcPrimitiveToDetail(pyblish.api.InstancePlugin): cls.log.debug("Checking with path attribute: %s" % path_attr) # Check if the primitive attribute exists - frame = instance.data.get("startFrame", 0) + frame = instance.data.get("frameStart", 0) geo = output.geometryAtFrame(frame) # If there are no primitives on the start frame then it might be diff --git a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py index 17c9da837a..8d7e3b611f 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py +++ b/openpype/hosts/houdini/plugins/publish/validate_alembic_input_node.py @@ -38,7 +38,7 @@ class ValidateAlembicInputNode(pyblish.api.InstancePlugin): cls.log.warning("No geometry output node found, skipping check..") return - frame = instance.data.get("startFrame", 0) + frame = instance.data.get("frameStart", 0) geo = node.geometryAtFrame(frame) invalid = False diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py index fa146c0d30..ace2aabb85 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py @@ -50,8 +50,8 @@ class HoudiniSubmitRenderDeadline(pyblish.api.InstancePlugin): # StartFrame to EndFrame by byFrameStep frames = "{start}-{end}x{step}".format( - start=int(instance.data["startFrame"]), - end=int(instance.data["endFrame"]), + start=int(instance.data["frameStart"]), + end=int(instance.data["frameEnd"]), step=int(instance.data["byFrameStep"]), ) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py index 516bd755d0..a5ccbe9cf9 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/submit_publish_job.py @@ -311,8 +311,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): import speedcopy self.log.info("Preparing to copy ...") - start = instance.data.get("startFrame") - end = instance.data.get("endFrame") + start = instance.data.get("frameStart") + end = instance.data.get("frameEnd") # get latest version of subset # this will stop if subset wasn't published yet From 7621f02a47995ead84a102793c9584a2b45f21dc Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 12:35:25 +0100 Subject: [PATCH 017/152] Refactor "startFrame" -> "frameStart" --- .../plugins/publish/validate_primitive_hierarchy_paths.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py index 3c15532be8..1eb36763bb 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_primitive_hierarchy_paths.py @@ -51,7 +51,7 @@ class ValidatePrimitiveHierarchyPaths(pyblish.api.InstancePlugin): cls.log.debug("Checking for attribute: %s" % path_attr) # Check if the primitive attribute exists - frame = instance.data.get("startFrame", 0) + frame = instance.data.get("frameStart", 0) geo = output.geometryAtFrame(frame) # If there are no primitives on the current frame then we can't From 7bf02646bc6896ff96a3c07ffff737d269924266 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 13:38:19 +0100 Subject: [PATCH 018/152] Maya: Refactor `handles` to correctly extract handles with the frame range in export using `handleStart` and `handleEnd` as input --- openpype/hosts/maya/api/lib.py | 3 ++- .../maya/plugins/publish/extract_animation.py | 8 ++------ .../hosts/maya/plugins/publish/extract_ass.py | 8 ++------ .../plugins/publish/extract_camera_alembic.py | 19 ++++--------------- .../publish/extract_camera_mayaScene.py | 18 ++++-------------- .../hosts/maya/plugins/publish/extract_fbx.py | 8 ++------ .../maya/plugins/publish/extract_vrayproxy.py | 13 +++++++++---- .../plugins/publish/extract_yeti_cache.py | 4 ++-- 8 files changed, 27 insertions(+), 54 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 52ebcaff64..d003203959 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -297,7 +297,8 @@ def collect_animation_data(): data = OrderedDict() data["frameStart"] = start data["frameEnd"] = end - data["handles"] = 0 + data["handleStart"] = 0 + data["handleEnd"] = 0 data["step"] = 1.0 data["fps"] = fps diff --git a/openpype/hosts/maya/plugins/publish/extract_animation.py b/openpype/hosts/maya/plugins/publish/extract_animation.py index 7ecc40a68d..e0ed4411a8 100644 --- a/openpype/hosts/maya/plugins/publish/extract_animation.py +++ b/openpype/hosts/maya/plugins/publish/extract_animation.py @@ -35,12 +35,8 @@ class ExtractAnimation(openpype.api.Extractor): fullPath=True) or [] # Collect the start and end including handles - start = instance.data["frameStart"] - end = instance.data["frameEnd"] - handles = instance.data.get("handles", 0) or 0 - if handles: - start -= handles - end += handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting animation..") dirname = self.staging_dir(instance) diff --git a/openpype/hosts/maya/plugins/publish/extract_ass.py b/openpype/hosts/maya/plugins/publish/extract_ass.py index 7461ccdf78..9025709178 100644 --- a/openpype/hosts/maya/plugins/publish/extract_ass.py +++ b/openpype/hosts/maya/plugins/publish/extract_ass.py @@ -38,13 +38,9 @@ class ExtractAssStandin(openpype.api.Extractor): self.log.info("Extracting ass sequence") # Collect the start and end including handles - start = instance.data.get("frameStart", 1) - end = instance.data.get("frameEnd", 1) - handles = instance.data.get("handles", 0) + start = instance.data.get("frameStartHandle", 1) + end = instance.data.get("frameEndHandle", 1) step = instance.data.get("step", 0) - if handles: - start -= handles - end += handles exported_files = cmds.arnoldExportAss(filename=file_path, selected=True, diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py index 8950ed6254..b6f1826098 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_alembic.py @@ -23,17 +23,9 @@ class ExtractCameraAlembic(openpype.api.Extractor): def process(self, instance): - # get settings - framerange = [instance.data.get("frameStart", 1), - instance.data.get("frameEnd", 1)] - handle_start = instance.data.get("handleStart", 0) - handle_end = instance.data.get("handleEnd", 0) - - # TODO: deprecated attribute "handles" - - if handle_start is None: - handle_start = instance.data.get("handles", 0) - handle_end = instance.data.get("handles", 0) + # Collect the start and end including handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] step = instance.data.get("step", 1.0) bake_to_worldspace = instance.data("bakeToWorldSpace", True) @@ -63,10 +55,7 @@ class ExtractCameraAlembic(openpype.api.Extractor): job_str = ' -selection -dataFormat "ogawa" ' job_str += ' -attrPrefix cb' - job_str += ' -frameRange {0} {1} '.format(framerange[0] - - handle_start, - framerange[1] - + handle_end) + job_str += ' -frameRange {0} {1} '.format(start, end) job_str += ' -step {0} '.format(step) if bake_to_worldspace: diff --git a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py index 888dc636b2..bac00dc711 100644 --- a/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py +++ b/openpype/hosts/maya/plugins/publish/extract_camera_mayaScene.py @@ -118,19 +118,9 @@ class ExtractCameraMayaScene(openpype.api.Extractor): # no preset found pass - framerange = [instance.data.get("frameStart", 1), - instance.data.get("frameEnd", 1)] - handle_start = instance.data.get("handleStart", 0) - handle_end = instance.data.get("handleEnd", 0) - - # TODO: deprecated attribute "handles" - - if handle_start is None: - handle_start = instance.data.get("handles", 0) - handle_end = instance.data.get("handles", 0) - - range_with_handles = [framerange[0] - handle_start, - framerange[1] + handle_end] + # Collect the start and end including handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] step = instance.data.get("step", 1.0) bake_to_worldspace = instance.data("bakeToWorldSpace", True) @@ -165,7 +155,7 @@ class ExtractCameraMayaScene(openpype.api.Extractor): "Performing camera bakes: {}".format(transform)) baked = lib.bake_to_world_space( transform, - frame_range=range_with_handles, + frame_range=[start, end], step=step ) baked_shapes = cmds.ls(baked, diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index 720a61b0a7..4a92e31ccb 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -166,12 +166,8 @@ class ExtractFBX(openpype.api.Extractor): self.log.info("Export options: {0}".format(options)) # Collect the start and end including handles - start = instance.data["frameStart"] - end = instance.data["frameEnd"] - handles = instance.data.get("handles", 0) - if handles: - start -= handles - end += handles + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] options['bakeComplexStart'] = start options['bakeComplexEnd'] = end diff --git a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py index 7103601b85..8bfbbd525d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py +++ b/openpype/hosts/maya/plugins/publish/extract_vrayproxy.py @@ -28,14 +28,19 @@ class ExtractVRayProxy(openpype.api.Extractor): if not anim_on: # Remove animation information because it is not required for # non-animated subsets - instance.data.pop("frameStart", None) - instance.data.pop("frameEnd", None) + keys = ["frameStart", "frameEnd", + "handleStart", "handleEnd", + "frameStartHandle", "frameEndHandle", + # Backwards compatibility + "handles"] + for key in keys: + instance.data.pop(key, None) start_frame = 1 end_frame = 1 else: - start_frame = instance.data["frameStart"] - end_frame = instance.data["frameEnd"] + start_frame = instance.data["frameStartHandle"] + end_frame = instance.data["frameEndHandle"] vertex_colors = instance.data.get("vertexColors", False) diff --git a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py index 05fe79ecc5..0d85708789 100644 --- a/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py +++ b/openpype/hosts/maya/plugins/publish/extract_yeti_cache.py @@ -29,8 +29,8 @@ class ExtractYetiCache(openpype.api.Extractor): data_file = os.path.join(dirname, "yeti.fursettings") # Collect information for writing cache - start_frame = instance.data.get("frameStart") - end_frame = instance.data.get("frameEnd") + start_frame = instance.data.get("frameStartHandle") + end_frame = instance.data.get("frameEndHandle") preroll = instance.data.get("preroll") if preroll > 0: start_frame -= preroll From e00e26d3eb41652859a825b93f72e2b31aa0b154 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 5 Jan 2022 14:46:06 +0100 Subject: [PATCH 019/152] Refactor 'handles' to 'handleStart' & 'handleEnd' with backwards compatibility --- .../modules/ftrack_lib.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 26b197ee1d..7a0efe079e 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -138,12 +138,24 @@ class FtrackComponentCreator: if name == "ftrackreview-mp4": duration = data["duration"] - handles = data["handles"] + + handle_start = data.get("handleStart", None) + handle_end = data.get("handleEnd", None) + if handle_start is not None: + duration += handle_start + if handle_end is not None: + duration += handle_end + if handle_start is None and handle_end is None: + # Backwards compatibility; old style 'handles' + # We multiply by two because old-style handles defined + # both the handle start and handle end + duration += data.get("handles", 0) * 2 + fps = data["fps"] component_data["metadata"] = { 'ftr_meta': json.dumps({ 'frameIn': int(0), - 'frameOut': int(duration + (handles * 2)), + 'frameOut': int(duration), 'frameRate': float(fps) }) } From 40978d7ed488e3a1aab2f2ae9344804cd897eb22 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Sun, 23 Jan 2022 14:30:00 +0100 Subject: [PATCH 020/152] Clarify logic of falling back to first server url --- openpype/hosts/maya/plugins/create/create_render.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 9e94996734..f1a8acadf4 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -287,15 +287,12 @@ class CreateRender(plugin.Creator): raise RuntimeError("Both Deadline and Muster are enabled") if deadline_enabled: - # if default server is not between selected, use first one for - # initial list of pools. try: deadline_url = self.deadline_servers["default"] except KeyError: - deadline_url = [ - self.deadline_servers[k] - for k in self.deadline_servers.keys() - ][0] + # if 'default' server is not between selected, + # use first one for initial list of pools. + deadline_url = next(iter(self.deadline_servers.values())) pool_names = self._get_deadline_pools(deadline_url) From 33ac3186c776160a313a3b82c6124057dbcce8b7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 25 Jan 2022 15:18:41 +0100 Subject: [PATCH 021/152] fix job killer action --- .../event_handlers_user/action_job_killer.py | 175 ++++++++++-------- 1 file changed, 96 insertions(+), 79 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py index af24e0280d..f489c0c54c 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_job_killer.py @@ -3,111 +3,128 @@ from openpype_modules.ftrack.lib import BaseAction, statics_icon class JobKiller(BaseAction): - '''Edit meta data action.''' + """Kill jobs that are marked as running.""" - #: Action identifier. - identifier = 'job.killer' - #: Action label. + identifier = "job.killer" label = "OpenPype Admin" - variant = '- Job Killer' - #: Action description. - description = 'Killing selected running jobs' - #: roles that are allowed to register this action + variant = "- Job Killer" + description = "Killing selected running jobs" icon = statics_icon("ftrack", "action_icons", "OpenPypeAdmin.svg") settings_key = "job_killer" def discover(self, session, entities, event): - ''' Validation ''' + """Check if action is available for user role.""" return self.valid_roles(session, entities, event) def interface(self, session, entities, event): - if not event['data'].get('values', {}): - title = 'Select jobs to kill' - - jobs = session.query( - 'select id, status from Job' - ' where status in ("queued", "running")' - ).all() - - items = [] - - item_splitter = {'type': 'label', 'value': '---'} - for job in jobs: - try: - data = json.loads(job['data']) - desctiption = data['description'] - except Exception: - desctiption = '*No description*' - user = job['user']['username'] - created = job['created_at'].strftime('%d.%m.%Y %H:%M:%S') - label = '{} - {} - {}'.format( - desctiption, created, user - ) - item_label = { - 'type': 'label', - 'value': label - } - item = { - 'name': job['id'], - 'type': 'boolean', - 'value': False - } - if len(items) > 0: - items.append(item_splitter) - items.append(item_label) - items.append(item) - - if len(items) == 0: - return { - 'success': False, - 'message': 'Didn\'t found any running jobs' - } - else: - return { - 'items': items, - 'title': title - } - - def launch(self, session, entities, event): - """ GET JOB """ - if 'values' not in event['data']: + if event["data"].get("values"): return - values = event['data']['values'] - if len(values) <= 0: + title = "Select jobs to kill" + + jobs = session.query( + "select id, user_id, status, created_at, data from Job" + " where status in (\"queued\", \"running\")" + ).all() + if not jobs: return { - 'success': True, - 'message': 'No jobs to kill!' + "success": True, + "message": "Didn't found any running jobs" } - jobs = [] - job_ids = [] - for k, v in values.items(): - if v is True: - job_ids.append(k) + # Collect user ids from jobs + user_ids = set() + for job in jobs: + user_id = job["user_id"] + if user_id: + user_ids.add(user_id) + + # Store usernames by their ids + usernames_by_id = {} + if user_ids: + users = session.query( + "select id, username from User where id in ({})".format( + self.join_query_keys(user_ids) + ) + ).all() + for user in users: + usernames_by_id[user["id"]] = user["username"] + + items = [] + for job in jobs: + try: + data = json.loads(job["data"]) + desctiption = data["description"] + except Exception: + desctiption = "*No description*" + user_id = job["user_id"] + username = usernames_by_id.get(user_id) or "Unknown user" + created = job["created_at"].strftime('%d.%m.%Y %H:%M:%S') + label = "{} - {} - {}".format( + username, desctiption, created + ) + item_label = { + "type": "label", + "value": label + } + item = { + "name": job["id"], + "type": "boolean", + "value": False + } + if len(items) > 0: + items.append({"type": "label", "value": "---"}) + items.append(item_label) + items.append(item) + + return { + "items": items, + "title": title + } + + def launch(self, session, entities, event): + if "values" not in event["data"]: + return + + values = event["data"]["values"] + if len(values) < 1: + return { + "success": True, + "message": "No jobs to kill!" + } + + job_ids = set() + for job_id, kill_job in values.items(): + if kill_job: + job_ids.add(job_id) + + jobs = session.query( + "select id, status from Job where id in ({})".format( + self.join_query_keys(job_ids) + ) + ).all() - for id in job_ids: - query = 'Job where id is "{}"'.format(id) - jobs.append(session.query(query).one()) # Update all the queried jobs, setting the status to failed. for job in jobs: try: origin_status = job["status"] - job['status'] = 'failed' - session.commit() self.log.debug(( 'Changing Job ({}) status: {} -> failed' - ).format(job['id'], origin_status)) + ).format(job["id"], origin_status)) + + job["status"] = "failed" + session.commit() + except Exception: session.rollback() self.log.warning(( - 'Changing Job ({}) has failed' - ).format(job['id'])) + "Changing Job ({}) has failed" + ).format(job["id"])) - self.log.info('All running jobs were killed Successfully!') + self.log.info("All selected jobs were killed Successfully!") return { - 'success': True, - 'message': 'All running jobs were killed Successfully!' + "success": True, + "message": "All selected jobs were killed Successfully!" } From c7559b602fbbf3b1fcfe94509859020cbbaedba0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 25 Jan 2022 17:39:36 +0100 Subject: [PATCH 022/152] Draft to support Color Management v2 preferences in Maya 2022+ --- openpype/hosts/maya/api/lib.py | 75 ++++++++++++++++--- .../defaults/project_anatomy/imageio.json | 11 +++ .../schemas/schema_anatomy_imageio.json | 42 ++++++++++- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 21d5e581a5..6578d423e6 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -2780,7 +2780,27 @@ def set_colorspace(): """ project_name = os.getenv("AVALON_PROJECT") imageio = get_anatomy_settings(project_name)["imageio"]["maya"] - root_dict = imageio["colorManagementPreference"] + + # Maya 2022+ introduces new OCIO v2 color management settings that + # can override the old color managenement preferences. OpenPype has + # separate settings for both so we fall back when necessary. + use_ocio_v2 = imageio["colorManagementPreference_v2"]["enabled"] + required_maya_version = 2022 + maya_version = int(cmds.about(version=True)) + maya_supports_ocio_v2 = maya_version >= required_maya_version + if use_ocio_v2 and not maya_supports_ocio_v2: + # Fallback to legacy behavior with a warning + log.warning("Color Management Preference v2 is enabled but not " + "supported by current Maya version: {} (< {}). Falling " + "back to legacy settings.".format( + maya_version, required_maya_version) + ) + use_ocio_v2 = False + + if use_ocio_v2: + root_dict = imageio["colorManagementPreference_v2"] + else: + root_dict = imageio["colorManagementPreference"] if not isinstance(root_dict, dict): msg = "set_colorspace(): argument should be dictionary" @@ -2788,11 +2808,12 @@ def set_colorspace(): log.debug(">> root_dict: {}".format(root_dict)) - # first enable color management + # enable color management cmds.colorManagementPrefs(e=True, cmEnabled=True) cmds.colorManagementPrefs(e=True, ocioRulesEnabled=True) - # second set config path + # set config path + custom_ocio_config = False if root_dict.get("configFilePath"): unresolved_path = root_dict["configFilePath"] ocio_paths = unresolved_path[platform.system().lower()] @@ -2809,13 +2830,47 @@ def set_colorspace(): cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=True) log.debug("maya '{}' changed to: {}".format( "configFilePath", resolved_path)) - root_dict.pop("configFilePath") + custom_ocio_config = True else: cmds.colorManagementPrefs(e=True, cmConfigFileEnabled=False) - cmds.colorManagementPrefs(e=True, configFilePath="" ) + cmds.colorManagementPrefs(e=True, configFilePath="") - # third set rendering space and view transform - renderSpace = root_dict["renderSpace"] - cmds.colorManagementPrefs(e=True, renderingSpaceName=renderSpace) - viewTransform = root_dict["viewTransform"] - cmds.colorManagementPrefs(e=True, viewTransformName=viewTransform) + # If no custom OCIO config file was set we make sure that Maya 2022+ + # either chooses between Maya's newer default v2 or legacy config based + # on OpenPype setting to use ocio v2 or not. + if maya_supports_ocio_v2 and not custom_ocio_config: + if use_ocio_v2: + # Use Maya 2022+ default OCIO v2 config + log.info("Setting default Maya OCIO v2 config") + cmds.colorManagementPrefs(edit=True, configFilePath="") + else: + # Set the Maya default config file path + log.info("Setting default Maya OCIO v1 legacy config") + cmds.colorManagementPrefs(edit=True, configFilePath="legacy") + + # set color spaces for rendering space and view transforms + def _colormanage(**kwargs): + """Wrapper around `cmds.colorManagementPrefs`. + + This logs errors instead of raising an error so color management + settings get applied as much as possible. + + """ + assert len(kwargs) == 1, "Must receive one keyword argument" + try: + cmds.colorManagementPrefs(edit=True, **kwargs) + log.debug("Setting Color Management Preference: {}".format(kwargs)) + except RuntimeError as exc: + log.error(exc) + + if use_ocio_v2: + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + _colormanage(displayName=root_dict["displayName"]) + _colormanage(viewName=root_dict["viewName"]) + else: + _colormanage(renderingSpaceName=root_dict["renderSpace"]) + if maya_supports_ocio_v2: + _colormanage(viewName=root_dict["viewTransform"]) + _colormanage(displayName="legacy") + else: + _colormanage(viewTransformName=root_dict["viewTransform"]) diff --git a/openpype/settings/defaults/project_anatomy/imageio.json b/openpype/settings/defaults/project_anatomy/imageio.json index 09ab398c37..1065ac58b2 100644 --- a/openpype/settings/defaults/project_anatomy/imageio.json +++ b/openpype/settings/defaults/project_anatomy/imageio.json @@ -177,6 +177,17 @@ } }, "maya": { + "colorManagementPreference_v2": { + "enabled": true, + "configFilePath": { + "windows": [], + "darwin": [], + "linux": [] + }, + "renderSpace": "ACEScg", + "viewName": "ACES 1.0 SDR-video", + "displayName": "sRGB" + }, "colorManagementPreference": { "configFilePath": { "windows": [], diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json index 380ea4a83d..fe37a450f3 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_anatomy_imageio.json @@ -377,11 +377,47 @@ "type": "dict", "label": "Maya", "children": [ + { + "key": "colorManagementPreference_v2", + "type": "dict", + "label": "Color Management Preference v2 (Maya 2022+)", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Use Color Management Preference v2" + }, + { + "type": "path", + "key": "configFilePath", + "label": "OCIO Config File Path", + "multiplatform": true, + "multipath": true + }, + { + "type": "text", + "key": "renderSpace", + "label": "Rendering Space" + }, + { + "type": "text", + "key": "displayName", + "label": "Display" + }, + { + "type": "text", + "key": "viewName", + "label": "View" + } + ] + }, { "key": "colorManagementPreference", "type": "dict", - "label": "Color Managment Preference", - "collapsible": false, + "label": "Color Management Preference (legacy)", + "collapsible": true, "children": [ { "type": "path", @@ -401,7 +437,7 @@ "label": "Viewer Transform" } ] - } + } ] } ] From f8d534f14a86393b5e3c809435111309a91c8a4b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 12:50:30 +0100 Subject: [PATCH 023/152] Tweak Deadline Module ValidateExpectedFiles - Improve reported missing files by logging them sorted - Always check for use override, since updated Job frame list might be longer than initial frame list. - Check only for missing files instead of difference in files - allow other files to exist - Add a raised error if `file_name_template` could not be parsed - since that would be problematic if it happens? --- .../validate_expected_and_rendered_files.py | 70 +++++++++++-------- 1 file changed, 42 insertions(+), 28 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 719c7dfe3e..cca8f06d9c 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -30,42 +30,54 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): staging_dir = repre["stagingDir"] existing_files = self._get_existing_files(staging_dir) - expected_non_existent = expected_files.difference( - existing_files) - if len(expected_non_existent) != 0: - self.log.info("Some expected files missing {}".format( - expected_non_existent)) + if self.allow_user_override: + # We always check for user override because the user might have + # also overridden the Job frame list to be longer than the + # originally submitted frame range + # todo: We should first check if Job frame range was overridden + # at all so we don't unnecessarily override anything + file_name_template, frame_placeholder = \ + self._get_file_name_template_and_placeholder( + expected_files) - if self.allow_user_override: - file_name_template, frame_placeholder = \ - self._get_file_name_template_and_placeholder( - expected_files) + if not file_name_template: + raise RuntimeError("Unable to retrieve file_name template" + "from files: {}".format(expected_files)) - if not file_name_template: - return + job_expected_files = self._get_job_expected_files( + file_name_template, + frame_placeholder, + frame_list) - real_expected_rendered = self._get_real_render_expected( - file_name_template, - frame_placeholder, - frame_list) + job_files_diff = job_expected_files.difference(expected_files) + if job_files_diff: + self.log.debug("Detected difference in expected output " + "files from Deadline job. Assuming an " + "updated frame list by the user. " + "Difference: {}".format( + sorted(job_files_diff))) - real_expected_non_existent = \ - real_expected_rendered.difference(existing_files) - if len(real_expected_non_existent) != 0: - raise RuntimeError("Still missing some files {}". - format(real_expected_non_existent)) - self.log.info("Update range from actual job range") + # Update the representation expected files + self.log.info("Update range from actual job range " + "to frame list: {}".format(frame_list)) repre["files"] = sorted(list(real_expected_rendered)) - else: - raise RuntimeError("Some expected files missing {}".format( - expected_non_existent)) + + # Update the expected files + expected_files = job_expected_files + + # We don't use set.difference because we do allow other existing + # files to be in the folder that we might not want to use. + missing = expected_files - existing_files + if missing: + raise RuntimeError("Missing expected files: {}".format( + sorted(missing))) def _get_frame_list(self, original_job_id): """ Returns list of frame ranges from all render job. - Render job might be requeried so job_id in metadata.json is invalid - GlobalJobPreload injects current ids to RENDER_JOB_IDS. + Render job might be re-queried so job_id in metadata.json is + invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. Args: original_job_id (str) @@ -87,8 +99,10 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return all_frame_lists - def _get_real_render_expected(self, file_name_template, frame_placeholder, - frame_list): + def _get_job_expected_files(self, + file_name_template, + frame_placeholder, + frame_list): """ Calculates list of names of expected rendered files. From 5eca6a35385c74001676f561220cfc70abf342ce Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 13:06:54 +0100 Subject: [PATCH 024/152] Tweak cosmetics for the Hound --- .../publish/validate_expected_and_rendered_files.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index cca8f06d9c..f4828435c2 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -51,11 +51,11 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): job_files_diff = job_expected_files.difference(expected_files) if job_files_diff: - self.log.debug("Detected difference in expected output " - "files from Deadline job. Assuming an " - "updated frame list by the user. " - "Difference: {}".format( - sorted(job_files_diff))) + self.log.debug( + "Detected difference in expected output files from " + "Deadline job. Assuming an updated frame list by the " + "user. Difference: {}".format(sorted(job_files_diff)) + ) # Update the representation expected files self.log.info("Update range from actual job range " From c8410674c28e50b348bf64dde738e85cd0fa6f18 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 14:24:42 +0100 Subject: [PATCH 025/152] Remove redundant `list()` inside `sorted()` --- .../plugins/publish/validate_expected_and_rendered_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index f4828435c2..d814cd6ae6 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -60,7 +60,7 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) - repre["files"] = sorted(list(real_expected_rendered)) + repre["files"] = sorted(real_expected_rendered) # Update the expected files expected_files = job_expected_files From 307cfc92fdff72d0a67e84766dfa3a44762eba09 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 14:29:38 +0100 Subject: [PATCH 026/152] Fix: Refactor variable name --- .../plugins/publish/validate_expected_and_rendered_files.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index d814cd6ae6..896c7dbbc3 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -60,7 +60,7 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): # Update the representation expected files self.log.info("Update range from actual job range " "to frame list: {}".format(frame_list)) - repre["files"] = sorted(real_expected_rendered) + repre["files"] = sorted(job_expected_files) # Update the expected files expected_files = job_expected_files From 68d3f9ec3e53ba2f7c4c1efbfa2609f0903fe9ec Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 2 Feb 2022 18:25:38 +0100 Subject: [PATCH 027/152] fix imports --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 465dab2a76..61be634a42 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -1,6 +1,6 @@ from avalon.maya import lib from avalon import api -from openpype.api import config +from openpype.api import get_project_settings import os import maya.cmds as cmds @@ -19,7 +19,7 @@ class VRaySceneLoader(api.Loader): def load(self, context, name, namespace, data): from avalon.maya.pipeline import containerise - from openpype.hosts.maya.lib import namespaced + from openpype.hosts.maya.api.lib import namespaced try: family = context["representation"]["context"]["family"] @@ -47,8 +47,8 @@ class VRaySceneLoader(api.Loader): return # colour the group node - presets = config.get_presets(project=os.environ['AVALON_PROJECT']) - colors = presets['plugins']['maya']['load']['colors'] + settings = get_project_settings(os.environ['AVALON_PROJECT']) + colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) From c42d9a1b566876949c91fa8864568825e4d7f1d6 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 22:26:22 +0100 Subject: [PATCH 028/152] Remove unused function and fix docstrings - Fix docstring that had invalid information - Refactor 'out_dir' to 'staging_dir' for consistency of what the variable refers to --- .../validate_expected_and_rendered_files.py | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 896c7dbbc3..2566374d2b 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -73,16 +73,15 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): sorted(missing))) def _get_frame_list(self, original_job_id): - """ - Returns list of frame ranges from all render job. + """Returns list of frame ranges from all render job. - Render job might be re-queried so job_id in metadata.json is - invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. + Render job might be re-queried so job_id in metadata.json is + invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. - Args: - original_job_id (str) - Returns: - (list) + Args: + original_job_id (str) + Returns: + (list) """ all_frame_lists = [] render_job_ids = os.environ.get("RENDER_JOB_IDS") @@ -103,11 +102,11 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): file_name_template, frame_placeholder, frame_list): - """ - Calculates list of names of expected rendered files. + """Calculates list of names of expected rendered files. + + Might be different from expected files from submission if user + explicitly and manually changed the frame list on the Deadline job. - Might be different from job expected files if user explicitly and - manually change frame list on Deadline job. """ real_expected_rendered = set() src_padding_exp = "%0{}d".format(len(frame_placeholder)) @@ -137,11 +136,11 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return file_name_template, frame_placeholder def _get_job_info(self, job_id): - """ - Calls DL for actual job info for 'job_id' + """Calls DL for actual job info for 'job_id' + + Might be different than job info saved in metadata.json if user + manually changes job pre/during rendering. - Might be different than job info saved in metadata.json if user - manually changes job pre/during rendering. """ # get default deadline webservice url from deadline module deadline_url = self.instance.context.data["defaultDeadline"] @@ -154,8 +153,8 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): try: response = requests_get(url) except requests.exceptions.ConnectionError: - print("Deadline is not accessible at {}".format(deadline_url)) - # self.log("Deadline is not accessible at {}".format(deadline_url)) + self.log.error("Deadline is not accessible at " + "{}".format(deadline_url)) return {} if not response.ok: @@ -169,29 +168,26 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): return json_content.pop() return {} - def _parse_metadata_json(self, json_path): - if not os.path.exists(json_path): - msg = "Metadata file {} doesn't exist".format(json_path) - raise RuntimeError(msg) - - with open(json_path) as fp: - try: - return json.load(fp) - except Exception as exc: - self.log.error( - "Error loading json: " - "{} - Exception: {}".format(json_path, exc) - ) - - def _get_existing_files(self, out_dir): - """Returns set of existing file names from 'out_dir'""" + def _get_existing_files(self, staging_dir): + """Returns set of existing file names from 'staging_dir'""" existing_files = set() - for file_name in os.listdir(out_dir): + for file_name in os.listdir(staging_dir): existing_files.add(file_name) return existing_files def _get_expected_files(self, repre): - """Returns set of file names from metadata.json""" + """Returns set of file names in representation['files'] + + The representations are collected from `CollectRenderedFiles` using + the metadata.json file submitted along with the render job. + + Args: + repre (dict): The representation containing 'files' + + Returns: + set: Set of expected file_names in the staging directory. + + """ expected_files = set() files = repre["files"] From ee614102796f3dbe32da48bcd4a78713180bbbed Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 2 Feb 2022 22:29:24 +0100 Subject: [PATCH 029/152] Tweak docstring info --- .../plugins/publish/validate_expected_and_rendered_files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index 2566374d2b..ef2c2b03da 100644 --- a/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/default_modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -75,8 +75,8 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): def _get_frame_list(self, original_job_id): """Returns list of frame ranges from all render job. - Render job might be re-queried so job_id in metadata.json is - invalid GlobalJobPreload injects current ids to RENDER_JOB_IDS. + Render job might be re-submitted so job_id in metadata.json could be + invalid. GlobalJobPreload injects current job id to RENDER_JOB_IDS. Args: original_job_id (str) From 6fd4e7cf381e2e3601c256d1fd62787ed23a0ae7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:12:56 +0100 Subject: [PATCH 030/152] Lock shape nodes --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 61be634a42..6cad4f3e1e 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -131,6 +131,10 @@ class VRaySceneLoader(api.Loader): cmds.setAttr("{}.FilePath".format(vray_scene), filename, type="string") + # Lock the shape nodes so the user cannot delete these + cmds.lockNode(mesh, lock=True) + cmds.lockNode(vray_scene, lock=True) + # Create important connections cmds.connectAttr("time1.outTime", "{0}.inputTime".format(trans)) From ed2908353d3a1b1bdbf242167f48abcc88bc745f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:21:03 +0100 Subject: [PATCH 031/152] Don't create redundant extra group --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 6cad4f3e1e..40d7bd6403 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -39,8 +39,8 @@ class VRaySceneLoader(api.Loader): with lib.maintained_selection(): cmds.namespace(addNamespace=namespace) with namespaced(namespace, new=False): - nodes, group_node = self.create_vray_scene(name, - filename=self.fname) + nodes, root_node = self.create_vray_scene(name, + filename=self.fname) self[:] = nodes if not nodes: @@ -51,8 +51,8 @@ class VRaySceneLoader(api.Loader): colors = settings['maya']['load']['colors'] c = colors.get(family) if c is not None: - cmds.setAttr("{0}.useOutlinerColor".format(group_node), 1) - cmds.setAttr("{0}.outlinerColor".format(group_node), + cmds.setAttr("{0}.useOutlinerColor".format(root_node), 1) + cmds.setAttr("{0}.outlinerColor".format(root_node), (float(c[0])/255), (float(c[1])/255), (float(c[2])/255) @@ -142,11 +142,9 @@ class VRaySceneLoader(api.Loader): # Connect mesh to initialShadingGroup cmds.sets([mesh], forceElement="initialShadingGroup") - group_node = cmds.group(empty=True, name="{}_GRP".format(name)) - cmds.parent(trans, group_node) - nodes = [trans, vray_scene, mesh, group_node] + nodes = [trans, vray_scene, mesh] # Fix: Force refresh so the mesh shows correctly after creation cmds.refresh() - return nodes, group_node + return nodes, trans From b0aa53b52c31f3552f16680c4177b539414c8ee7 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:21:42 +0100 Subject: [PATCH 032/152] Remove redundant string format --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 40d7bd6403..3c0edac9a8 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -120,7 +120,7 @@ class VRaySceneLoader(api.Loader): mesh_node_name = "VRayScene_{}".format(name) trans = cmds.createNode( - "transform", name="{}".format(mesh_node_name)) + "transform", name=mesh_node_name) mesh = cmds.createNode( "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) vray_scene = cmds.createNode( From e7e8235be1dab0aecdc0295903e9025fab0acb6b Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:28:29 +0100 Subject: [PATCH 033/152] Add V-Ray Scene to Maya Loaded Subsets Outliner Colors settings --- openpype/settings/defaults/project_settings/maya.json | 6 ++++++ .../schemas/projects_schema/schemas/schema_maya_load.json | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 52b8db058c..2712aeb1b2 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -575,6 +575,12 @@ 12, 255 ], + "vrayscene_layer": [ + 255, + 150, + 12, + 255 + ], "yeticache": [ 99, 206, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json index 7c87644817..6b2315abc0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_load.json @@ -75,6 +75,11 @@ "label": "Vray Proxy:", "key": "vrayproxy" }, + { + "type": "color", + "label": "Vray Scene:", + "key": "vrayscene_layer" + }, { "type": "color", "label": "Yeti Cache:", From 5269510ed9707d40b7b740770e5e61babd3fe116 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 3 Feb 2022 13:38:09 +0100 Subject: [PATCH 034/152] Create and parent the V-Ray Scene first to transform so that outliner shows VRayScene icon on transform --- openpype/hosts/maya/plugins/load/load_vrayscene.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_vrayscene.py b/openpype/hosts/maya/plugins/load/load_vrayscene.py index 3c0edac9a8..5a67ab859d 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayscene.py +++ b/openpype/hosts/maya/plugins/load/load_vrayscene.py @@ -121,10 +121,10 @@ class VRaySceneLoader(api.Loader): trans = cmds.createNode( "transform", name=mesh_node_name) - mesh = cmds.createNode( - "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) vray_scene = cmds.createNode( "VRayScene", name="{}_VRSCN".format(mesh_node_name), parent=trans) + mesh = cmds.createNode( + "mesh", name="{}_Shape".format(mesh_node_name), parent=trans) cmds.connectAttr( "{}.outMesh".format(vray_scene), "{}.inMesh".format(mesh)) From 0724f70eeed1d9276b70a53893aaadfedbb6a526 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 20 Jan 2022 18:10:08 +0100 Subject: [PATCH 035/152] add sequence image in photoshop --- .../plugins/publish/extract_review.py | 141 ++++++++++++------ .../defaults/project_settings/photoshop.json | 3 +- .../schema_project_photoshop.json | 5 + 3 files changed, 103 insertions(+), 46 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 1ad442279a..57ad573aae 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -1,4 +1,5 @@ import os +import shutil import openpype.api import openpype.lib @@ -7,7 +8,7 @@ from openpype.hosts.photoshop import api as photoshop class ExtractReview(openpype.api.Extractor): """ - Produce a flattened image file from all 'image' instances. + Produce a flattened or sequence image file from all 'image' instances. If no 'image' instance is created, it produces flattened image from all visible layers. @@ -20,54 +21,43 @@ class ExtractReview(openpype.api.Extractor): # Extract Options jpg_options = None mov_options = None + make_image_sequence = None def process(self, instance): - staging_dir = self.staging_dir(instance) - self.log.info("Outputting image to {}".format(staging_dir)) + self.staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(self.staging_dir)) - stub = photoshop.stub() + self.stub = photoshop.stub() + self.output_seq_filename = os.path.splitext( + self.stub.get_active_document_name())[0] + ".%04d.jpg" - layers = [] - for image_instance in instance.context: - if image_instance.data["family"] != "image": - continue - layers.append(image_instance[0]) - - # Perform extraction - output_image = "{}.jpg".format( - os.path.splitext(stub.get_active_document_name())[0] - ) - output_image_path = os.path.join(staging_dir, output_image) - with photoshop.maintained_visibility(): - if layers: - # Hide all other layers. - extract_ids = set([ll.id for ll in stub. - get_layers_in_layers(layers)]) - self.log.debug("extract_ids {}".format(extract_ids)) - for layer in stub.get_layers(): - # limit unnecessary calls to client - if layer.visible and layer.id not in extract_ids: - stub.set_visible(layer.id, False) - - stub.saveAs(output_image_path, 'jpg', True) + new_img_list = src_img_list = [] + if self.make_image_sequence: + src_img_list = self._get_image_path_from_instances(instance) + if self.make_image_sequence and src_img_list: + new_img_list = self._copy_image_to_staging_dir(src_img_list) + else: + layers = self._get_layers_from_instance(instance) + new_img_list = self._saves_flattened_layers(layers) + instance.data["representations"].append({ + "name": "jpg", + "ext": "jpg", + "files": new_img_list, + "stagingDir": self.staging_dir, + "tags": self.jpg_options['tags'] + }) ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") - instance.data["representations"].append({ - "name": "jpg", - "ext": "jpg", - "files": output_image, - "stagingDir": staging_dir, - "tags": self.jpg_options['tags'] - }) - instance.data["stagingDir"] = staging_dir + instance.data["stagingDir"] = self.staging_dir # Generate thumbnail. - thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") + thumbnail_path = os.path.join(self.staging_dir, "thumbnail.jpg") + self.log.info(f"Generate thumbnail {thumbnail_path}") args = [ ffmpeg_path, "-y", - "-i", output_image_path, + "-i", os.path.join(self.staging_dir, self.output_seq_filename), "-vf", "scale=300:-1", "-vframes", "1", thumbnail_path @@ -78,17 +68,20 @@ class ExtractReview(openpype.api.Extractor): "name": "thumbnail", "ext": "jpg", "files": os.path.basename(thumbnail_path), - "stagingDir": staging_dir, + "stagingDir": self.staging_dir, "tags": ["thumbnail"] }) + # Generate mov. - mov_path = os.path.join(staging_dir, "review.mov") + mov_path = os.path.join(self.staging_dir, "review.mov") + self.log.info(f"Generate mov review: {mov_path}") + img_number = len(new_img_list) args = [ ffmpeg_path, "-y", - "-i", output_image_path, + "-i", os.path.join(self.staging_dir, self.output_seq_filename), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", - "-vframes", "1", + "-vframes", str(img_number), mov_path ] output = openpype.lib.run_subprocess(args) @@ -97,9 +90,9 @@ class ExtractReview(openpype.api.Extractor): "name": "mov", "ext": "mov", "files": os.path.basename(mov_path), - "stagingDir": staging_dir, + "stagingDir":self.staging_dir, "frameStart": 1, - "frameEnd": 1, + "frameEnd": img_number, "fps": 25, "preview": True, "tags": self.mov_options['tags'] @@ -107,7 +100,65 @@ class ExtractReview(openpype.api.Extractor): # Required for extract_review plugin (L222 onwards). instance.data["frameStart"] = 1 - instance.data["frameEnd"] = 1 + instance.data["frameEnd"] = img_number instance.data["fps"] = 25 - self.log.info(f"Extracted {instance} to {staging_dir}") + self.log.info(f"Extracted {instance} to {self.staging_dir}") + + def _get_image_path_from_instances(self, instance): + img_list = [] + + for instance in instance.context: + if instance.data["family"] != "image": + continue + + for rep in instance.data["representations"]: + img_path = os.path.join( + rep["stagingDir"], + rep["files"] + ) + img_list.append(img_path) + + return img_list + + def _copy_image_to_staging_dir(self, img_list): + copy_files = [] + for i, img_src in enumerate(img_list): + img_filename = self.output_seq_filename %i + img_dst = os.path.join(self.staging_dir, img_filename) + + self.log.debug( + "Copying file .. {} -> {}".format(img_src, img_dst) + ) + shutil.copy(img_src, img_dst) + copy_files.append(img_filename) + + return copy_files + + def _get_layers_from_instance(self, instance): + layers = [] + for image_instance in instance.context: + if image_instance.data["family"] != "image": + continue + layers.append(image_instance[0]) + + return layers + + def _saves_flattened_layers(self, layers): + img_filename = self.output_seq_filename %0 + output_image_path = os.path.join(self.staging_dir, img_filename) + + with photoshop.maintained_visibility(): + if layers: + # Hide all other layers. + extract_ids = set([ll.id for ll in self.stub. + get_layers_in_layers(layers)]) + self.log.debug("extract_ids {}".format(extract_ids)) + for layer in self.stub.get_layers(): + # limit unnecessary calls to client + if layer.visible and layer.id not in extract_ids: + self.stub.set_visible(layer.id, False) + + self.stub.saveAs(output_image_path, 'jpg', True) + + return img_filename diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 31cd815dd8..b679d9c880 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -33,6 +33,7 @@ ] }, "ExtractReview": { + "make_image_sequence": false, "jpg_options": { "tags": [] }, @@ -48,4 +49,4 @@ "create_first_version": false, "custom_templates": [] } -} \ No newline at end of file +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json index 51ea5b3fe7..644e53cc95 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -154,6 +154,11 @@ "key": "ExtractReview", "label": "Extract Review", "children": [ + { + "type": "boolean", + "key": "make_image_sequence", + "label": "Makes an image sequence instead of a flatten image" + }, { "type": "dict", "collapsible": false, From 797a1615a3a7084b6f91ee188b8ce3b54c8789fa Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Fri, 21 Jan 2022 11:00:15 +0100 Subject: [PATCH 036/152] remove storing data in object --- .../plugins/publish/extract_review.py | 54 ++++++++++--------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 57ad573aae..031ef5eefa 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -24,40 +24,43 @@ class ExtractReview(openpype.api.Extractor): make_image_sequence = None def process(self, instance): - self.staging_dir = self.staging_dir(instance) - self.log.info("Outputting image to {}".format(self.staging_dir)) + staging_dir = self.staging_dir(instance) + self.log.info("Outputting image to {}".format(staging_dir)) - self.stub = photoshop.stub() + stub = photoshop.stub() self.output_seq_filename = os.path.splitext( - self.stub.get_active_document_name())[0] + ".%04d.jpg" + stub.get_active_document_name())[0] + ".%04d.jpg" new_img_list = src_img_list = [] if self.make_image_sequence: src_img_list = self._get_image_path_from_instances(instance) if self.make_image_sequence and src_img_list: - new_img_list = self._copy_image_to_staging_dir(src_img_list) + new_img_list = self._copy_image_to_staging_dir( + staging_dir, + src_img_list + ) else: layers = self._get_layers_from_instance(instance) - new_img_list = self._saves_flattened_layers(layers) + new_img_list = self._saves_flattened_layers(staging_dir, layers) instance.data["representations"].append({ "name": "jpg", "ext": "jpg", "files": new_img_list, - "stagingDir": self.staging_dir, + "stagingDir": staging_dir, "tags": self.jpg_options['tags'] }) ffmpeg_path = openpype.lib.get_ffmpeg_tool_path("ffmpeg") - instance.data["stagingDir"] = self.staging_dir + instance.data["stagingDir"] = staging_dir # Generate thumbnail. - thumbnail_path = os.path.join(self.staging_dir, "thumbnail.jpg") + thumbnail_path = os.path.join(staging_dir, "thumbnail.jpg") self.log.info(f"Generate thumbnail {thumbnail_path}") args = [ ffmpeg_path, "-y", - "-i", os.path.join(self.staging_dir, self.output_seq_filename), + "-i", os.path.join(staging_dir, self.output_seq_filename), "-vf", "scale=300:-1", "-vframes", "1", thumbnail_path @@ -68,18 +71,18 @@ class ExtractReview(openpype.api.Extractor): "name": "thumbnail", "ext": "jpg", "files": os.path.basename(thumbnail_path), - "stagingDir": self.staging_dir, + "stagingDir": staging_dir, "tags": ["thumbnail"] }) # Generate mov. - mov_path = os.path.join(self.staging_dir, "review.mov") + mov_path = os.path.join(staging_dir, "review.mov") self.log.info(f"Generate mov review: {mov_path}") img_number = len(new_img_list) args = [ ffmpeg_path, "-y", - "-i", os.path.join(self.staging_dir, self.output_seq_filename), + "-i", os.path.join(staging_dir, self.output_seq_filename), "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", "-vframes", str(img_number), mov_path @@ -90,7 +93,7 @@ class ExtractReview(openpype.api.Extractor): "name": "mov", "ext": "mov", "files": os.path.basename(mov_path), - "stagingDir":self.staging_dir, + "stagingDir": staging_dir, "frameStart": 1, "frameEnd": img_number, "fps": 25, @@ -103,7 +106,7 @@ class ExtractReview(openpype.api.Extractor): instance.data["frameEnd"] = img_number instance.data["fps"] = 25 - self.log.info(f"Extracted {instance} to {self.staging_dir}") + self.log.info(f"Extracted {instance} to {staging_dir}") def _get_image_path_from_instances(self, instance): img_list = [] @@ -121,11 +124,11 @@ class ExtractReview(openpype.api.Extractor): return img_list - def _copy_image_to_staging_dir(self, img_list): + def _copy_image_to_staging_dir(self, staging_dir, img_list): copy_files = [] for i, img_src in enumerate(img_list): - img_filename = self.output_seq_filename %i - img_dst = os.path.join(self.staging_dir, img_filename) + img_filename = self.output_seq_filename % i + img_dst = os.path.join(staging_dir, img_filename) self.log.debug( "Copying file .. {} -> {}".format(img_src, img_dst) @@ -144,21 +147,22 @@ class ExtractReview(openpype.api.Extractor): return layers - def _saves_flattened_layers(self, layers): - img_filename = self.output_seq_filename %0 - output_image_path = os.path.join(self.staging_dir, img_filename) + def _saves_flattened_layers(self, staging_dir, layers): + img_filename = self.output_seq_filename % 0 + output_image_path = os.path.join(staging_dir, img_filename) + stub = photoshop.stub() with photoshop.maintained_visibility(): if layers: # Hide all other layers. - extract_ids = set([ll.id for ll in self.stub. + extract_ids = set([ll.id for ll in stub. get_layers_in_layers(layers)]) self.log.debug("extract_ids {}".format(extract_ids)) - for layer in self.stub.get_layers(): + for layer in stub.get_layers(): # limit unnecessary calls to client if layer.visible and layer.id not in extract_ids: - self.stub.set_visible(layer.id, False) + stub.set_visible(layer.id, False) - self.stub.saveAs(output_image_path, 'jpg', True) + stub.saveAs(output_image_path, 'jpg', True) return img_filename From b31f7b2f2067b5c9dfd1e435ebf8f15e9ead0ddc Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Wed, 9 Feb 2022 16:36:48 +0100 Subject: [PATCH 037/152] sort instance by name --- openpype/hosts/photoshop/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 031ef5eefa..5bda02d51c 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -111,7 +111,7 @@ class ExtractReview(openpype.api.Extractor): def _get_image_path_from_instances(self, instance): img_list = [] - for instance in instance.context: + for instance in sorted(instance.context): if instance.data["family"] != "image": continue From fba83d883775e1952f6358d04133db5d845ed829 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 10 Feb 2022 20:42:57 +0100 Subject: [PATCH 038/152] extract jpg in review --- .../plugins/publish/extract_image.py | 9 +-- .../plugins/publish/extract_review.py | 74 +++++++++++++------ 2 files changed, 51 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 2ba81e0bac..88b9a6c1bd 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -26,14 +26,7 @@ class ExtractImage(openpype.api.Extractor): with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): - # Hide all other layers. - extract_ids = set([ll.id for ll in stub. - get_layers_in_layers([instance[0]])]) - - for layer in stub.get_layers(): - # limit unnecessary calls to client - if layer.visible and layer.id not in extract_ids: - stub.set_visible(layer.id, False) + stub.hide_all_others_layers([instance[0]]) file_basename = os.path.splitext( stub.get_active_document_name() diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 5bda02d51c..f8e6cae040 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -27,25 +27,39 @@ class ExtractReview(openpype.api.Extractor): staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) + fps = instance.data.get("fps", 25) stub = photoshop.stub() self.output_seq_filename = os.path.splitext( stub.get_active_document_name())[0] + ".%04d.jpg" - new_img_list = src_img_list = [] - if self.make_image_sequence: - src_img_list = self._get_image_path_from_instances(instance) - if self.make_image_sequence and src_img_list: - new_img_list = self._copy_image_to_staging_dir( - staging_dir, - src_img_list - ) - else: - layers = self._get_layers_from_instance(instance) - new_img_list = self._saves_flattened_layers(staging_dir, layers) + + layers = self._get_layers_from_image_instances(instance) + self.log.info("Layers image instance found: {}".format(layers)) + + img_list = [] + if self.make_image_sequence and layers: + self.log.info("Extract layers to image sequence.") + img_list = self._saves_sequences_layers(staging_dir, layers) + instance.data["representations"].append({ "name": "jpg", "ext": "jpg", - "files": new_img_list, + "files": img_list, + "frameStart": 0, + "frameEnd": len(img_list), + "fps": fps, + "stagingDir": staging_dir, + "tags": self.jpg_options['tags'], #"review" + }) + + else: + self.log.info("Extract layers to flatten image.") + img_list = self._saves_flattened_layers(staging_dir, layers) + + instance.data["representations"].append({ + "name": "jpg", + "ext": "jpg", + "files": img_list, "stagingDir": staging_dir, "tags": self.jpg_options['tags'] }) @@ -78,7 +92,7 @@ class ExtractReview(openpype.api.Extractor): # Generate mov. mov_path = os.path.join(staging_dir, "review.mov") self.log.info(f"Generate mov review: {mov_path}") - img_number = len(new_img_list) + img_number = len(img_list) args = [ ffmpeg_path, "-y", @@ -96,7 +110,7 @@ class ExtractReview(openpype.api.Extractor): "stagingDir": staging_dir, "frameStart": 1, "frameEnd": img_number, - "fps": 25, + "fps": fps, "preview": True, "tags": self.mov_options['tags'] }) @@ -138,14 +152,14 @@ class ExtractReview(openpype.api.Extractor): return copy_files - def _get_layers_from_instance(self, instance): + def _get_layers_from_image_instances(self, instance): layers = [] for image_instance in instance.context: if image_instance.data["family"] != "image": continue layers.append(image_instance[0]) - return layers + return sorted(layers) def _saves_flattened_layers(self, staging_dir, layers): img_filename = self.output_seq_filename % 0 @@ -153,16 +167,28 @@ class ExtractReview(openpype.api.Extractor): stub = photoshop.stub() with photoshop.maintained_visibility(): + self.log.info("Extracting {}".format(layers)) if layers: - # Hide all other layers. - extract_ids = set([ll.id for ll in stub. - get_layers_in_layers(layers)]) - self.log.debug("extract_ids {}".format(extract_ids)) - for layer in stub.get_layers(): - # limit unnecessary calls to client - if layer.visible and layer.id not in extract_ids: - stub.set_visible(layer.id, False) + stub.hide_all_others_layers(layers) stub.saveAs(output_image_path, 'jpg', True) return img_filename + + def _saves_sequences_layers(self, staging_dir, layers): + stub = photoshop.stub() + + list_img_filename = [] + with photoshop.maintained_visibility(): + for i, layer in enumerate(layers): + self.log.info("Extracting {}".format(layer)) + + img_filename = self.output_seq_filename % i + output_image_path = os.path.join(staging_dir, img_filename) + list_img_filename.append(img_filename) + + with photoshop.maintained_visibility(): + stub.hide_all_others_layers([layer]) + stub.saveAs(output_image_path, 'jpg', True) + + return list_img_filename From b405a092e08d11f78662c7d0b146fd41c7abf3b5 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 10 Feb 2022 20:42:33 +0100 Subject: [PATCH 039/152] create method hide_all_others_layers --- openpype/hosts/photoshop/api/ws_stub.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index b8f66332c6..9db8f38a32 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -327,6 +327,19 @@ class PhotoshopServerStub: ) ) + def hide_all_others_layers(self, layers): + """hides all layers that are not part of the list or that are not + children of this list + + Args: + layers (list): list of PSItem + """ + extract_ids = set([ll.id for ll in self.get_layers_in_layers(layers)]) + + for layer in self.get_layers(): + if layer.visible and layer.id not in extract_ids: + self.set_visible(layer.id, False) + def get_layers_metadata(self): """Reads layers metadata from Headline from active document in PS. (Headline accessible by File > File Info) From c2421af820e7f5bc98ba29e0f50e6b17316092ba Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Thu, 10 Feb 2022 20:48:59 +0100 Subject: [PATCH 040/152] fix formatting --- openpype/hosts/photoshop/api/ws_stub.py | 2 +- openpype/hosts/photoshop/plugins/publish/extract_review.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index 9db8f38a32..fc114f759e 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -328,7 +328,7 @@ class PhotoshopServerStub: ) def hide_all_others_layers(self, layers): - """hides all layers that are not part of the list or that are not + """hides all layers that are not part of the list or that are not children of this list Args: diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index f8e6cae040..b9750922f8 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -32,7 +32,6 @@ class ExtractReview(openpype.api.Extractor): self.output_seq_filename = os.path.splitext( stub.get_active_document_name())[0] + ".%04d.jpg" - layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) @@ -49,7 +48,7 @@ class ExtractReview(openpype.api.Extractor): "frameEnd": len(img_list), "fps": fps, "stagingDir": staging_dir, - "tags": self.jpg_options['tags'], #"review" + "tags": self.jpg_options['tags'], }) else: From 1141a54c5e3332fd41eb90cea84565a10730b7da Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 14:20:32 +0100 Subject: [PATCH 041/152] Merges of extractors with current develop Develop contains additional feature and changed storing of layers. --- openpype/hosts/photoshop/api/ws_stub.py | 11 ++++++++++- .../hosts/photoshop/plugins/publish/extract_image.py | 9 ++++++++- .../hosts/photoshop/plugins/publish/extract_review.py | 3 +-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index 406c68c7ce..d4406d17b9 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -349,10 +349,19 @@ class PhotoshopServerStub: children of this list Args: - layers (list): list of PSItem + layers (list): list of PSItem - highest hierarchy """ extract_ids = set([ll.id for ll in self.get_layers_in_layers(layers)]) + self.hide_all_others_layers_ids(extract_ids) + + def hide_all_others_layers_ids(self, extract_ids): + """hides all layers that are not part of the list or that are not + children of this list + + Args: + extract_ids (list): list of integer that should be visible + """ for layer in self.get_layers(): if layer.visible and layer.id not in extract_ids: self.set_visible(layer.id, False) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 88b9a6c1bd..04ce77ee34 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -26,7 +26,14 @@ class ExtractImage(openpype.api.Extractor): with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): - stub.hide_all_others_layers([instance[0]]) + layer = instance.data.get("layer") + ids = set([layer.id]) + add_ids = instance.data.pop("ids", None) + if add_ids: + ids.update(set(add_ids)) + extract_ids = set([ll.id for ll in stub. + get_layers_in_layers_ids(ids)]) + stub.hide_all_others_layers_ids(extract_ids) file_basename = os.path.splitext( stub.get_active_document_name() diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index b9750922f8..455c7b43a3 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -35,7 +35,6 @@ class ExtractReview(openpype.api.Extractor): layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) - img_list = [] if self.make_image_sequence and layers: self.log.info("Extract layers to image sequence.") img_list = self._saves_sequences_layers(staging_dir, layers) @@ -156,7 +155,7 @@ class ExtractReview(openpype.api.Extractor): for image_instance in instance.context: if image_instance.data["family"] != "image": continue - layers.append(image_instance[0]) + layers.append(image_instance.data.get("layer")) return sorted(layers) From 80bf891183514424586ae35d5392d45caf068d44 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 18:37:14 +0100 Subject: [PATCH 042/152] Fix - do not trigger during automatic testing Skip if automatic testing and no batch file. Eventually we might want to automatically test webpublisher functionality too. --- .../plugins/publish/collect_color_coded_instances.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py index c1ae88fbbb..7d44d55a80 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_color_coded_instances.py @@ -38,10 +38,15 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectColorCodedInstances") - self.log.debug("mapping:: {}".format(self.color_code_mapping)) + batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") + if (os.environ.get("IS_TEST") and + (not batch_dir or not os.path.exists(batch_dir))): + self.log.debug("Automatic testing, no batch data, skipping") + return existing_subset_names = self._get_existing_subset_names(context) - asset_name, task_name, variant = self._parse_batch() + + asset_name, task_name, variant = self._parse_batch(batch_dir) stub = photoshop.stub() layers = stub.get_layers() @@ -125,9 +130,8 @@ class CollectColorCodedInstances(pyblish.api.ContextPlugin): return existing_subset_names - def _parse_batch(self): + def _parse_batch(self, batch_dir): """Parses asset_name, task_name, variant from batch manifest.""" - batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") task_data = None if batch_dir and os.path.exists(batch_dir): task_data = parse_json(os.path.join(batch_dir, From 7042330924185be973ccb9e071ec4e7c3ba15d52 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 20:02:38 +0100 Subject: [PATCH 043/152] Updated db_asserts for Photoshop publish test --- .../photoshop/test_publish_in_photoshop.py | 68 +++++++++++++------ 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py index 32053cd9d4..5387bbe51e 100644 --- a/tests/integration/hosts/photoshop/test_publish_in_photoshop.py +++ b/tests/integration/hosts/photoshop/test_publish_in_photoshop.py @@ -1,5 +1,10 @@ +import logging + +from tests.lib.assert_classes import DBAssert from tests.integration.hosts.photoshop.lib import PhotoshopTestClass +log = logging.getLogger("test_publish_in_photoshop") + class TestPublishInPhotoshop(PhotoshopTestClass): """Basic test case for publishing in Photoshop @@ -30,7 +35,7 @@ class TestPublishInPhotoshop(PhotoshopTestClass): {OPENPYPE_ROOT}/.venv/Scripts/python.exe {OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/photoshop # noqa: E501 """ - PERSIST = False + PERSIST = True TEST_FILES = [ ("1zD2v5cBgkyOm_xIgKz3WKn8aFB_j8qC-", "test_photoshop_publish.zip", "") @@ -44,33 +49,56 @@ class TestPublishInPhotoshop(PhotoshopTestClass): TIMEOUT = 120 # publish timeout - def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") - assert 3 == dbcon.count_documents({"type": "version"}), \ - "Not expected no of versions" + failures = [] - assert 0 == dbcon.count_documents({"type": "version", - "name": {"$ne": 1}}), \ - "Only versions with 1 expected" + failures.append(DBAssert.count_of_types(dbcon, "version", 4)) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "imageMainBackgroundcopy"} - ), \ - "modelMain subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "workfileTesttask"}), \ - "workfileTest_task subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainForeground")) - assert 6 == dbcon.count_documents({"type": "representation"}), \ - "Not expected no of representations" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainBackground")) - assert 1 == dbcon.count_documents({"type": "representation", - "context.subset": "imageMainBackgroundcopy", # noqa: E501 - "context.ext": "png"}), \ - "Not expected no of representations with ext 'png'" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="workfileTest_task")) + + failures.append( + DBAssert.count_of_types(dbcon, "representation", 8)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainForeground", + "context.ext": "jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + additional_args = {"context.subset": "imageMainBackground", + "context.ext": "jpg"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + assert not any(failures) if __name__ == "__main__": From 037b514409d5e0f514025eecb7931f2b71ce1bcf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 20:04:07 +0100 Subject: [PATCH 044/152] Added print of more detailed message --- tests/lib/assert_classes.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/lib/assert_classes.py b/tests/lib/assert_classes.py index 7298853b67..98f758767d 100644 --- a/tests/lib/assert_classes.py +++ b/tests/lib/assert_classes.py @@ -1,5 +1,6 @@ """Classed and methods for comparing expected and published items in DBs""" + class DBAssert: @classmethod @@ -41,5 +42,7 @@ class DBAssert: print("Comparing count of {}{} {}".format(queried_type, detail_str, status)) + if msg: + print(msg) return msg From 8b8f9524371161f8f79e93a01e20c15f53f66c9d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Feb 2022 20:06:45 +0100 Subject: [PATCH 045/152] Updated db asserts in After Effects --- tests/integration/hosts/aftereffects/lib.py | 34 ++++++++ .../test_publish_in_aftereffects.py | 85 +++++++------------ 2 files changed, 64 insertions(+), 55 deletions(-) create mode 100644 tests/integration/hosts/aftereffects/lib.py diff --git a/tests/integration/hosts/aftereffects/lib.py b/tests/integration/hosts/aftereffects/lib.py new file mode 100644 index 0000000000..9fffc6073d --- /dev/null +++ b/tests/integration/hosts/aftereffects/lib.py @@ -0,0 +1,34 @@ +import os +import pytest +import shutil + +from tests.lib.testing_classes import HostFixtures + + +class AfterEffectsTestClass(HostFixtures): + @pytest.fixture(scope="module") + def last_workfile_path(self, download_test_data, output_folder_url): + """Get last_workfile_path from source data. + + Maya expects workfile in proper folder, so copy is done first. + """ + src_path = os.path.join(download_test_data, + "input", + "workfile", + "test_project_test_asset_TestTask_v001.aep") + dest_folder = os.path.join(download_test_data, + self.PROJECT, + self.ASSET, + "work", + self.TASK) + os.makedirs(dest_folder) + dest_path = os.path.join(dest_folder, + "test_project_test_asset_TestTask_v001.aep") + shutil.copy(src_path, dest_path) + + yield dest_path + + @pytest.fixture(scope="module") + def startup_scripts(self, monkeypatch_session, download_test_data): + """Points Maya to userSetup file from input data""" + pass diff --git a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py index 407c4f8a3a..4925cbd2d7 100644 --- a/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py +++ b/tests/integration/hosts/aftereffects/test_publish_in_aftereffects.py @@ -1,11 +1,12 @@ -import pytest -import os -import shutil +import logging -from tests.lib.testing_classes import PublishTest +from tests.lib.assert_classes import DBAssert +from tests.integration.hosts.aftereffects.lib import AfterEffectsTestClass + +log = logging.getLogger("test_publish_in_aftereffects") -class TestPublishInAfterEffects(PublishTest): +class TestPublishInAfterEffects(AfterEffectsTestClass): """Basic test case for publishing in AfterEffects Uses generic TestCase to prepare fixtures for test data, testing DBs, @@ -23,7 +24,7 @@ class TestPublishInAfterEffects(PublishTest): Checks tmp folder if all expected files were published. """ - PERSIST = True + PERSIST = False TEST_FILES = [ ("1c8261CmHwyMgS-g7S4xL5epAp0jCBmhf", @@ -32,70 +33,44 @@ class TestPublishInAfterEffects(PublishTest): ] APP = "aftereffects" - APP_VARIANT = "2022" + APP_VARIANT = "" APP_NAME = "{}/{}".format(APP, APP_VARIANT) TIMEOUT = 120 # publish timeout - @pytest.fixture(scope="module") - def last_workfile_path(self, download_test_data): - """Get last_workfile_path from source data. - - Maya expects workfile in proper folder, so copy is done first. - """ - src_path = os.path.join(download_test_data, - "input", - "workfile", - "test_project_test_asset_TestTask_v001.aep") - dest_folder = os.path.join(download_test_data, - self.PROJECT, - self.ASSET, - "work", - self.TASK) - os.makedirs(dest_folder) - dest_path = os.path.join(dest_folder, - "test_project_test_asset_TestTask_v001.aep") - shutil.copy(src_path, dest_path) - - yield dest_path - - @pytest.fixture(scope="module") - def startup_scripts(self, monkeypatch_session, download_test_data): - """Points AfterEffects to userSetup file from input data""" - pass - def test_db_asserts(self, dbcon, publish_finished): """Host and input data dependent expected results in DB.""" print("test_db_asserts") + failures = [] - assert 2 == dbcon.count_documents({"type": "version"}), \ - "Not expected no of versions" + failures.append(DBAssert.count_of_types(dbcon, "version", 2)) - assert 0 == dbcon.count_documents({"type": "version", - "name": {"$ne": 1}}), \ - "Only versions with 1 expected" + failures.append( + DBAssert.count_of_types(dbcon, "version", 0, name={"$ne": 1})) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "imageMainBackgroundcopy" - }), \ - "modelMain subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="imageMainBackgroundcopy")) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "workfileTest_task"}), \ - "workfileTesttask subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="workfileTest_task")) - assert 1 == dbcon.count_documents({"type": "subset", - "name": "reviewTesttask"}), \ - "reviewTesttask subset must be present" + failures.append( + DBAssert.count_of_types(dbcon, "subset", 1, + name="reviewTesttask")) - assert 4 == dbcon.count_documents({"type": "representation"}), \ - "Not expected no of representations" + failures.append( + DBAssert.count_of_types(dbcon, "representation", 4)) - assert 1 == dbcon.count_documents({"type": "representation", - "context.subset": "renderTestTaskDefault", # noqa E501 - "context.ext": "png"}), \ - "Not expected no of representations with ext 'png'" + additional_args = {"context.subset": "renderTestTaskDefault", + "context.ext": "png"} + failures.append( + DBAssert.count_of_types(dbcon, "representation", 1, + additional_args=additional_args)) + + assert not any(failures) if __name__ == "__main__": From ea54edb9cdc5fb25d71bb6446aa41ce5f6f428bf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 15:49:37 +0100 Subject: [PATCH 046/152] Fix wrong label --- openpype/hosts/photoshop/api/launch_logic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/api/launch_logic.py b/openpype/hosts/photoshop/api/launch_logic.py index 16a1d23244..112cd8fe3f 100644 --- a/openpype/hosts/photoshop/api/launch_logic.py +++ b/openpype/hosts/photoshop/api/launch_logic.py @@ -175,7 +175,7 @@ class ProcessLauncher(QtCore.QObject): def start(self): if self._started: return - self.log.info("Started launch logic of AfterEffects") + self.log.info("Started launch logic of Photoshop") self._started = True self._start_process_timer.start() From cc9fa259dd55b97373855c84af56c885c75c4654 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 16:11:32 +0100 Subject: [PATCH 047/152] Moved tests for RoyalRender to unit tests folder --- .../openpype}/default_modules/royal_render/test_rr_job.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{openpype/modules => unit/openpype}/default_modules/royal_render/test_rr_job.py (100%) diff --git a/tests/openpype/modules/default_modules/royal_render/test_rr_job.py b/tests/unit/openpype/default_modules/royal_render/test_rr_job.py similarity index 100% rename from tests/openpype/modules/default_modules/royal_render/test_rr_job.py rename to tests/unit/openpype/default_modules/royal_render/test_rr_job.py From c6805d5b5af8eca0e531f4e632c1976dc31b7100 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 18:08:15 +0100 Subject: [PATCH 048/152] Added timeout to command line arguments --- openpype/cli.py | 9 +++++++-- openpype/pype_commands.py | 5 ++++- tests/integration/conftest.py | 10 ++++++++++ tests/lib/testing_classes.py | 7 +++++-- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/openpype/cli.py b/openpype/cli.py index 0597c387d0..6851541060 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -371,10 +371,15 @@ def run(script): "--app_variant", help="Provide specific app variant for test, empty for latest", default=None) -def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant): +@click.option("-t", + "--timeout", + help="Provide specific timeout value for test case", + default=None) +def runtests(folder, mark, pyargs, test_data_folder, persist, app_variant, + timeout): """Run all automatic tests after proper initialization via start.py""" PypeCommands().run_tests(folder, mark, pyargs, test_data_folder, - persist, app_variant) + persist, app_variant, timeout) @main.command() diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 47f5e7fcc0..9704b9198f 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -363,7 +363,7 @@ class PypeCommands: pass def run_tests(self, folder, mark, pyargs, - test_data_folder, persist, app_variant): + test_data_folder, persist, app_variant, timeout): """ Runs tests from 'folder' @@ -401,6 +401,9 @@ class PypeCommands: if app_variant: args.extend(["--app_variant", app_variant]) + if timeout: + args.extend(["--timeout", timeout]) + print("run_tests args: {}".format(args)) import pytest pytest.main(args) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 400c0dcc2a..aa850be1a6 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -19,6 +19,11 @@ def pytest_addoption(parser): help="Keep empty to locate latest installed variant or explicit" ) + parser.addoption( + "--timeout", action="store", default=None, + help="Overwrite default timeout" + ) + @pytest.fixture(scope="module") def test_data_folder(request): @@ -33,3 +38,8 @@ def persist(request): @pytest.fixture(scope="module") def app_variant(request): return request.config.getoption("--app_variant") + + +@pytest.fixture(scope="module") +def timeout(request): + return request.config.getoption("--timeout") diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py index fa467acf9c..0a9da1aca8 100644 --- a/tests/lib/testing_classes.py +++ b/tests/lib/testing_classes.py @@ -293,13 +293,16 @@ class PublishTest(ModuleUnitTest): yield app_process @pytest.fixture(scope="module") - def publish_finished(self, dbcon, launched_app, download_test_data): + def publish_finished(self, dbcon, launched_app, download_test_data, + timeout): """Dummy fixture waiting for publish to finish""" import time time_start = time.time() + timeout = timeout or self.TIMEOUT + timeout = float(timeout) while launched_app.poll() is None: time.sleep(0.5) - if time.time() - time_start > self.TIMEOUT: + if time.time() - time_start > timeout: launched_app.terminate() raise ValueError("Timeout reached") From f618c00ad780207314d70601491cfc0941aca4ca Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 18:08:54 +0100 Subject: [PATCH 049/152] Fixed readme for start of tests --- tests/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/README.md b/tests/README.md index d0578f8059..854d56718c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,11 +15,11 @@ How to run: - single test class could be run by PyCharm and its pytest runner directly - OR - use Openpype command 'runtests' from command line (`.venv` in ${OPENPYPE_ROOT} must be activated to use configured Python!) --- `${OPENPYPE_ROOT}/python start.py runtests` +-- `python ${OPENPYPE_ROOT}/start.py runtests` By default, this command will run all tests in ${OPENPYPE_ROOT}/tests. Specific location could be provided to this command as an argument, either as absolute path, or relative path to ${OPENPYPE_ROOT}. -(eg. `${OPENPYPE_ROOT}/python start.py runtests ../tests/integration`) will trigger only tests in `integration` folder. +(eg. `python ${OPENPYPE_ROOT}/start.py start.py runtests ../tests/integration`) will trigger only tests in `integration` folder. See `${OPENPYPE_ROOT}/cli.py:runtests` for other arguments. From f902d0dd78158e2aa8e8d2916edd026b0c1b0ad6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Feb 2022 18:13:53 +0100 Subject: [PATCH 050/152] First iteration of developer documentation for testing --- website/docs/dev_testing.md | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 website/docs/dev_testing.md diff --git a/website/docs/dev_testing.md b/website/docs/dev_testing.md new file mode 100644 index 0000000000..7f082951f7 --- /dev/null +++ b/website/docs/dev_testing.md @@ -0,0 +1,126 @@ +--- +id: dev_testing +title: Testing in OpenPype +sidebar_label: Testing +--- + +## Introduction +As OpenPype is growing there also grows need for automatic testing. There are already bunch of tests present in root folder of OpenPype directory. +But many tests should be yet created! + +### How to run tests + +If you would like to experiment with provided tests, and have particular DCC installed on your machine, you could run test for this DCC by: + +- From source: +``` +- use Openpype command 'runtests' from command line (`.venv` in ${OPENPYPE_ROOT} must be activated to use configured Python!) +- `python ${OPENPYPE_ROOT}/start.py runtests ../tests/integration/hosts/nuke` +``` +- From build: +``` +- ${OPENPYPE_BUILD}/openpype_console run {ABSOLUT_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` +``` + + +### Content of tests folder + +Main tests folder contains hierarchy of folders with tests and supporting lib files. It is intended that tests should be run separately in each folder in the hierarchy. + +Main folders in the structure: +- integration - end to end tests in applications, mimicking regular publishing process +- lib - helper classes +- resources - test data skeletons etc. +- unit - unit test covering methods and functions in OP + + +### lib folder + +This location should contain library of helpers and miscellaneous classes used for integration or unit tests. + +Content: +- `assert_classes.py` - helpers for easier use of assert expressions +- `db_handler.py` - class for creation of DB dumps/restore/purge +- `file_hanlder.py` - class for preparation/cleanup of test data +- `testing_classes.py` - base classes for testing of publish in various DCCs + +### integration folder + +Contains end to end testing in DCC. Currently it is setup to start DCC application with prepared worfkile, run publish process and compare results in DB and file system automatically. +This approach is implemented as it should work in any DCC application and should cover most common use cases. + +There will be also possibility to build workfile and publish it programmatically, this would work only in DCCs that support it (Maya, Nuke). + +It is expected that each test class should work with single worfkile with supporting resources (as a dump of project DB, all necessary environment variables, expected published files etc.) + +There are currently implemented basic publish tests for `Maya`, `Nuke`, `AfterEffects` and `Photoshop`. Additional hosts will be added. + +Each `test_` class should contain single test class based on `tests.lib.testing_classes.PublishTest`. This base class handles all necessary +functionality for testing in a host application. + +#### Steps of publish test + +Each publish test is consisted of areas: +- preparation +- launch of host application +- publish +- comparison of results in DB and file system +- cleanup + +##### Preparation + +For each test publish case is expected zip file with this structure: +- expected - published files after workfile is published (in same structure as in regular manual publish) +- input + - dumps - database dumps (check `tests.lib.db_handler` for implemented functionality) + - openpype - settings + - test_db - skeleton of test project (contains project document, asset document etc.) + - env_vars - `env_var.json` file with a dictionary of all required environment variables + - json - json files with human readable content of databases + - startup - any required initialization scripts (for example Nuke requires one `init.py` file) + - workfile - contains single workfile + +These folders needs to be zipped (in zip's root must be this structure directly!), currently zip files for all prepared tests are stored in OpenPype GDrive folder. + +##### Launch of application and publish + +Integration tests are using same approach as OpenPype process regarding launching of host applications (eg. `ApplicationManager().launch`). +Each host application is in charge of triggering of publish process and closing itself. Different hosts handle this differently, Adobe products are handling this via injected "HEADLESS_PUBLISH" environment variable, +Maya and Nuke must contain this in their's startup files. + +Base `PublishTest` class contains configurable timeout in case of publish process is not working, or taking too long. + +##### Comparison of results + +Each test class requires re-iplemented `PublishTest.test_db_asserts` fixture. This method is triggered after publish is finished and should +compare current results in DB (each test has its own database which gets filled with dump data first, cleaned up after test finishing) with expected results. + +`tests.lib.assert_classes.py` contains prepared method `count_of_types` which makes easier to write assert expression. + +Basic use case: +```DBAssert.count_of_types(dbcon, "version", 2)``` >> It is expected that DB contains only 2 documents of `type==version` + +If zip file contains file structure in `expected` folder, `PublishTest.test_folder_structure_same` implements comparison of expected and published file structure, +eg. if test case published all expected files. + +##### Cleanup + +By default, each test case pulls data from GDrive, unzips them in temporary folder, runs publish, compares results and then +purges created temporary test database and temporary folder. This could be changed by setting of `PublishTest.PERSIST`. + +In case you want to modify test data, use `PublishTest.TEST_DATA_FOLDER` to point test to specific location. + +Both options are mostly useful for debugging during implementation of new test cases. + +#### Test configuration + +Each test case could be configured from command line with: +- test_data_folder - use specific folder with extracted test zip file +- persist - keep content of temporary folder and database after test finishes +- app_variant - run test for specific version of host app, matches app variants in Settings, eg. `2021` for Photoshop, `12-2` for Nuke +- timeout - override default time (in seconds) + +### unit folder + +Here should be located unit tests for classes, methods etc. As most classes expect to be triggered in OpenPype context, best option is to +start these test in similar fashion as `integration` tests (eg. via `runtests`). \ No newline at end of file From 351a42fac9a03a8f5f9cbb8cda47760fa88bfa28 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Feb 2022 11:26:35 +0100 Subject: [PATCH 051/152] OP-2551 - fix broken link to settings png --- website/docs/admin_settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/admin_settings.md b/website/docs/admin_settings.md index ba4bb1a9be..9b00e6c612 100644 --- a/website/docs/admin_settings.md +++ b/website/docs/admin_settings.md @@ -22,7 +22,7 @@ We use simple colour coding to show you any changes to the settings: - **Orange**: [Project Override](#project-overrides) - **Blue**: Changed and unsaved value -![Colour coding](assets\settings\settings_colour_coding.png) +![Colour coding](assets/settings/settings_colour_coding.png) You'll find that settings are split into categories: From 39920905999d07bf89f90a4a2cfb6bbfd43e5884 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Feb 2022 11:38:54 +0100 Subject: [PATCH 052/152] OP-2551 - added new section for Dev docs Added basic dev introduction page Updates to dev_testing.md --- website/docs/dev_introduction.md | 10 ++++ website/docs/dev_testing.md | 83 +++++++++++++++++++------------- website/docusaurus.config.js | 8 ++- website/sidebars.js | 14 +++++- 4 files changed, 80 insertions(+), 35 deletions(-) create mode 100644 website/docs/dev_introduction.md diff --git a/website/docs/dev_introduction.md b/website/docs/dev_introduction.md new file mode 100644 index 0000000000..22a23fc523 --- /dev/null +++ b/website/docs/dev_introduction.md @@ -0,0 +1,10 @@ +--- +id: dev_introduction +title: Introduction +sidebar_label: Introduction +--- + + +Here you should find additional information targetted on developers who would like to contribute or dive deeper into OpenPype platform + +Currently there are details about automatic testing, in the future this should be location for API definition and documentation \ No newline at end of file diff --git a/website/docs/dev_testing.md b/website/docs/dev_testing.md index 7f082951f7..08864988cf 100644 --- a/website/docs/dev_testing.md +++ b/website/docs/dev_testing.md @@ -6,11 +6,15 @@ sidebar_label: Testing ## Introduction As OpenPype is growing there also grows need for automatic testing. There are already bunch of tests present in root folder of OpenPype directory. -But many tests should be yet created! +But many tests should yet be created! -### How to run tests +### How to run (integration) tests -If you would like to experiment with provided tests, and have particular DCC installed on your machine, you could run test for this DCC by: +#### Requirements +- installed DCC you want to test +- `mongorestore` on a PATH + +If you would like just to experiment with provided integration tests, and have particular DCC installed on your machine, you could run test for this host by: - From source: ``` @@ -21,17 +25,17 @@ If you would like to experiment with provided tests, and have particular DCC ins ``` - ${OPENPYPE_BUILD}/openpype_console run {ABSOLUT_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` ``` - +Modify tests path argument to limit which tests should be run (`../tests/integration` will run all implemented integration tests). ### Content of tests folder -Main tests folder contains hierarchy of folders with tests and supporting lib files. It is intended that tests should be run separately in each folder in the hierarchy. +Main tests folder contains hierarchy of folders with tests and supporting lib files. It is intended that tests in each folder of the hierarchy could be run separately. Main folders in the structure: -- integration - end to end tests in applications, mimicking regular publishing process -- lib - helper classes -- resources - test data skeletons etc. -- unit - unit test covering methods and functions in OP +- `integration` - end to end tests in host applications, mimicking regular publishing process +- `lib` - helper classes +- `resources` - test data skeletons etc. +- `unit` - unit test covering methods and functions in OP ### lib folder @@ -46,10 +50,11 @@ Content: ### integration folder -Contains end to end testing in DCC. Currently it is setup to start DCC application with prepared worfkile, run publish process and compare results in DB and file system automatically. -This approach is implemented as it should work in any DCC application and should cover most common use cases. +Contains end to end testing in a DCC. Currently it is setup to start DCC application with prepared worfkile, run publish process and compare results in DB and file system automatically. +This approach is implemented as it should work in any DCC application and should cover most common use cases. Not all hosts allow "real headless" publishing, but all hosts should allow to trigger +publish process programatically when UI of host is actually running. -There will be also possibility to build workfile and publish it programmatically, this would work only in DCCs that support it (Maya, Nuke). +There will be eventually also possibility to build workfile and publish it programmatically, this would work only in DCCs that support it (Maya, Nuke). It is expected that each test class should work with single worfkile with supporting resources (as a dump of project DB, all necessary environment variables, expected published files etc.) @@ -60,7 +65,7 @@ functionality for testing in a host application. #### Steps of publish test -Each publish test is consisted of areas: +Each publish test consists of areas: - preparation - launch of host application - publish @@ -69,24 +74,35 @@ Each publish test is consisted of areas: ##### Preparation -For each test publish case is expected zip file with this structure: -- expected - published files after workfile is published (in same structure as in regular manual publish) -- input - - dumps - database dumps (check `tests.lib.db_handler` for implemented functionality) - - openpype - settings - - test_db - skeleton of test project (contains project document, asset document etc.) - - env_vars - `env_var.json` file with a dictionary of all required environment variables - - json - json files with human readable content of databases - - startup - any required initialization scripts (for example Nuke requires one `init.py` file) - - workfile - contains single workfile +Each test publish case expects zip file with this structure: +- `expected` - published files after workfile is published (in same structure as in regular manual publish) +- `input` + - `dumps` - database dumps (check `tests.lib.db_handler` for implemented functionality) + - `openpype` - settings + - `test_db` - skeleton of test project (contains project document, asset document etc.) + - `env_vars` - `env_var.json` file with a dictionary of all required environment variables + - `json` - json files with human readable content of databases + - `startup` - any required initialization scripts (for example Nuke requires one `init.py` file) + - `workfile` - contains single workfile These folders needs to be zipped (in zip's root must be this structure directly!), currently zip files for all prepared tests are stored in OpenPype GDrive folder. +Each test then goes in steps (by default): +- download test data zip +- create temporary folder and unzip there data zip file +- purge test DB if exists, import dump files from unzipped folder +- sets environment variables from `env_vars` folder +- launches host application and trigger publish process +- waits until publish process finishes, application closes (or timeouts) +- compares results in DB with expected values +- compares published files structure with expected values +- cleans up temporary test DB and folder + ##### Launch of application and publish Integration tests are using same approach as OpenPype process regarding launching of host applications (eg. `ApplicationManager().launch`). Each host application is in charge of triggering of publish process and closing itself. Different hosts handle this differently, Adobe products are handling this via injected "HEADLESS_PUBLISH" environment variable, -Maya and Nuke must contain this in their's startup files. +Maya and Nuke must contain this in theirs startup files. Base `PublishTest` class contains configurable timeout in case of publish process is not working, or taking too long. @@ -95,7 +111,7 @@ Base `PublishTest` class contains configurable timeout in case of publish proces Each test class requires re-iplemented `PublishTest.test_db_asserts` fixture. This method is triggered after publish is finished and should compare current results in DB (each test has its own database which gets filled with dump data first, cleaned up after test finishing) with expected results. -`tests.lib.assert_classes.py` contains prepared method `count_of_types` which makes easier to write assert expression. +`tests.lib.assert_classes.py` contains prepared method `count_of_types` which makes easier to write assert expression. This method also produces formatted error message. Basic use case: ```DBAssert.count_of_types(dbcon, "version", 2)``` >> It is expected that DB contains only 2 documents of `type==version` @@ -106,21 +122,22 @@ eg. if test case published all expected files. ##### Cleanup By default, each test case pulls data from GDrive, unzips them in temporary folder, runs publish, compares results and then -purges created temporary test database and temporary folder. This could be changed by setting of `PublishTest.PERSIST`. +purges created temporary test database and temporary folder. This could be changed by setting of `PublishTest.PERSIST`. If set to True, DB and published folder are kept intact +until next run of any test. -In case you want to modify test data, use `PublishTest.TEST_DATA_FOLDER` to point test to specific location. +In case you want to modify test data, use `PublishTest.TEST_DATA_FOLDER` to point test to specific location where test folder is already unzipped. Both options are mostly useful for debugging during implementation of new test cases. #### Test configuration Each test case could be configured from command line with: -- test_data_folder - use specific folder with extracted test zip file -- persist - keep content of temporary folder and database after test finishes -- app_variant - run test for specific version of host app, matches app variants in Settings, eg. `2021` for Photoshop, `12-2` for Nuke -- timeout - override default time (in seconds) +- `test_data_folder` - use specific folder with extracted test zip file +- `persist` - keep content of temporary folder and database after test finishes +- `app_variant` - run test for specific version of host app, matches app variants in Settings, eg. `2021` for Photoshop, `12-2` for Nuke +- `timeout` - override default time (in seconds) ### unit folder -Here should be located unit tests for classes, methods etc. As most classes expect to be triggered in OpenPype context, best option is to -start these test in similar fashion as `integration` tests (eg. via `runtests`). \ No newline at end of file +Here should be located unit tests for classes, methods of OpenPype etc. As most classes expect to be triggered in OpenPype context, best option is to +start these tests in similar fashion as `integration` tests (eg. via `runtests`). \ No newline at end of file diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 026917b58f..b9ada026e1 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -58,10 +58,16 @@ module.exports = { to: 'docs/artist_getting_started', label: 'User Docs', position: 'left' - }, { + }, + { to: 'docs/system_introduction', label: 'Admin Docs', position: 'left' + }, + { + to: 'docs/dev_introduction', + label: 'Dev Docs', + position: 'left' }, { to: 'https://pype.club', diff --git a/website/sidebars.js b/website/sidebars.js index 38e4206b84..d819796991 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -50,7 +50,6 @@ module.exports = { "dev_build", "admin_distribute", "admin_use", - "dev_contribute", "admin_openpype_commands", ], }, @@ -133,4 +132,17 @@ module.exports = { ], }, ], + Dev: [ + "dev_introduction", + { + type: "category", + label: "Dev documentation", + items: [ + "dev_requirements", + "dev_build", + "dev_testing", + "dev_contribute", + ], + } + ] }; From 5852e2c9978d75ae948197ac14e9db1fb93f279f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 18 Feb 2022 17:07:37 +0100 Subject: [PATCH 053/152] Update website/docs/dev_testing.md Co-authored-by: Milan Kolar --- website/docs/dev_testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/dev_testing.md b/website/docs/dev_testing.md index 08864988cf..cab298ae37 100644 --- a/website/docs/dev_testing.md +++ b/website/docs/dev_testing.md @@ -23,7 +23,7 @@ If you would like just to experiment with provided integration tests, and have p ``` - From build: ``` -- ${OPENPYPE_BUILD}/openpype_console run {ABSOLUT_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` +- ${OPENPYPE_BUILD}/openpype_console run {ABSOLUTE_PATH_OPENPYPE_ROOT}/tests/integration/hosts/nuke` ``` Modify tests path argument to limit which tests should be run (`../tests/integration` will run all implemented integration tests). From dc911a85baf40c84f5f35425a105abc99841cd72 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Fri, 18 Feb 2022 18:57:05 +0100 Subject: [PATCH 054/152] Bugfix make_image_sequence with one layer --- openpype/hosts/photoshop/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index 455c7b43a3..b8f4470c7b 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -35,7 +35,7 @@ class ExtractReview(openpype.api.Extractor): layers = self._get_layers_from_image_instances(instance) self.log.info("Layers image instance found: {}".format(layers)) - if self.make_image_sequence and layers: + if self.make_image_sequence and len(layers) > 1: self.log.info("Extract layers to image sequence.") img_list = self._saves_sequences_layers(staging_dir, layers) From a9506a14806fe92b1d86bb68a3cd84a5749b4141 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:31:31 +0100 Subject: [PATCH 055/152] extracted template formatting logic from anatomy --- openpype/lib/path_templates.py | 745 +++++++++++++++++++++++++++++++++ 1 file changed, 745 insertions(+) create mode 100644 openpype/lib/path_templates.py diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py new file mode 100644 index 0000000000..6f68cc4ce9 --- /dev/null +++ b/openpype/lib/path_templates.py @@ -0,0 +1,745 @@ +import os +import re +import copy +import numbers +import collections + +import six + +from .log import PypeLogger + +log = PypeLogger.get_logger(__name__) + + +KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})") +KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+") +SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)") +OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") + + +def merge_dict(main_dict, enhance_dict): + """Merges dictionaries by keys. + + Function call itself if value on key is again dictionary. + + Args: + main_dict (dict): First dict to merge second one into. + enhance_dict (dict): Second dict to be merged. + + Returns: + dict: Merged result. + + .. note:: does not overrides whole value on first found key + but only values differences from enhance_dict + + """ + for key, value in enhance_dict.items(): + if key not in main_dict: + main_dict[key] = value + elif isinstance(value, dict) and isinstance(main_dict[key], dict): + main_dict[key] = merge_dict(main_dict[key], value) + else: + main_dict[key] = value + return main_dict + + +class TemplateMissingKey(Exception): + """Exception for cases when key does not exist in template.""" + + msg = "Template key does not exist: `{}`." + + def __init__(self, parents): + parent_join = "".join(["[\"{0}\"]".format(key) for key in parents]) + super(TemplateMissingKey, self).__init__( + self.msg.format(parent_join) + ) + + +class TemplateUnsolved(Exception): + """Exception for unsolved template when strict is set to True.""" + + msg = "Template \"{0}\" is unsolved.{1}{2}" + invalid_types_msg = " Keys with invalid DataType: `{0}`." + missing_keys_msg = " Missing keys: \"{0}\"." + + def __init__(self, template, missing_keys, invalid_types): + invalid_type_items = [] + for _key, _type in invalid_types.items(): + invalid_type_items.append( + "\"{0}\" {1}".format(_key, str(_type)) + ) + + invalid_types_msg = "" + if invalid_type_items: + invalid_types_msg = self.invalid_types_msg.format( + ", ".join(invalid_type_items) + ) + + missing_keys_msg = "" + if missing_keys: + missing_keys_msg = self.missing_keys_msg.format( + ", ".join(missing_keys) + ) + super(TemplateUnsolved, self).__init__( + self.msg.format(template, missing_keys_msg, invalid_types_msg) + ) + + +class StringTemplate(object): + """String that can be formatted.""" + def __init__(self, template): + if not isinstance(template, six.string_types): + raise TypeError("<{}> argument must be a string, not {}.".format( + self.__class__.__name__, str(type(template)) + )) + + self._template = template + parts = [] + last_end_idx = 0 + for item in KEY_PATTERN.finditer(template): + start, end = item.span() + if start > last_end_idx: + parts.append(template[last_end_idx:start]) + parts.append(FormattingPart(template[start:end])) + last_end_idx = end + + if last_end_idx < len(template): + parts.append(template[last_end_idx:len(template)]) + + new_parts = [] + for part in parts: + if not isinstance(part, six.string_types): + new_parts.append(part) + continue + + substr = "" + for char in part: + if char not in ("<", ">"): + substr += char + else: + if substr: + new_parts.append(substr) + new_parts.append(char) + substr = "" + if substr: + new_parts.append(substr) + + self._parts = self.find_optional_parts(new_parts) + + @property + def template(self): + return self._template + + def format(self, data): + """ Figure out with whole formatting. + + Separate advanced keys (*Like '{project[name]}') from string which must + be formatted separatelly in case of missing or incomplete keys in data. + + Args: + data (dict): Containing keys to be filled into template. + + Returns: + TemplateResult: Filled or partially filled template containing all + data needed or missing for filling template. + """ + result = TemplatePartResult() + for part in self._parts: + if isinstance(part, six.string_types): + result.add_output(part) + else: + part.format(data, result) + + invalid_types = result.invalid_types + invalid_types.update(result.invalid_optional_types) + invalid_types = result.split_keys_to_subdicts(invalid_types) + + missing_keys = result.missing_keys + missing_keys |= result.missing_optional_keys + + solved = result.solved + used_values = result.split_keys_to_subdicts(result.used_values) + + return TemplateResult( + result.output, + self.template, + solved, + used_values, + missing_keys, + invalid_types + ) + + def format_strict(self, *args, **kwargs): + result = self.format(*args, **kwargs) + result.validate() + return result + + @staticmethod + def find_optional_parts(parts): + new_parts = [] + tmp_parts = {} + counted_symb = -1 + for part in parts: + if part == "<": + counted_symb += 1 + tmp_parts[counted_symb] = [] + + elif part == ">": + if counted_symb > -1: + parts = tmp_parts.pop(counted_symb) + counted_symb -= 1 + if parts: + # Remove optional start char + parts.pop(0) + if counted_symb < 0: + out_parts = new_parts + else: + out_parts = tmp_parts[counted_symb] + # Store temp parts + out_parts.append(OptionalPart(parts)) + continue + + if counted_symb < 0: + new_parts.append(part) + else: + tmp_parts[counted_symb].append(part) + + if tmp_parts: + for idx in sorted(tmp_parts.keys()): + new_parts.extend(tmp_parts[idx]) + return new_parts + + +class TemplatesDict(object): + def __init__(self, templates=None): + self._raw_templates = None + self._templates = None + self.set_templates(templates) + + def set_templates(self, templates): + if templates is None: + self._raw_templates = None + self._templates = None + elif isinstance(templates, dict): + self._raw_templates = copy.deepcopy(templates) + self._templates = self.create_ojected_templates(templates) + else: + raise TypeError("<{}> argument must be a dict, not {}.".format( + self.__class__.__name__, str(type(templates)) + )) + + def __getitem__(self, key): + return self.templates[key] + + def get(self, key, *args, **kwargs): + return self.templates.get(key, *args, **kwargs) + + @property + def raw_templates(self): + return self._raw_templates + + @property + def templates(self): + return self._templates + + @classmethod + def create_ojected_templates(cls, templates): + if not isinstance(templates, dict): + raise TypeError("Expected dict object, got {}".format( + str(type(templates)) + )) + + objected_templates = copy.deepcopy(templates) + inner_queue = collections.deque() + inner_queue.append(objected_templates) + while inner_queue: + item = inner_queue.popleft() + if not isinstance(item, dict): + continue + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, six.string_types): + item[key] = StringTemplate(value) + elif isinstance(value, dict): + inner_queue.append(value) + return objected_templates + + def _format_value(self, value, data): + if isinstance(value, StringTemplate): + return value.format(data) + + if isinstance(value, dict): + return self._solve_dict(value, data) + return value + + def _solve_dict(self, templates, data): + """ Solves templates with entered data. + + Args: + templates (dict): All templates which will be formatted. + data (dict): Containing keys to be filled into template. + + Returns: + dict: With `TemplateResult` in values containing filled or + partially filled templates. + """ + output = collections.defaultdict(dict) + for key, value in templates.items(): + output[key] = self._format_value(value, data) + + return output + + def format(self, in_data, only_keys=True, strict=True): + """ Solves templates based on entered data. + + Args: + data (dict): Containing keys to be filled into template. + only_keys (bool, optional): Decides if environ will be used to + fill templates or only keys in data. + + Returns: + TemplatesResultDict: Output `TemplateResult` have `strict` + attribute set to True so accessing unfilled keys in templates + will raise exceptions with explaned error. + """ + # Create a copy of inserted data + data = copy.deepcopy(in_data) + + # Add environment variable to data + if only_keys is False: + for key, val in os.environ.items(): + env_key = "$" + key + if env_key not in data: + data[env_key] = val + + solved = self._solve_dict(self.templates, data) + + output = TemplatesResultDict(solved) + output.strict = strict + return output + + +class TemplateResult(str): + """Result of template format with most of information in. + + Args: + used_values (dict): Dictionary of template filling data with + only used keys. + solved (bool): For check if all required keys were filled. + template (str): Original template. + missing_keys (list): Missing keys that were not in the data. Include + missing optional keys. + invalid_types (dict): When key was found in data, but value had not + allowed DataType. Allowed data types are `numbers`, + `str`(`basestring`) and `dict`. Dictionary may cause invalid type + when value of key in data is dictionary but template expect string + of number. + """ + used_values = None + solved = None + template = None + missing_keys = None + invalid_types = None + + def __new__( + cls, filled_template, template, solved, + used_values, missing_keys, invalid_types + ): + new_obj = super(TemplateResult, cls).__new__(cls, filled_template) + new_obj.used_values = used_values + new_obj.solved = solved + new_obj.template = template + new_obj.missing_keys = list(set(missing_keys)) + new_obj.invalid_types = invalid_types + return new_obj + + def validate(self): + if not self.solved: + raise TemplateUnsolved( + self.template, + self.missing_keys, + self.invalid_types + ) + + +class TemplatesResultDict(dict): + """Holds and wrap TemplateResults for easy bug report.""" + + def __init__(self, in_data, key=None, parent=None, strict=None): + super(TemplatesResultDict, self).__init__() + for _key, _value in in_data.items(): + if isinstance(_value, dict): + _value = self.__class__(_value, _key, self) + self[_key] = _value + + self.key = key + self.parent = parent + self.strict = strict + if self.parent is None and strict is None: + self.strict = True + + def __getitem__(self, key): + if key not in self.keys(): + hier = self.hierarchy() + hier.append(key) + raise TemplateMissingKey(hier) + + value = super(TemplatesResultDict, self).__getitem__(key) + if isinstance(value, self.__class__): + return value + + # Raise exception when expected solved templates and it is not. + if self.raise_on_unsolved and hasattr(value, "validate"): + value.validate() + return value + + @property + def raise_on_unsolved(self): + """To affect this change `strict` attribute.""" + if self.strict is not None: + return self.strict + return self.parent.raise_on_unsolved + + def hierarchy(self): + """Return dictionary keys one by one to root parent.""" + if self.parent is None: + return [] + + hier_keys = [] + par_hier = self.parent.hierarchy() + if par_hier: + hier_keys.extend(par_hier) + hier_keys.append(self.key) + + return hier_keys + + @property + def missing_keys(self): + """Return missing keys of all children templates.""" + missing_keys = set() + for value in self.values(): + missing_keys |= value.missing_keys + return missing_keys + + @property + def invalid_types(self): + """Return invalid types of all children templates.""" + invalid_types = {} + for value in self.values(): + invalid_types = merge_dict(invalid_types, value.invalid_types) + return invalid_types + + @property + def used_values(self): + """Return used values for all children templates.""" + used_values = {} + for value in self.values(): + used_values = merge_dict(used_values, value.used_values) + return used_values + + def get_solved(self): + """Get only solved key from templates.""" + result = {} + for key, value in self.items(): + if isinstance(value, self.__class__): + value = value.get_solved() + if not value: + continue + result[key] = value + + elif ( + not hasattr(value, "solved") or + value.solved + ): + result[key] = value + return self.__class__(result, key=self.key, parent=self.parent) + + +class TemplatePartResult: + """Result to store result of template parts.""" + def __init__(self, optional=False): + # Missing keys or invalid value types of required keys + self._missing_keys = set() + self._invalid_types = {} + # Missing keys or invalid value types of optional keys + self._missing_optional_keys = set() + self._invalid_optional_types = {} + + # Used values stored by key + # - key without any padding or key modifiers + # - value from filling data + # Example: {"version": 1} + self._used_values = {} + # Used values stored by key with all modifirs + # - value is already formatted string + # Example: {"version:0>3": "001"} + self._realy_used_values = {} + # Concatenated string output after formatting + self._output = "" + # Is this result from optional part + self._optional = True + + def add_output(self, other): + if isinstance(other, six.string_types): + self._output += other + + elif isinstance(other, TemplatePartResult): + self._output += other.output + + self._missing_keys |= other.missing_keys + self._missing_optional_keys |= other.missing_optional_keys + + self._invalid_types.update(other.invalid_types) + self._invalid_optional_types.update(other.invalid_optional_types) + + if other.optional and not other.solved: + return + self._used_values.update(other.used_values) + self._realy_used_values.update(other.realy_used_values) + + else: + raise TypeError("Cannot add data from \"{}\" to \"{}\"".format( + str(type(other)), self.__class__.__name__) + ) + + @property + def solved(self): + if self.optional: + if ( + len(self.missing_optional_keys) > 0 + or len(self.invalid_optional_types) > 0 + ): + return False + return ( + len(self.missing_keys) == 0 + and len(self.invalid_types) == 0 + ) + + @property + def optional(self): + return self._optional + + @property + def output(self): + return self._output + + @property + def missing_keys(self): + return self._missing_keys + + @property + def missing_optional_keys(self): + return self._missing_optional_keys + + @property + def invalid_types(self): + return self._invalid_types + + @property + def invalid_optional_types(self): + return self._invalid_optional_types + + @property + def realy_used_values(self): + return self._realy_used_values + + @property + def used_values(self): + return self._used_values + + @staticmethod + def split_keys_to_subdicts(values): + output = {} + for key, value in values.items(): + key_padding = list(KEY_PADDING_PATTERN.findall(key)) + if key_padding: + key = key_padding[0] + key_subdict = list(SUB_DICT_PATTERN.findall(key)) + data = output + last_key = key_subdict.pop(-1) + for subkey in key_subdict: + if subkey not in data: + data[subkey] = {} + data = data[subkey] + data[last_key] = value + return output + + def add_realy_used_value(self, key, value): + self._realy_used_values[key] = value + + def add_used_value(self, key, value): + self._used_values[key] = value + + def add_missing_key(self, key): + if self._optional: + self._missing_optional_keys.add(key) + else: + self._missing_keys.add(key) + + def add_invalid_type(self, key, value): + if self._optional: + self._invalid_optional_types[key] = type(value) + else: + self._invalid_types[key] = type(value) + + +class FormatObject(object): + def __init__(self): + self.value = "" + + def __format__(self, *args, **kwargs): + return self.value.__format__(*args, **kwargs) + + def __str__(self): + return str(self.value) + + def __repr__(self): + return self.__str__() + + +class FormattingPart: + """String with formatting template. + + Containt only single key to format e.g. "{project[name]}". + + Args: + template(str): String containing the formatting key. + """ + def __init__(self, template): + self._template = template + + @property + def template(self): + return self._template + + def __repr__(self): + return "".format(self._template) + + def __str__(self): + return self._template + + @staticmethod + def validate_value_type(value): + """Check if value can be used for formatting of single key.""" + if isinstance(value, (numbers.Number, FormatObject)): + return True + + for inh_class in type(value).mro(): + if inh_class in six.string_types: + return True + return False + + def format(self, data, result): + """Format the formattings string. + + Args: + data(dict): Data that should be used for formatting. + result(TemplatePartResult): Object where result is stored. + """ + key = self.template[1:-1] + if key in result.realy_used_values: + result.add_output(result.realy_used_values[key]) + return result + + # check if key expects subdictionary keys (e.g. project[name]) + existence_check = key + key_padding = list(KEY_PADDING_PATTERN.findall(existence_check)) + if key_padding: + existence_check = key_padding[0] + key_subdict = list(SUB_DICT_PATTERN.findall(existence_check)) + + value = data + missing_key = False + invalid_type = False + used_keys = [] + for sub_key in key_subdict: + if ( + value is None + or (hasattr(value, "items") and sub_key not in value) + ): + missing_key = True + used_keys.append(sub_key) + break + + if not hasattr(value, "items"): + invalid_type = True + break + + used_keys.append(sub_key) + value = value.get(sub_key) + + if missing_key or invalid_type: + if len(used_keys) == 0: + invalid_key = key_subdict[0] + else: + invalid_key = used_keys[0] + for idx, sub_key in enumerate(used_keys): + if idx == 0: + continue + invalid_key += "[{0}]".format(sub_key) + + if missing_key: + result.add_missing_key(invalid_key) + + elif invalid_type: + result.add_invalid_type(invalid_key, value) + + result.add_output(self.template) + return result + + if self.validate_value_type(value): + fill_data = {} + first_value = True + for used_key in reversed(used_keys): + if first_value: + first_value = False + fill_data[used_key] = value + else: + _fill_data = {used_key: fill_data} + fill_data = _fill_data + + formatted_value = self.template.format(**fill_data) + result.add_realy_used_value(key, formatted_value) + result.add_used_value(existence_check, value) + result.add_output(formatted_value) + return result + + result.add_invalid_type(key, value) + result.add_output(self.template) + + return result + + +class OptionalPart: + """Template part which contains optional formatting strings. + + If this part can't be filled the result is empty string. + + Args: + parts(list): Parts of template. Can contain 'str', 'OptionalPart' or + 'FormattingPart'. + """ + def __init__(self, parts): + self._parts = parts + + @property + def parts(self): + return self._parts + + def __str__(self): + return "<{}>".format("".join([str(p) for p in self._parts])) + + def __repr__(self): + return "".format("".join([str(p) for p in self._parts])) + + def format(self, data, result): + new_result = TemplatePartResult(True) + for part in self._parts: + if isinstance(part, six.string_types): + new_result.add_output(part) + else: + part.format(data, new_result) + + if new_result.solved: + result.add_output(new_result) + return result From a80b15d7912cb92b419699a16e0442d6dc8909f4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:33:51 +0100 Subject: [PATCH 056/152] use new templates logic in anatomy templates --- openpype/lib/__init__.py | 18 +- openpype/lib/anatomy.py | 635 +++++---------------------------- openpype/lib/path_templates.py | 4 + 3 files changed, 117 insertions(+), 540 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index ebe7648ad7..4d956d9876 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,6 +16,15 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) +from .path_templates import ( + merge_dict, + TemplateMissingKey, + TemplateUnsolved, + StringTemplate, + TemplatesDict, + FormatObject, +) + from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -41,7 +50,6 @@ from .mongo import ( OpenPypeMongoConnection ) from .anatomy import ( - merge_dict, Anatomy ) @@ -183,6 +191,13 @@ from .openpype_version import ( terminal = Terminal __all__ = [ + "merge_dict", + "TemplateMissingKey", + "TemplateUnsolved", + "StringTemplate", + "TemplatesDict", + "FormatObject", + "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", @@ -285,7 +300,6 @@ __all__ = [ "terminal", - "merge_dict", "Anatomy", "get_datetime_data", diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index fa81a18ff7..f817646cd7 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -9,6 +9,14 @@ from openpype.settings.lib import ( get_default_anatomy_settings, get_anatomy_settings ) +from .path_templates import ( + merge_dict, + TemplateUnsolved, + TemplateResult, + TemplatesResultDict, + TemplatesDict, + FormatObject, +) from .log import PypeLogger log = PypeLogger().get_logger(__name__) @@ -19,32 +27,6 @@ except NameError: StringType = str -def merge_dict(main_dict, enhance_dict): - """Merges dictionaries by keys. - - Function call itself if value on key is again dictionary. - - Args: - main_dict (dict): First dict to merge second one into. - enhance_dict (dict): Second dict to be merged. - - Returns: - dict: Merged result. - - .. note:: does not overrides whole value on first found key - but only values differences from enhance_dict - - """ - for key, value in enhance_dict.items(): - if key not in main_dict: - main_dict[key] = value - elif isinstance(value, dict) and isinstance(main_dict[key], dict): - main_dict[key] = merge_dict(main_dict[key], value) - else: - main_dict[key] = value - return main_dict - - class ProjectNotSet(Exception): """Exception raised when is created Anatomy without project name.""" @@ -59,7 +41,7 @@ class RootCombinationError(Exception): # TODO better error message msg = ( "Combination of root with and" - " without root name in Templates. {}" + " without root name in AnatomyTemplates. {}" ).format(joined_roots) super(RootCombinationError, self).__init__(msg) @@ -68,7 +50,7 @@ class RootCombinationError(Exception): class Anatomy: """Anatomy module helps to keep project settings. - Wraps key project specifications, Templates and Roots. + Wraps key project specifications, AnatomyTemplates and Roots. Args: project_name (str): Project name to look on overrides. @@ -93,7 +75,7 @@ class Anatomy: get_anatomy_settings(project_name, site_name) ) self._site_name = site_name - self._templates_obj = Templates(self) + self._templates_obj = AnatomyTemplates(self) self._roots_obj = Roots(self) # Anatomy used as dictionary @@ -158,12 +140,12 @@ class Anatomy: @property def templates(self): - """Wrap property `templates` of Anatomy's Templates instance.""" + """Wrap property `templates` of Anatomy's AnatomyTemplates instance.""" return self._templates_obj.templates @property def templates_obj(self): - """Return `Templates` object of current Anatomy instance.""" + """Return `AnatomyTemplates` object of current Anatomy instance.""" return self._templates_obj def format(self, *args, **kwargs): @@ -375,203 +357,45 @@ class Anatomy: return rootless_path.format(**data) -class TemplateMissingKey(Exception): - """Exception for cases when key does not exist in Anatomy.""" - - msg = "Anatomy key does not exist: `anatomy{0}`." - - def __init__(self, parents): - parent_join = "".join(["[\"{0}\"]".format(key) for key in parents]) - super(TemplateMissingKey, self).__init__( - self.msg.format(parent_join) - ) - - -class TemplateUnsolved(Exception): +class AnatomyTemplateUnsolved(TemplateUnsolved): """Exception for unsolved template when strict is set to True.""" msg = "Anatomy template \"{0}\" is unsolved.{1}{2}" - invalid_types_msg = " Keys with invalid DataType: `{0}`." - missing_keys_msg = " Missing keys: \"{0}\"." - def __init__(self, template, missing_keys, invalid_types): - invalid_type_items = [] - for _key, _type in invalid_types.items(): - invalid_type_items.append( - "\"{0}\" {1}".format(_key, str(_type)) - ) - invalid_types_msg = "" - if invalid_type_items: - invalid_types_msg = self.invalid_types_msg.format( - ", ".join(invalid_type_items) - ) +class AnatomyTemplateResult(TemplateResult): + rootless = None - missing_keys_msg = "" - if missing_keys: - missing_keys_msg = self.missing_keys_msg.format( - ", ".join(missing_keys) - ) - super(TemplateUnsolved, self).__init__( - self.msg.format(template, missing_keys_msg, invalid_types_msg) + def __new__(cls, result, rootless_path): + new_obj = super(AnatomyTemplateResult, cls).__new__( + cls, + str(result), + result.template, + result.solved, + result.used_values, + result.missing_keys, + result.invalid_types ) - - -class TemplateResult(str): - """Result (formatted template) of anatomy with most of information in. - - Args: - used_values (dict): Dictionary of template filling data with - only used keys. - solved (bool): For check if all required keys were filled. - template (str): Original template. - missing_keys (list): Missing keys that were not in the data. Include - missing optional keys. - invalid_types (dict): When key was found in data, but value had not - allowed DataType. Allowed data types are `numbers`, - `str`(`basestring`) and `dict`. Dictionary may cause invalid type - when value of key in data is dictionary but template expect string - of number. - """ - - def __new__( - cls, filled_template, template, solved, rootless_path, - used_values, missing_keys, invalid_types - ): - new_obj = super(TemplateResult, cls).__new__(cls, filled_template) - new_obj.used_values = used_values - new_obj.solved = solved - new_obj.template = template new_obj.rootless = rootless_path - new_obj.missing_keys = list(set(missing_keys)) - _invalid_types = {} - for invalid_type in invalid_types: - for key, val in invalid_type.items(): - if key in _invalid_types: - continue - _invalid_types[key] = val - new_obj.invalid_types = _invalid_types return new_obj - -class TemplatesDict(dict): - """Holds and wrap TemplateResults for easy bug report.""" - - def __init__(self, in_data, key=None, parent=None, strict=None): - super(TemplatesDict, self).__init__() - for _key, _value in in_data.items(): - if isinstance(_value, dict): - _value = self.__class__(_value, _key, self) - self[_key] = _value - - self.key = key - self.parent = parent - self.strict = strict - if self.parent is None and strict is None: - self.strict = True - - def __getitem__(self, key): - # Raise error about missing key in anatomy.yaml - if key not in self.keys(): - hier = self.hierarchy() - hier.append(key) - raise TemplateMissingKey(hier) - - value = super(TemplatesDict, self).__getitem__(key) - if isinstance(value, self.__class__): - return value - - # Raise exception when expected solved templates and it is not. - if ( - self.raise_on_unsolved - and (hasattr(value, "solved") and not value.solved) - ): - raise TemplateUnsolved( - value.template, value.missing_keys, value.invalid_types + def validate(self): + if not self.solved: + raise AnatomyTemplateUnsolved( + self.template, + self.missing_keys, + self.invalid_types ) - return value - - @property - def raise_on_unsolved(self): - """To affect this change `strict` attribute.""" - if self.strict is not None: - return self.strict - return self.parent.raise_on_unsolved - - def hierarchy(self): - """Return dictionary keys one by one to root parent.""" - if self.parent is None: - return [] - - hier_keys = [] - par_hier = self.parent.hierarchy() - if par_hier: - hier_keys.extend(par_hier) - hier_keys.append(self.key) - - return hier_keys - - @property - def missing_keys(self): - """Return missing keys of all children templates.""" - missing_keys = [] - for value in self.values(): - missing_keys.extend(value.missing_keys) - return list(set(missing_keys)) - - @property - def invalid_types(self): - """Return invalid types of all children templates.""" - invalid_types = {} - for value in self.values(): - for invalid_type in value.invalid_types: - _invalid_types = {} - for key, val in invalid_type.items(): - if key in invalid_types: - continue - _invalid_types[key] = val - invalid_types = merge_dict(invalid_types, _invalid_types) - return invalid_types - - @property - def used_values(self): - """Return used values for all children templates.""" - used_values = {} - for value in self.values(): - used_values = merge_dict(used_values, value.used_values) - return used_values - - def get_solved(self): - """Get only solved key from templates.""" - result = {} - for key, value in self.items(): - if isinstance(value, self.__class__): - value = value.get_solved() - if not value: - continue - result[key] = value - - elif ( - not hasattr(value, "solved") or - value.solved - ): - result[key] = value - return self.__class__(result, key=self.key, parent=self.parent) -class Templates: - key_pattern = re.compile(r"(\{.*?[^{0]*\})") - key_padding_pattern = re.compile(r"([^:]+)\S+[><]\S+") - sub_dict_pattern = re.compile(r"([^\[\]]+)") - optional_pattern = re.compile(r"(<.*?[^{0]*>)[^0-9]*?") - +class AnatomyTemplates(TemplatesDict): inner_key_pattern = re.compile(r"(\{@.*?[^{}0]*\})") inner_key_name_pattern = re.compile(r"\{@(.*?[^{}0]*)\}") def __init__(self, anatomy): + super(AnatomyTemplates, self).__init__() self.anatomy = anatomy self.loaded_project = None - self._templates = None def __getitem__(self, key): return self.templates[key] @@ -596,13 +420,51 @@ class Templates: self._templates = None if self._templates is None: - self._templates = self._discover() + self._discover() self.loaded_project = self.project_name return self._templates + def _format_value(self, value, data): + if isinstance(value, RootItem): + return self._solve_dict(value, data) + + result = super(AnatomyTemplates, self)._format_value(value, data) + if isinstance(result, TemplateResult): + rootless_path = self._rootless_path(result, data) + result = AnatomyTemplateResult(result, rootless_path) + return result + + def set_templates(self, templates): + if not templates: + self._raw_templates = None + self._templates = None + else: + self._raw_templates = copy.deepcopy(templates) + templates = copy.deepcopy(templates) + v_queue = collections.deque() + v_queue.append(templates) + while v_queue: + item = v_queue.popleft() + if not isinstance(item, dict): + continue + + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, dict): + v_queue.append(value) + + elif ( + isinstance(value, StringType) + and "{task}" in value + ): + item[key] = value.replace("{task}", "{task[name]}") + + solved_templates = self.solve_template_inner_links(templates) + self._templates = self.create_ojected_templates(solved_templates) + def default_templates(self): """Return default templates data with solved inner keys.""" - return Templates.solve_template_inner_links( + return self.solve_template_inner_links( self.anatomy["templates"] ) @@ -613,7 +475,7 @@ class Templates: TODO: create templates if not exist. Returns: - TemplatesDict: Contain templates data for current project of + TemplatesResultDict: Contain templates data for current project of default templates. """ @@ -624,7 +486,7 @@ class Templates: " Trying to use default." ).format(self.project_name)) - return Templates.solve_template_inner_links(self.anatomy["templates"]) + self.set_templates(self.anatomy["templates"]) @classmethod def replace_inner_keys(cls, matches, value, key_values, key): @@ -791,149 +653,6 @@ class Templates: return keys_by_subkey - def _filter_optional(self, template, data): - """Filter invalid optional keys. - - Invalid keys may be missing keys of with invalid value DataType. - - Args: - template (str): Anatomy template which will be formatted. - data (dict): Containing keys to be filled into template. - - Result: - tuple: Contain origin template without missing optional keys and - without optional keys identificator ("<" and ">"), information - about missing optional keys and invalid types of optional keys. - - """ - - # Remove optional missing keys - missing_keys = [] - invalid_types = [] - for optional_group in self.optional_pattern.findall(template): - _missing_keys = [] - _invalid_types = [] - for optional_key in self.key_pattern.findall(optional_group): - key = str(optional_key[1:-1]) - key_padding = list( - self.key_padding_pattern.findall(key) - ) - if key_padding: - key = key_padding[0] - - validation_result = self._validate_data_key( - key, data - ) - missing_key = validation_result["missing_key"] - invalid_type = validation_result["invalid_type"] - - valid = True - if missing_key is not None: - _missing_keys.append(missing_key) - valid = False - - if invalid_type is not None: - _invalid_types.append(invalid_type) - valid = False - - if valid: - try: - optional_key.format(**data) - except KeyError: - _missing_keys.append(key) - valid = False - - valid = len(_invalid_types) == 0 and len(_missing_keys) == 0 - missing_keys.extend(_missing_keys) - invalid_types.extend(_invalid_types) - replacement = "" - if valid: - replacement = optional_group[1:-1] - - template = template.replace(optional_group, replacement) - return (template, missing_keys, invalid_types) - - def _validate_data_key(self, key, data): - """Check and prepare missing keys and invalid types of template.""" - result = { - "missing_key": None, - "invalid_type": None - } - - # check if key expects subdictionary keys (e.g. project[name]) - key_subdict = list(self.sub_dict_pattern.findall(key)) - used_keys = [] - if len(key_subdict) <= 1: - if key not in data: - result["missing_key"] = key - return result - - used_keys.append(key) - value = data[key] - - else: - value = data - missing_key = False - invalid_type = False - for sub_key in key_subdict: - if ( - value is None - or (hasattr(value, "items") and sub_key not in value) - ): - missing_key = True - used_keys.append(sub_key) - break - - elif not hasattr(value, "items"): - invalid_type = True - break - - used_keys.append(sub_key) - value = value.get(sub_key) - - if missing_key or invalid_type: - if len(used_keys) == 0: - invalid_key = key_subdict[0] - else: - invalid_key = used_keys[0] - for idx, sub_key in enumerate(used_keys): - if idx == 0: - continue - invalid_key += "[{0}]".format(sub_key) - - if missing_key: - result["missing_key"] = invalid_key - - elif invalid_type: - result["invalid_type"] = {invalid_key: type(value)} - - return result - - if isinstance(value, (numbers.Number, Roots, RootItem)): - return result - - for inh_class in type(value).mro(): - if inh_class == StringType: - return result - - result["missing_key"] = key - result["invalid_type"] = {key: type(value)} - return result - - def _merge_used_values(self, current_used, keys, value): - key = keys[0] - _keys = keys[1:] - if len(_keys) == 0: - current_used[key] = value - else: - next_dict = {} - if key in current_used: - next_dict = current_used[key] - current_used[key] = self._merge_used_values( - next_dict, _keys, value - ) - return current_used - def _dict_to_subkeys_list(self, subdict, pre_keys=None): if pre_keys is None: pre_keys = [] @@ -956,9 +675,11 @@ class Templates: return {key_list[0]: value} return {key_list[0]: self._keys_to_dicts(key_list[1:], value)} - def _rootless_path( - self, template, used_values, final_data, missing_keys, invalid_types - ): + def _rootless_path(self, result, final_data): + used_values = result.used_values + missing_keys = result.missing_keys + template = result.template + invalid_types = result.invalid_types if ( "root" not in used_values or "root" in missing_keys @@ -974,210 +695,48 @@ class Templates: if not root_keys: return - roots_dict = {} + output = str(result) for used_root_keys in root_keys: if not used_root_keys: continue + used_value = used_values root_key = None for key in used_root_keys: + used_value = used_value[key] if root_key is None: root_key = key else: root_key += "[{}]".format(key) root_key = "{" + root_key + "}" - - roots_dict = merge_dict( - roots_dict, - self._keys_to_dicts(used_root_keys, root_key) - ) - - final_data["root"] = roots_dict["root"] - return template.format(**final_data) - - def _format(self, orig_template, data): - """ Figure out with whole formatting. - - Separate advanced keys (*Like '{project[name]}') from string which must - be formatted separatelly in case of missing or incomplete keys in data. - - Args: - template (str): Anatomy template which will be formatted. - data (dict): Containing keys to be filled into template. - - Returns: - TemplateResult: Filled or partially filled template containing all - data needed or missing for filling template. - """ - task_data = data.get("task") - if ( - isinstance(task_data, StringType) - and "{task[name]}" in orig_template - ): - # Change task to dictionary if template expect dictionary - data["task"] = {"name": task_data} - - template, missing_optional, invalid_optional = ( - self._filter_optional(orig_template, data) - ) - # Remove optional missing keys - used_values = {} - invalid_required = [] - missing_required = [] - replace_keys = [] - - for group in self.key_pattern.findall(template): - orig_key = group[1:-1] - key = str(orig_key) - key_padding = list(self.key_padding_pattern.findall(key)) - if key_padding: - key = key_padding[0] - - validation_result = self._validate_data_key(key, data) - missing_key = validation_result["missing_key"] - invalid_type = validation_result["invalid_type"] - - if invalid_type is not None: - invalid_required.append(invalid_type) - replace_keys.append(key) - continue - - if missing_key is not None: - missing_required.append(missing_key) - replace_keys.append(key) - continue - - try: - value = group.format(**data) - key_subdict = list(self.sub_dict_pattern.findall(key)) - if len(key_subdict) <= 1: - used_values[key] = value - - else: - used_values = self._merge_used_values( - used_values, key_subdict, value - ) - - except (TypeError, KeyError): - missing_required.append(key) - replace_keys.append(key) - - final_data = copy.deepcopy(data) - for key in replace_keys: - key_subdict = list(self.sub_dict_pattern.findall(key)) - if len(key_subdict) <= 1: - final_data[key] = "{" + key + "}" - continue - - replace_key_dst = "---".join(key_subdict) - replace_key_dst_curly = "{" + replace_key_dst + "}" - replace_key_src_curly = "{" + key + "}" - template = template.replace( - replace_key_src_curly, replace_key_dst_curly - ) - final_data[replace_key_dst] = replace_key_src_curly - - solved = len(missing_required) == 0 and len(invalid_required) == 0 - - missing_keys = missing_required + missing_optional - invalid_types = invalid_required + invalid_optional - - filled_template = template.format(**final_data) - # WARNING `_rootless_path` change values in `final_data` please keep - # in midn when changing order - rootless_path = self._rootless_path( - template, used_values, final_data, missing_keys, invalid_types - ) - if rootless_path is None: - rootless_path = filled_template - - result = TemplateResult( - filled_template, orig_template, solved, rootless_path, - used_values, missing_keys, invalid_types - ) - return result - - def solve_dict(self, templates, data): - """ Solves templates with entered data. - - Args: - templates (dict): All Anatomy templates which will be formatted. - data (dict): Containing keys to be filled into template. - - Returns: - dict: With `TemplateResult` in values containing filled or - partially filled templates. - """ - output = collections.defaultdict(dict) - for key, orig_value in templates.items(): - if isinstance(orig_value, StringType): - # Replace {task} by '{task[name]}' for backward compatibility - if '{task}' in orig_value: - orig_value = orig_value.replace('{task}', '{task[name]}') - - output[key] = self._format(orig_value, data) - continue - - # Check if orig_value has items attribute (any dict inheritance) - if not hasattr(orig_value, "items"): - # TODO we should handle this case - output[key] = orig_value - continue - - for s_key, s_value in self.solve_dict(orig_value, data).items(): - output[key][s_key] = s_value + output = output.replace(str(used_value), root_key) return output + def format(self, data, strict=True): + roots = self.roots + if roots: + data["root"] = roots + result = super(AnatomyTemplates, self).format(data) + result.strict = strict + return result + def format_all(self, in_data, only_keys=True): """ Solves templates based on entered data. Args: data (dict): Containing keys to be filled into template. - only_keys (bool, optional): Decides if environ will be used to - fill templates or only keys in data. Returns: - TemplatesDict: Output `TemplateResult` have `strict` attribute - set to False so accessing unfilled keys in templates won't - raise any exceptions. + TemplatesResultDict: Output `TemplateResult` have `strict` + attribute set to False so accessing unfilled keys in templates + won't raise any exceptions. """ - output = self.format(in_data, only_keys) - output.strict = False - return output - - def format(self, in_data, only_keys=True): - """ Solves templates based on entered data. - - Args: - data (dict): Containing keys to be filled into template. - only_keys (bool, optional): Decides if environ will be used to - fill templates or only keys in data. - - Returns: - TemplatesDict: Output `TemplateResult` have `strict` attribute - set to True so accessing unfilled keys in templates will - raise exceptions with explaned error. - """ - # Create a copy of inserted data - data = copy.deepcopy(in_data) - - # Add environment variable to data - if only_keys is False: - for key, val in os.environ.items(): - data["$" + key] = val - - # override root value - roots = self.roots - if roots: - data["root"] = roots - solved = self.solve_dict(self.templates, data) - - return TemplatesDict(solved) + return self.format(in_data, strict=False) -class RootItem: +class RootItem(FormatObject): """Represents one item or roots. Holds raw data of root item specification. Raw data contain value diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 6f68cc4ce9..b51951851f 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -584,6 +584,10 @@ class TemplatePartResult: class FormatObject(object): + """Object that can be used for formatting. + + This is base that is valid for to be used in 'StringTemplate' value. + """ def __init__(self): self.value = "" From 1fa2e86d1840a8a8801fdb8455930442550f792d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:36:13 +0100 Subject: [PATCH 057/152] reorganized init --- openpype/lib/__init__.py | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 4d956d9876..6ec10a2209 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -16,15 +16,6 @@ sys.path.insert(0, python_version_dir) site.addsitedir(python_version_dir) -from .path_templates import ( - merge_dict, - TemplateMissingKey, - TemplateUnsolved, - StringTemplate, - TemplatesDict, - FormatObject, -) - from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -44,6 +35,16 @@ from .execute import ( CREATE_NO_WINDOW ) from .log import PypeLogger, timeit + +from .path_templates import ( + merge_dict, + TemplateMissingKey, + TemplateUnsolved, + StringTemplate, + TemplatesDict, + FormatObject, +) + from .mongo import ( get_default_components, validate_mongo_connection, @@ -191,13 +192,6 @@ from .openpype_version import ( terminal = Terminal __all__ = [ - "merge_dict", - "TemplateMissingKey", - "TemplateUnsolved", - "StringTemplate", - "TemplatesDict", - "FormatObject", - "get_openpype_execute_args", "get_pype_execute_args", "get_linux_launcher_args", @@ -298,6 +292,13 @@ __all__ = [ "get_version_from_path", "get_last_version_from_path", + "merge_dict", + "TemplateMissingKey", + "TemplateUnsolved", + "StringTemplate", + "TemplatesDict", + "FormatObject", + "terminal", "Anatomy", From 26549b34650a45037084107356f94a12b3146441 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 19 Feb 2022 20:51:28 +0100 Subject: [PATCH 058/152] hound fixes --- openpype/lib/anatomy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index f817646cd7..8f2f09a803 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -10,10 +10,8 @@ from openpype.settings.lib import ( get_anatomy_settings ) from .path_templates import ( - merge_dict, TemplateUnsolved, TemplateResult, - TemplatesResultDict, TemplatesDict, FormatObject, ) @@ -69,7 +67,10 @@ class Anatomy: " to load data for specific project." )) + from .avalon_context import get_project_code + self.project_name = project_name + self.project_code = get_project_code(project_name) self._data = self._prepare_anatomy_data( get_anatomy_settings(project_name, site_name) From b7f6e2dd55c24ef5534495e4adc1287d5f49571f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:50:25 +0100 Subject: [PATCH 059/152] use proxy model for check of files existence --- openpype/widgets/attribute_defs/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index fb48528bdc..5aa76d8754 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -552,7 +552,7 @@ class MultiFilesWidget(QtWidgets.QFrame): self._update_visibility() def _update_visibility(self): - files_exists = self._files_model.rowCount() > 0 + files_exists = self._files_proxy_model.rowCount() > 0 self._files_view.setVisible(files_exists) self._empty_widget.setVisible(not files_exists) From 42f47c868b838adfc1c0762ba64487eb822f6de1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:51:30 +0100 Subject: [PATCH 060/152] created clickable label in utils --- openpype/tools/utils/__init__.py | 6 +++++- openpype/tools/utils/widgets.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 46af051069..b4b0af106e 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -2,11 +2,12 @@ from .widgets import ( PlaceholderLineEdit, BaseClickableFrame, ClickableFrame, + ClickableLabel, ExpandBtn, PixmapLabel, IconButton, ) - +from .views import DeselectableTreeView from .error_dialog import ErrorMessageBox from .lib import ( WrappedCallbackItem, @@ -24,10 +25,13 @@ __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", + "ClickableLabel", "ExpandBtn", "PixmapLabel", "IconButton", + "DeselectableTreeView", + "ErrorMessageBox", "WrappedCallbackItem", diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index c62b838231..a4e172ea5c 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -63,6 +63,29 @@ class ClickableFrame(BaseClickableFrame): self.clicked.emit() +class ClickableLabel(QtWidgets.QLabel): + """Label that catch left mouse click and can trigger 'clicked' signal.""" + clicked = QtCore.Signal() + + def __init__(self, parent): + super(ClickableLabel, self).__init__(parent) + + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(ClickableLabel, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + + super(ClickableLabel, self).mouseReleaseEvent(event) + + class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" def __init__(self, parent): From f69091bd8c46ef15459cb9423462ad1fd0e9ef6a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:55:24 +0100 Subject: [PATCH 061/152] enhanced report viewer --- .../publish_report_viewer/__init__.py | 5 + .../publisher/publish_report_viewer/model.py | 4 + .../publish_report_viewer/report_items.py | 126 ++++++ .../publish_report_viewer/widgets.py | 394 ++++++++++++------ .../publisher/publish_report_viewer/window.py | 334 ++++++++++++++- 5 files changed, 734 insertions(+), 129 deletions(-) create mode 100644 openpype/tools/publisher/publish_report_viewer/report_items.py diff --git a/openpype/tools/publisher/publish_report_viewer/__init__.py b/openpype/tools/publisher/publish_report_viewer/__init__.py index 3cfaaa5a05..ce1cc3729c 100644 --- a/openpype/tools/publisher/publish_report_viewer/__init__.py +++ b/openpype/tools/publisher/publish_report_viewer/__init__.py @@ -1,3 +1,6 @@ +from .report_items import ( + PublishReport +) from .widgets import ( PublishReportViewerWidget ) @@ -8,6 +11,8 @@ from .window import ( __all__ = ( + "PublishReport", + "PublishReportViewerWidget", "PublishReportViewerWindow", diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py index 460d3e12d1..a88129a358 100644 --- a/openpype/tools/publisher/publish_report_viewer/model.py +++ b/openpype/tools/publisher/publish_report_viewer/model.py @@ -28,6 +28,8 @@ class InstancesModel(QtGui.QStandardItemModel): self.clear() self._items_by_id.clear() self._plugin_items_by_id.clear() + if not report_item: + return root_item = self.invisibleRootItem() @@ -119,6 +121,8 @@ class PluginsModel(QtGui.QStandardItemModel): self.clear() self._items_by_id.clear() self._plugin_items_by_id.clear() + if not report_item: + return root_item = self.invisibleRootItem() diff --git a/openpype/tools/publisher/publish_report_viewer/report_items.py b/openpype/tools/publisher/publish_report_viewer/report_items.py new file mode 100644 index 0000000000..b47d14da25 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/report_items.py @@ -0,0 +1,126 @@ +import uuid +import collections +import copy + + +class PluginItem: + def __init__(self, plugin_data): + self._id = uuid.uuid4() + + self.name = plugin_data["name"] + self.label = plugin_data["label"] + self.order = plugin_data["order"] + self.skipped = plugin_data["skipped"] + self.passed = plugin_data["passed"] + + errored = False + for instance_data in plugin_data["instances_data"]: + for log_item in instance_data["logs"]: + errored = log_item["type"] == "error" + if errored: + break + if errored: + break + + self.errored = errored + + @property + def id(self): + return self._id + + +class InstanceItem: + def __init__(self, instance_id, instance_data, logs_by_instance_id): + self._id = instance_id + self.label = instance_data.get("label") or instance_data.get("name") + self.family = instance_data.get("family") + self.removed = not instance_data.get("exists", True) + + logs = logs_by_instance_id.get(instance_id) or [] + errored = False + for log_item in logs: + if log_item.errored: + errored = True + break + + self.errored = errored + + @property + def id(self): + return self._id + + +class LogItem: + def __init__(self, log_item_data, plugin_id, instance_id): + self._instance_id = instance_id + self._plugin_id = plugin_id + self._errored = log_item_data["type"] == "error" + self.data = log_item_data + + def __getitem__(self, key): + return self.data[key] + + @property + def errored(self): + return self._errored + + @property + def instance_id(self): + return self._instance_id + + @property + def plugin_id(self): + return self._plugin_id + + +class PublishReport: + def __init__(self, report_data): + data = copy.deepcopy(report_data) + + context_data = data["context"] + context_data["name"] = "context" + context_data["label"] = context_data["label"] or "Context" + + logs = [] + plugins_items_by_id = {} + plugins_id_order = [] + for plugin_data in data["plugins_data"]: + item = PluginItem(plugin_data) + plugins_id_order.append(item.id) + plugins_items_by_id[item.id] = item + for instance_data_item in plugin_data["instances_data"]: + instance_id = instance_data_item["id"] + for log_item_data in instance_data_item["logs"]: + log_item = LogItem( + copy.deepcopy(log_item_data), item.id, instance_id + ) + logs.append(log_item) + + logs_by_instance_id = collections.defaultdict(list) + for log_item in logs: + logs_by_instance_id[log_item.instance_id].append(log_item) + + instance_items_by_id = {} + instance_items_by_family = {} + context_item = InstanceItem(None, context_data, logs_by_instance_id) + instance_items_by_id[context_item.id] = context_item + instance_items_by_family[context_item.family] = [context_item] + + for instance_id, instance_data in data["instances"].items(): + item = InstanceItem( + instance_id, instance_data, logs_by_instance_id + ) + instance_items_by_id[item.id] = item + if item.family not in instance_items_by_family: + instance_items_by_family[item.family] = [] + instance_items_by_family[item.family].append(item) + + self.instance_items_by_id = instance_items_by_id + self.instance_items_by_family = instance_items_by_family + + self.plugins_id_order = plugins_id_order + self.plugins_items_by_id = plugins_items_by_id + + self.logs = logs + + self.crashed_plugin_paths = report_data["crashed_file_paths"] diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index 24f1d33d0e..0b17efb614 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -1,10 +1,8 @@ -import copy -import uuid - -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui from openpype.widgets.nice_checkbox import NiceCheckbox +# from openpype.tools.utils import DeselectableTreeView from .constants import ( ITEM_ID_ROLE, ITEM_IS_GROUP_ROLE @@ -16,98 +14,127 @@ from .model import ( PluginsModel, PluginProxyModel ) +from .report_items import PublishReport + +FILEPATH_ROLE = QtCore.Qt.UserRole + 1 +TRACEBACK_ROLE = QtCore.Qt.UserRole + 2 +IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3 -class PluginItem: - def __init__(self, plugin_data): - self._id = uuid.uuid4() +class PluginLoadReportModel(QtGui.QStandardItemModel): + def set_report(self, report): + parent = self.invisibleRootItem() + parent.removeRows(0, parent.rowCount()) - self.name = plugin_data["name"] - self.label = plugin_data["label"] - self.order = plugin_data["order"] - self.skipped = plugin_data["skipped"] - self.passed = plugin_data["passed"] + new_items = [] + new_items_by_filepath = {} + for filepath in report.crashed_plugin_paths.keys(): + item = QtGui.QStandardItem(filepath) + new_items.append(item) + new_items_by_filepath[filepath] = item - logs = [] - errored = False - for instance_data in plugin_data["instances_data"]: - for log_item in instance_data["logs"]: - if not errored: - errored = log_item["type"] == "error" - logs.append(copy.deepcopy(log_item)) + if not new_items: + return - self.errored = errored - self.logs = logs - - @property - def id(self): - return self._id + parent.appendRows(new_items) + for filepath, item in new_items_by_filepath.items(): + traceback_txt = report.crashed_plugin_paths[filepath] + detail_item = QtGui.QStandardItem() + detail_item.setData(filepath, FILEPATH_ROLE) + detail_item.setData(traceback_txt, TRACEBACK_ROLE) + detail_item.setData(True, IS_DETAIL_ITEM_ROLE) + item.appendRow(detail_item) -class InstanceItem: - def __init__(self, instance_id, instance_data, report_data): - self._id = instance_id - self.label = instance_data.get("label") or instance_data.get("name") - self.family = instance_data.get("family") - self.removed = not instance_data.get("exists", True) +class DetailWidget(QtWidgets.QTextEdit): + def __init__(self, text, *args, **kwargs): + super(DetailWidget, self).__init__(*args, **kwargs) - logs = [] - for plugin_data in report_data["plugins_data"]: - for instance_data_item in plugin_data["instances_data"]: - if instance_data_item["id"] == self._id: - logs.extend(copy.deepcopy(instance_data_item["logs"])) + self.setReadOnly(True) + self.setHtml(text) + self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + self.setWordWrapMode( + QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere + ) - errored = False - for log in logs: - if log["type"] == "error": - errored = True - break - - self.errored = errored - self.logs = logs - - @property - def id(self): - return self._id + def sizeHint(self): + content_margins = ( + self.contentsMargins().top() + + self.contentsMargins().bottom() + ) + size = self.document().documentLayout().documentSize().toSize() + size.setHeight(size.height() + content_margins) + return size -class PublishReport: - def __init__(self, report_data): - data = copy.deepcopy(report_data) +class PluginLoadReportWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(PluginLoadReportWidget, self).__init__(parent) - context_data = data["context"] - context_data["name"] = "context" - context_data["label"] = context_data["label"] or "Context" + view = QtWidgets.QTreeView(self) + view.setEditTriggers(view.NoEditTriggers) + view.setTextElideMode(QtCore.Qt.ElideLeft) + view.setHeaderHidden(True) + view.setAlternatingRowColors(True) + view.setVerticalScrollMode(view.ScrollPerPixel) - instance_items_by_id = {} - instance_items_by_family = {} - context_item = InstanceItem(None, context_data, data) - instance_items_by_id[context_item.id] = context_item - instance_items_by_family[context_item.family] = [context_item] + model = PluginLoadReportModel() + view.setModel(model) - for instance_id, instance_data in data["instances"].items(): - item = InstanceItem(instance_id, instance_data, data) - instance_items_by_id[item.id] = item - if item.family not in instance_items_by_family: - instance_items_by_family[item.family] = [] - instance_items_by_family[item.family].append(item) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view, 1) - all_logs = [] - plugins_items_by_id = {} - plugins_id_order = [] - for plugin_data in data["plugins_data"]: - item = PluginItem(plugin_data) - plugins_id_order.append(item.id) - plugins_items_by_id[item.id] = item - all_logs.extend(copy.deepcopy(item.logs)) + view.expanded.connect(self._on_expand) - self.instance_items_by_id = instance_items_by_id - self.instance_items_by_family = instance_items_by_family + self._view = view + self._model = model + self._widgets_by_filepath = {} - self.plugins_id_order = plugins_id_order - self.plugins_items_by_id = plugins_items_by_id + def _on_expand(self, index): + for row in range(self._model.rowCount(index)): + child_index = self._model.index(row, index.column(), index) + self._create_widget(child_index) - self.logs = all_logs + def showEvent(self, event): + super(PluginLoadReportWidget, self).showEvent(event) + self._update_widgets_size_hints() + + def resizeEvent(self, event): + super(PluginLoadReportWidget, self).resizeEvent(event) + self._update_widgets_size_hints() + + def _update_widgets_size_hints(self): + for item in self._widgets_by_filepath.values(): + widget, index = item + if not widget.isVisible(): + continue + self._model.setData( + index, widget.sizeHint(), QtCore.Qt.SizeHintRole + ) + + def _create_widget(self, index): + if not index.data(IS_DETAIL_ITEM_ROLE): + return + + filepath = index.data(FILEPATH_ROLE) + if filepath in self._widgets_by_filepath: + return + + traceback_txt = index.data(TRACEBACK_ROLE) + detail_text = ( + "Filepath:
" + "{}

" + "Traceback:
" + "{}" + ).format(filepath, traceback_txt.replace("\n", "
")) + widget = DetailWidget(detail_text, self) + self._view.setIndexWidget(index, widget) + self._widgets_by_filepath[filepath] = (widget, index) + + def set_report(self, report): + self._widgets_by_filepath = {} + self._model.set_report(report) class DetailsWidget(QtWidgets.QWidget): @@ -123,11 +150,50 @@ class DetailsWidget(QtWidgets.QWidget): layout.addWidget(output_widget) self._output_widget = output_widget + self._report_item = None + self._instance_filter = set() + self._plugin_filter = set() def clear(self): self._output_widget.setPlainText("") - def set_logs(self, logs): + def set_report(self, report): + self._report_item = report + self._plugin_filter = set() + self._instance_filter = set() + self._update_logs() + + def set_plugin_filter(self, plugin_filter): + self._plugin_filter = plugin_filter + self._update_logs() + + def set_instance_filter(self, instance_filter): + self._instance_filter = instance_filter + self._update_logs() + + def _update_logs(self): + if not self._report_item: + self._output_widget.setPlainText("") + return + + filtered_logs = [] + for log in self._report_item.logs: + if ( + self._instance_filter + and log.instance_id not in self._instance_filter + ): + continue + + if ( + self._plugin_filter + and log.plugin_id not in self._plugin_filter + ): + continue + filtered_logs.append(log) + + self._set_logs(filtered_logs) + + def _set_logs(self, logs): lines = [] for log in logs: if log["type"] == "record": @@ -148,6 +214,59 @@ class DetailsWidget(QtWidgets.QWidget): self._output_widget.setPlainText(text) +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + index = self.indexAt(event.pos()) + clear_selection = False + if not index.isValid(): + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ShiftModifier: + return + elif modifiers == QtCore.Qt.ControlModifier: + return + clear_selection = True + else: + indexes = self.selectedIndexes() + if len(indexes) == 1 and index in indexes: + clear_selection = True + + if clear_selection: + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + event.accept() + return + + QtWidgets.QTreeView.mousePressEvent(self, event) + + +class DetailsPopup(QtWidgets.QDialog): + closed = QtCore.Signal() + + def __init__(self, parent, center_widget): + super(DetailsPopup, self).__init__(parent) + self.setWindowTitle("Report Details") + layout = QtWidgets.QHBoxLayout(self) + + self._center_widget = center_widget + self._first_show = True + + def showEvent(self, event): + layout = self.layout() + layout.insertWidget(0, self._center_widget) + super(DetailsPopup, self).showEvent(event) + if self._first_show: + self._first_show = False + self.resize(700, 400) + + def closeEvent(self, event): + super(DetailsPopup, self).closeEvent(event) + self.closed.emit() + + class PublishReportViewerWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(PublishReportViewerWidget, self).__init__(parent) @@ -171,12 +290,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget): removed_instances_layout.addWidget(removed_instances_check, 0) removed_instances_layout.addWidget(removed_instances_label, 1) - instances_view = QtWidgets.QTreeView(self) + instances_view = DeselectableTreeView(self) instances_view.setObjectName("PublishDetailViews") instances_view.setModel(instances_proxy) instances_view.setIndentation(0) instances_view.setHeaderHidden(True) instances_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + instances_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection) instances_view.setExpandsOnDoubleClick(False) instances_delegate = GroupItemDelegate(instances_view) @@ -191,29 +311,49 @@ class PublishReportViewerWidget(QtWidgets.QWidget): skipped_plugins_layout.addWidget(skipped_plugins_check, 0) skipped_plugins_layout.addWidget(skipped_plugins_label, 1) - plugins_view = QtWidgets.QTreeView(self) + plugins_view = DeselectableTreeView(self) plugins_view.setObjectName("PublishDetailViews") plugins_view.setModel(plugins_proxy) plugins_view.setIndentation(0) plugins_view.setHeaderHidden(True) + plugins_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection) plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) plugins_view.setExpandsOnDoubleClick(False) plugins_delegate = GroupItemDelegate(plugins_view) plugins_view.setItemDelegate(plugins_delegate) - details_widget = DetailsWidget(self) + details_widget = QtWidgets.QWidget(self) + details_tab_widget = QtWidgets.QTabWidget(details_widget) + details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget) - layout = QtWidgets.QGridLayout(self) + details_layout = QtWidgets.QVBoxLayout(details_widget) + details_layout.setContentsMargins(0, 0, 0, 0) + details_layout.addWidget(details_tab_widget, 1) + details_layout.addWidget(details_popup_btn, 0) + + details_popup = DetailsPopup(self, details_tab_widget) + + logs_text_widget = DetailsWidget(details_tab_widget) + plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget) + + details_tab_widget.addTab(logs_text_widget, "Logs") + details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins") + + middle_widget = QtWidgets.QWidget(self) + middle_layout = QtWidgets.QGridLayout(middle_widget) + middle_layout.setContentsMargins(0, 0, 0, 0) # Row 1 - layout.addLayout(removed_instances_layout, 0, 0) - layout.addLayout(skipped_plugins_layout, 0, 1) + middle_layout.addLayout(removed_instances_layout, 0, 0) + middle_layout.addLayout(skipped_plugins_layout, 0, 1) # Row 2 - layout.addWidget(instances_view, 1, 0) - layout.addWidget(plugins_view, 1, 1) - layout.addWidget(details_widget, 1, 2) + middle_layout.addWidget(instances_view, 1, 0) + middle_layout.addWidget(plugins_view, 1, 1) - layout.setColumnStretch(2, 1) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(middle_widget, 0) + layout.addWidget(details_widget, 1) instances_view.selectionModel().selectionChanged.connect( self._on_instance_change @@ -230,10 +370,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget): removed_instances_check.stateChanged.connect( self._on_removed_instances_check ) + details_popup_btn.clicked.connect(self._on_details_popup) + details_popup.closed.connect(self._on_popup_close) self._ignore_selection_changes = False self._report_item = None - self._details_widget = details_widget + self._logs_text_widget = logs_text_widget + self._plugin_load_report_widget = plugin_load_report_widget self._removed_instances_check = removed_instances_check self._instances_view = instances_view @@ -248,6 +391,10 @@ class PublishReportViewerWidget(QtWidgets.QWidget): self._plugins_model = plugins_model self._plugins_proxy = plugins_proxy + self._details_widget = details_widget + self._details_tab_widget = details_tab_widget + self._details_popup = details_popup + def _on_instance_view_clicked(self, index): if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE): return @@ -266,62 +413,46 @@ class PublishReportViewerWidget(QtWidgets.QWidget): else: self._plugins_view.expand(index) - def set_report(self, report_data): + def set_report_data(self, report_data): + report = PublishReport(report_data) + self.set_report(report) + + def set_report(self, report): self._ignore_selection_changes = True - report_item = PublishReport(report_data) - self._report_item = report_item + self._report_item = report - self._instances_model.set_report(report_item) - self._plugins_model.set_report(report_item) - self._details_widget.set_logs(report_item.logs) + self._instances_model.set_report(report) + self._plugins_model.set_report(report) + self._logs_text_widget.set_report(report) + self._plugin_load_report_widget.set_report(report) self._ignore_selection_changes = False + self._instances_view.expandAll() + self._plugins_view.expandAll() + def _on_instance_change(self, *_args): if self._ignore_selection_changes: return - valid_index = None + instance_ids = set() for index in self._instances_view.selectedIndexes(): if index.isValid(): - valid_index = index - break + instance_ids.add(index.data(ITEM_ID_ROLE)) - if valid_index is None: - return - - if self._plugins_view.selectedIndexes(): - self._ignore_selection_changes = True - self._plugins_view.selectionModel().clearSelection() - self._ignore_selection_changes = False - - plugin_id = valid_index.data(ITEM_ID_ROLE) - instance_item = self._report_item.instance_items_by_id[plugin_id] - self._details_widget.set_logs(instance_item.logs) + self._logs_text_widget.set_instance_filter(instance_ids) def _on_plugin_change(self, *_args): if self._ignore_selection_changes: return - valid_index = None + plugin_ids = set() for index in self._plugins_view.selectedIndexes(): if index.isValid(): - valid_index = index - break + plugin_ids.add(index.data(ITEM_ID_ROLE)) - if valid_index is None: - self._details_widget.set_logs(self._report_item.logs) - return - - if self._instances_view.selectedIndexes(): - self._ignore_selection_changes = True - self._instances_view.selectionModel().clearSelection() - self._ignore_selection_changes = False - - plugin_id = valid_index.data(ITEM_ID_ROLE) - plugin_item = self._report_item.plugins_items_by_id[plugin_id] - self._details_widget.set_logs(plugin_item.logs) + self._logs_text_widget.set_plugin_filter(plugin_ids) def _on_skipped_plugin_check(self): self._plugins_proxy.set_ignore_skipped( @@ -332,3 +463,16 @@ class PublishReportViewerWidget(QtWidgets.QWidget): self._instances_proxy.set_ignore_removed( self._removed_instances_check.isChecked() ) + + def _on_details_popup(self): + self._details_widget.setVisible(False) + self._details_popup.show() + + def _on_popup_close(self): + self._details_widget.setVisible(True) + layout = self._details_widget.layout() + layout.insertWidget(0, self._details_tab_widget) + + def close_details_popup(self): + if self._details_popup.isVisible(): + self._details_popup.close() diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py index 7a0fef7d91..8ca075e4d2 100644 --- a/openpype/tools/publisher/publish_report_viewer/window.py +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -1,29 +1,355 @@ -from Qt import QtWidgets +import os +import json +import six +import appdirs +from Qt import QtWidgets, QtCore, QtGui from openpype import style +from openpype.lib import JSONSettingRegistry +from openpype.resources import get_openpype_icon_filepath +from openpype.tools import resources +from openpype.tools.utils import ( + IconButton, + paint_image_with_color +) + +from openpype.tools.utils.delegates import PrettyTimeDelegate + if __package__: from .widgets import PublishReportViewerWidget + from .report_items import PublishReport else: from widgets import PublishReportViewerWidget + from report_items import PublishReport + + +FILEPATH_ROLE = QtCore.Qt.UserRole + 1 +MODIFIED_ROLE = QtCore.Qt.UserRole + 2 + + +class PublisherReportRegistry(JSONSettingRegistry): + """Class handling storing publish report tool. + + Attributes: + vendor (str): Name used for path construction. + product (str): Additional name used for path construction. + + """ + + def __init__(self): + self.vendor = "pypeclub" + self.product = "openpype" + name = "publish_report_viewer" + path = appdirs.user_data_dir(self.product, self.vendor) + super(PublisherReportRegistry, self).__init__(name, path) + + +class LoadedFilesMopdel(QtGui.QStandardItemModel): + def __init__(self, *args, **kwargs): + super(LoadedFilesMopdel, self).__init__(*args, **kwargs) + self.setColumnCount(2) + self._items_by_filepath = {} + self._reports_by_filepath = {} + + self._registry = PublisherReportRegistry() + + self._loading_registry = False + self._load_registry() + + def headerData(self, section, orientation, role): + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if section == 0: + return "Exports" + if section == 1: + return "Modified" + return "" + super(LoadedFilesMopdel, self).headerData(section, orientation, role) + + def _load_registry(self): + self._loading_registry = True + try: + filepaths = self._registry.get_item("filepaths") + self.add_filepaths(filepaths) + except ValueError: + pass + self._loading_registry = False + + def _store_registry(self): + if self._loading_registry: + return + filepaths = list(self._items_by_filepath.keys()) + self._registry.set_item("filepaths", filepaths) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + index = self.index(index.row(), 0, index.parent()) + + if role == QtCore.Qt.ToolTipRole: + if col == 0: + role = FILEPATH_ROLE + elif col == 1: + return "File modified" + return None + + elif role == QtCore.Qt.DisplayRole: + if col == 1: + role = MODIFIED_ROLE + return super(LoadedFilesMopdel, self).data(index, role) + + def add_filepaths(self, filepaths): + if not filepaths: + return + + if isinstance(filepaths, six.string_types): + filepaths = [filepaths] + + filtered_paths = [] + for filepath in filepaths: + normalized_path = os.path.normpath(filepath) + if normalized_path in self._items_by_filepath: + continue + + if ( + os.path.exists(normalized_path) + and normalized_path not in filtered_paths + ): + filtered_paths.append(normalized_path) + + if not filtered_paths: + return + + new_items = [] + for filepath in filtered_paths: + try: + with open(normalized_path, "r") as stream: + data = json.load(stream) + report = PublishReport(data) + except Exception as exc: + # TODO handle errors + continue + + modified = os.path.getmtime(normalized_path) + item = QtGui.QStandardItem(os.path.basename(normalized_path)) + item.setColumnCount(self.columnCount()) + item.setData(normalized_path, FILEPATH_ROLE) + item.setData(modified, MODIFIED_ROLE) + new_items.append(item) + self._items_by_filepath[normalized_path] = item + self._reports_by_filepath[normalized_path] = report + + if not new_items: + return + + parent = self.invisibleRootItem() + parent.appendRows(new_items) + + self._store_registry() + + def remove_filepaths(self, filepaths): + if not filepaths: + return + + if isinstance(filepaths, six.string_types): + filepaths = [filepaths] + + filtered_paths = [] + for filepath in filepaths: + normalized_path = os.path.normpath(filepath) + if normalized_path in self._items_by_filepath: + filtered_paths.append(normalized_path) + + if not filtered_paths: + return + + parent = self.invisibleRootItem() + for filepath in filtered_paths: + self._reports_by_filepath.pop(normalized_path) + item = self._items_by_filepath.pop(filepath) + parent.removeRow(item.row()) + + self._store_registry() + + def get_report_by_filepath(self, filepath): + return self._reports_by_filepath.get(filepath) + + +class LoadedFilesView(QtWidgets.QTreeView): + selection_changed = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(LoadedFilesView, self).__init__(*args, **kwargs) + self.setEditTriggers(self.NoEditTriggers) + self.setIndentation(0) + self.setAlternatingRowColors(True) + + model = LoadedFilesMopdel() + self.setModel(model) + + time_delegate = PrettyTimeDelegate() + self.setItemDelegateForColumn(1, time_delegate) + + remove_btn = IconButton(self) + remove_icon_path = resources.get_icon_path("delete") + loaded_remove_image = QtGui.QImage(remove_icon_path) + pix = paint_image_with_color(loaded_remove_image, QtCore.Qt.white) + icon = QtGui.QIcon(pix) + remove_btn.setIcon(icon) + + model.rowsInserted.connect(self._on_rows_inserted) + remove_btn.clicked.connect(self._on_remove_clicked) + self.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + + self._model = model + self._time_delegate = time_delegate + self._remove_btn = remove_btn + + def _update_remove_btn(self): + viewport = self.viewport() + height = viewport.height() + self.header().height() + pos_x = viewport.width() - self._remove_btn.width() - 5 + pos_y = height - self._remove_btn.height() - 5 + self._remove_btn.move(max(0, pos_x), max(0, pos_y)) + + def _on_rows_inserted(self): + header = self.header() + header.resizeSections(header.ResizeToContents) + + def resizeEvent(self, event): + super(LoadedFilesView, self).resizeEvent(event) + self._update_remove_btn() + + def showEvent(self, event): + super(LoadedFilesView, self).showEvent(event) + self._update_remove_btn() + header = self.header() + header.resizeSections(header.ResizeToContents) + + def _on_selection_change(self): + self.selection_changed.emit() + + def add_filepaths(self, filepaths): + self._model.add_filepaths(filepaths) + self._fill_selection() + + def remove_filepaths(self, filepaths): + self._model.remove_filepaths(filepaths) + self._fill_selection() + + def _on_remove_clicked(self): + index = self.currentIndex() + filepath = index.data(FILEPATH_ROLE) + self.remove_filepaths(filepath) + + def _fill_selection(self): + index = self.currentIndex() + if index.isValid(): + return + + index = self._model.index(0, 0) + if index.isValid(): + self.setCurrentIndex(index) + + def get_current_report(self): + index = self.currentIndex() + filepath = index.data(FILEPATH_ROLE) + return self._model.get_report_by_filepath(filepath) + + +class LoadedFilesWidget(QtWidgets.QWidget): + report_changed = QtCore.Signal() + + def __init__(self, parent): + super(LoadedFilesWidget, self).__init__(parent) + + self.setAcceptDrops(True) + + view = LoadedFilesView(self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view, 1) + + view.selection_changed.connect(self._on_report_change) + + self._view = view + + def dragEnterEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + ext = os.path.splitext(filepath)[-1] + if os.path.exists(filepath) and ext == ".json": + filepaths.append(filepath) + self._add_filepaths(filepaths) + event.accept() + + def _on_report_change(self): + self.report_changed.emit() + + def _add_filepaths(self, filepaths): + self._view.add_filepaths(filepaths) + + def get_current_report(self): + return self._view.get_current_report() class PublishReportViewerWindow(QtWidgets.QWidget): - # TODO add buttons to be able load report file or paste content of report default_width = 1200 default_height = 600 def __init__(self, parent=None): super(PublishReportViewerWindow, self).__init__(parent) + self.setWindowTitle("Publish report viewer") + icon = QtGui.QIcon(get_openpype_icon_filepath()) + self.setWindowIcon(icon) - main_widget = PublishReportViewerWidget(self) + body = QtWidgets.QSplitter(self) + body.setContentsMargins(0, 0, 0, 0) + body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + body.setOrientation(QtCore.Qt.Horizontal) + + loaded_files_widget = LoadedFilesWidget(body) + main_widget = PublishReportViewerWidget(body) + + body.addWidget(loaded_files_widget) + body.addWidget(main_widget) + body.setStretchFactor(0, 70) + body.setStretchFactor(1, 65) layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(main_widget) + layout.addWidget(body, 1) + loaded_files_widget.report_changed.connect(self._on_report_change) + + self._loaded_files_widget = loaded_files_widget self._main_widget = main_widget self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) + def _on_report_change(self): + report = self._loaded_files_widget.get_current_report() + self.set_report(report) + def set_report(self, report_data): self._main_widget.set_report(report_data) From ae4c7a3ab4357183029bbc6d3bb9894987b6f07a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 16:58:01 +0100 Subject: [PATCH 062/152] fixed few issues in publisher ui --- openpype/tools/publisher/control.py | 15 +- .../tools/publisher/widgets/create_dialog.py | 28 +++- .../tools/publisher/widgets/publish_widget.py | 6 +- .../publisher/widgets/validations_widget.py | 139 ++++++++++++++---- openpype/tools/publisher/widgets/widgets.py | 16 +- openpype/tools/publisher/window.py | 5 +- 6 files changed, 162 insertions(+), 47 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 2ce0eaad62..ab2dffd489 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -42,18 +42,23 @@ class MainThreadProcess(QtCore.QObject): This approach gives ability to update UI meanwhile plugin is in progress. """ - timer_interval = 3 + count_timeout = 2 def __init__(self): super(MainThreadProcess, self).__init__() self._items_to_process = collections.deque() timer = QtCore.QTimer() - timer.setInterval(self.timer_interval) + timer.setInterval(0) timer.timeout.connect(self._execute) self._timer = timer + self._switch_counter = self.count_timeout + + def process(self, func, *args, **kwargs): + item = MainThreadItem(func, *args, **kwargs) + self.add_item(item) def add_item(self, item): self._items_to_process.append(item) @@ -62,6 +67,12 @@ class MainThreadProcess(QtCore.QObject): if not self._items_to_process: return + if self._switch_counter > 0: + self._switch_counter -= 1 + return + + self._switch_counter = self.count_timeout + item = self._items_to_process.popleft() item.process() diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index f9f8310e09..c5b77eca8b 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -174,6 +174,8 @@ class CreatorDescriptionWidget(QtWidgets.QWidget): class CreateDialog(QtWidgets.QDialog): + default_size = (900, 500) + def __init__( self, controller, asset_name=None, task_name=None, parent=None ): @@ -262,11 +264,16 @@ class CreateDialog(QtWidgets.QDialog): mid_layout.addLayout(form_layout, 0) mid_layout.addWidget(create_btn, 0) + splitter_widget = QtWidgets.QSplitter(self) + splitter_widget.addWidget(context_widget) + splitter_widget.addWidget(mid_widget) + splitter_widget.addWidget(pre_create_widget) + splitter_widget.setStretchFactor(0, 1) + splitter_widget.setStretchFactor(1, 1) + splitter_widget.setStretchFactor(2, 1) + layout = QtWidgets.QHBoxLayout(self) - layout.setSpacing(10) - layout.addWidget(context_widget, 1) - layout.addWidget(mid_widget, 1) - layout.addWidget(pre_create_widget, 1) + layout.addWidget(splitter_widget, 1) prereq_timer = QtCore.QTimer() prereq_timer.setInterval(50) @@ -289,6 +296,8 @@ class CreateDialog(QtWidgets.QDialog): controller.add_plugins_refresh_callback(self._on_plugins_refresh) + self._splitter_widget = splitter_widget + self._pre_create_widget = pre_create_widget self._context_widget = context_widget @@ -308,6 +317,7 @@ class CreateDialog(QtWidgets.QDialog): self.create_btn = create_btn self._prereq_timer = prereq_timer + self._first_show = True def _context_change_is_enabled(self): return self._context_widget.isEnabled() @@ -643,6 +653,16 @@ class CreateDialog(QtWidgets.QDialog): def showEvent(self, event): super(CreateDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + width, height = self.default_size + self.resize(width, height) + + third_size = int(width / 3) + self._splitter_widget.setSizes( + [third_size, third_size, width - (2 * third_size)] + ) + if self._last_pos is not None: self.move(self._last_pos) diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py index e4f3579978..80d0265dd3 100644 --- a/openpype/tools/publisher/widgets/publish_widget.py +++ b/openpype/tools/publisher/widgets/publish_widget.py @@ -213,7 +213,6 @@ class PublishFrame(QtWidgets.QFrame): close_report_btn.setIcon(close_report_icon) details_layout = QtWidgets.QVBoxLayout(details_widget) - details_layout.setContentsMargins(0, 0, 0, 0) details_layout.addWidget(report_view) details_layout.addWidget(close_report_btn) @@ -495,10 +494,11 @@ class PublishFrame(QtWidgets.QFrame): def _on_show_details(self): self._change_bg_property(2) self._main_layout.setCurrentWidget(self._details_widget) - logs = self.controller.get_publish_report() - self._report_view.set_report(logs) + report_data = self.controller.get_publish_report() + self._report_view.set_report_data(report_data) def _on_close_report_clicked(self): + self._report_view.close_details_popup() if self.controller.get_publish_crash_error(): self._change_bg_property() diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index bb88e1783c..798c1f9d92 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -10,6 +10,9 @@ from openpype.tools.utils import BaseClickableFrame from .widgets import ( IconValuePixmapLabel ) +from ..constants import ( + INSTANCE_ID_ROLE +) class ValidationErrorInstanceList(QtWidgets.QListView): @@ -22,19 +25,20 @@ class ValidationErrorInstanceList(QtWidgets.QListView): self.setObjectName("ValidationErrorInstanceList") + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setSelectionMode(QtWidgets.QListView.ExtendedSelection) def minimumSizeHint(self): - result = super(ValidationErrorInstanceList, self).minimumSizeHint() - result.setHeight(self.sizeHint().height()) - return result + return self.sizeHint() def sizeHint(self): + result = super(ValidationErrorInstanceList, self).sizeHint() row_count = self.model().rowCount() height = 0 if row_count > 0: height = self.sizeHintForRow(0) * row_count - return QtCore.QSize(self.width(), height) + result.setHeight(height) + return result class ValidationErrorTitleWidget(QtWidgets.QWidget): @@ -47,6 +51,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if there is a list (Valdation error may happen on context). """ selected = QtCore.Signal(int) + instance_changed = QtCore.Signal(int) def __init__(self, index, error_info, parent): super(ValidationErrorTitleWidget, self).__init__(parent) @@ -64,32 +69,38 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) toggle_instance_btn.setMaximumWidth(14) - exception = error_info["exception"] - label_widget = QtWidgets.QLabel(exception.title, title_frame) + label_widget = QtWidgets.QLabel(error_info["title"], title_frame) title_frame_layout = QtWidgets.QHBoxLayout(title_frame) title_frame_layout.addWidget(toggle_instance_btn) title_frame_layout.addWidget(label_widget) instances_model = QtGui.QStandardItemModel() - instances = error_info["instances"] + error_info = error_info["error_info"] + + help_text_by_instance_id = {} context_validation = False if ( - not instances - or (len(instances) == 1 and instances[0] is None) + not error_info + or (len(error_info) == 1 and error_info[0][0] is None) ): context_validation = True toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + description = self._prepare_description(error_info[0][1]) + help_text_by_instance_id[None] = description else: items = [] - for instance in instances: + for instance, exception in error_info: label = instance.data.get("label") or instance.data.get("name") item = QtGui.QStandardItem(label) item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) - item.setData(instance.id) + item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(instance.id, INSTANCE_ID_ROLE) items.append(item) + description = self._prepare_description(exception) + help_text_by_instance_id[instance.id] = description instances_model.invisibleRootItem().appendRows(items) @@ -114,17 +125,64 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if not context_validation: toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) + instances_view.selectionModel().selectionChanged.connect( + self._on_seleciton_change + ) + self._title_frame = title_frame self._toggle_instance_btn = toggle_instance_btn + self._view_layout = view_layout + self._instances_model = instances_model self._instances_view = instances_view + self._context_validation = context_validation + self._help_text_by_instance_id = help_text_by_instance_id + + def sizeHint(self): + result = super().sizeHint() + expected_width = 0 + for idx in range(self._view_layout.count()): + expected_width += self._view_layout.itemAt(idx).sizeHint().width() + + if expected_width < 200: + expected_width = 200 + + if result.width() < expected_width: + result.setWidth(expected_width) + return result + + def minimumSizeHint(self): + return self.sizeHint() + + def _prepare_description(self, exception): + dsc = exception.description + detail = exception.detail + if detail: + dsc += "

{}".format(detail) + + description = dsc + if commonmark: + description = commonmark.commonmark(dsc) + return description + def _mouse_release_callback(self): """Mark this widget as selected on click.""" self.set_selected(True) + def current_desctiption_text(self): + if self._context_validation: + return self._help_text_by_instance_id[None] + index = self._instances_view.currentIndex() + # TODO make sure instance is selected + if not index.isValid(): + index = self._instances_model.index(0, 0) + + indence_id = index.data(INSTANCE_ID_ROLE) + return self._help_text_by_instance_id[indence_id] + @property def is_selected(self): """Is widget marked a selected""" @@ -167,6 +225,9 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): else: self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + def _on_seleciton_change(self): + self.instance_changed.emit(self._index) + class ActionButton(BaseClickableFrame): """Plugin's action callback button. @@ -185,13 +246,15 @@ class ActionButton(BaseClickableFrame): action_label = action.label or action.__name__ action_icon = getattr(action, "icon", None) label_widget = QtWidgets.QLabel(action_label, self) + icon_label = None if action_icon: icon_label = IconValuePixmapLabel(action_icon, self) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(5, 0, 5, 0) layout.addWidget(label_widget, 1) - layout.addWidget(icon_label, 0) + if icon_label: + layout.addWidget(icon_label, 0) self.setSizePolicy( QtWidgets.QSizePolicy.Minimum, @@ -231,6 +294,7 @@ class ValidateActionsWidget(QtWidgets.QFrame): item = self._content_layout.takeAt(0) widget = item.widget() if widget: + widget.setVisible(False) widget.deleteLater() self._actions_mapping = {} @@ -363,24 +427,23 @@ class ValidationsWidget(QtWidgets.QWidget): errors_scroll.setWidgetResizable(True) errors_widget = QtWidgets.QWidget(errors_scroll) - errors_widget.setFixedWidth(200) errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) errors_layout = QtWidgets.QVBoxLayout(errors_widget) errors_layout.setContentsMargins(0, 0, 0, 0) errors_scroll.setWidget(errors_widget) - error_details_widget = QtWidgets.QWidget(self) - error_details_input = QtWidgets.QTextEdit(error_details_widget) + error_details_frame = QtWidgets.QFrame(self) + error_details_input = QtWidgets.QTextEdit(error_details_frame) error_details_input.setObjectName("InfoText") error_details_input.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction ) actions_widget = ValidateActionsWidget(controller, self) - actions_widget.setFixedWidth(140) + actions_widget.setMinimumWidth(140) - error_details_layout = QtWidgets.QHBoxLayout(error_details_widget) + error_details_layout = QtWidgets.QHBoxLayout(error_details_frame) error_details_layout.addWidget(error_details_input, 1) error_details_layout.addWidget(actions_widget, 0) @@ -389,7 +452,7 @@ class ValidationsWidget(QtWidgets.QWidget): content_layout.setContentsMargins(0, 0, 0, 0) content_layout.addWidget(errors_scroll, 0) - content_layout.addWidget(error_details_widget, 1) + content_layout.addWidget(error_details_frame, 1) top_label = QtWidgets.QLabel("Publish validation report", self) top_label.setObjectName("PublishInfoMainLabel") @@ -403,7 +466,7 @@ class ValidationsWidget(QtWidgets.QWidget): self._top_label = top_label self._errors_widget = errors_widget self._errors_layout = errors_layout - self._error_details_widget = error_details_widget + self._error_details_frame = error_details_frame self._error_details_input = error_details_input self._actions_widget = actions_widget @@ -423,7 +486,7 @@ class ValidationsWidget(QtWidgets.QWidget): widget.deleteLater() self._top_label.setVisible(False) - self._error_details_widget.setVisible(False) + self._error_details_frame.setVisible(False) self._errors_widget.setVisible(False) self._actions_widget.setVisible(False) @@ -434,34 +497,35 @@ class ValidationsWidget(QtWidgets.QWidget): return self._top_label.setVisible(True) - self._error_details_widget.setVisible(True) + self._error_details_frame.setVisible(True) self._errors_widget.setVisible(True) errors_by_title = [] for plugin_info in errors: titles = [] - exception_by_title = {} - instances_by_title = {} + error_info_by_title = {} for error_info in plugin_info["errors"]: exception = error_info["exception"] title = exception.title if title not in titles: titles.append(title) - instances_by_title[title] = [] - exception_by_title[title] = exception - instances_by_title[title].append(error_info["instance"]) + error_info_by_title[title] = [] + error_info_by_title[title].append( + (error_info["instance"], exception) + ) for title in titles: errors_by_title.append({ "plugin": plugin_info["plugin"], - "exception": exception_by_title[title], - "instances": instances_by_title[title] + "error_info": error_info_by_title[title], + "title": title }) for idx, item in enumerate(errors_by_title): widget = ValidationErrorTitleWidget(idx, item, self) widget.selected.connect(self._on_select) + widget.instance_changed.connect(self._on_instance_change) self._errors_layout.addWidget(widget) self._title_widgets[idx] = widget self._error_info[idx] = item @@ -471,6 +535,8 @@ class ValidationsWidget(QtWidgets.QWidget): if self._title_widgets: self._title_widgets[0].set_selected(True) + self.updateGeometry() + def _on_select(self, index): if self._previous_select: if self._previous_select.index == index: @@ -481,10 +547,19 @@ class ValidationsWidget(QtWidgets.QWidget): error_item = self._error_info[index] - dsc = error_item["exception"].description + self._actions_widget.set_plugin(error_item["plugin"]) + + self._update_description() + + def _on_instance_change(self, index): + if self._previous_select and self._previous_select.index != index: + return + self._update_description() + + def _update_description(self): + description = self._previous_select.current_desctiption_text() if commonmark: - html = commonmark.commonmark(dsc) + html = commonmark.commonmark(description) self._error_details_input.setHtml(html) else: - self._error_details_input.setMarkdown(dsc) - self._actions_widget.set_plugin(error_item["plugin"]) + self._error_details_input.setMarkdown(description) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index a63258efb7..fb1f0e54aa 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -535,6 +535,7 @@ class TasksCombobox(QtWidgets.QComboBox): return self._text = text + self.repaint() def paintEvent(self, event): """Paint custom text without using QLineEdit. @@ -548,6 +549,7 @@ class TasksCombobox(QtWidgets.QComboBox): self.initStyleOption(opt) if self._text is not None: opt.currentText = self._text + style = self.style() style.drawComplexControl( QtWidgets.QStyle.CC_ComboBox, opt, painter, self @@ -609,11 +611,15 @@ class TasksCombobox(QtWidgets.QComboBox): if self._selected_items: is_valid = True + valid_task_names = [] for task_name in self._selected_items: - is_valid = self._model.is_task_name_valid(asset_name, task_name) - if not is_valid: - break + _is_valid = self._model.is_task_name_valid(asset_name, task_name) + if _is_valid: + valid_task_names.append(task_name) + else: + is_valid = _is_valid + self._selected_items = valid_task_names if len(self._selected_items) == 0: self.set_selected_item("") @@ -625,6 +631,7 @@ class TasksCombobox(QtWidgets.QComboBox): if multiselection_text is None: multiselection_text = "|".join(self._selected_items) self.set_selected_item(multiselection_text) + self._set_is_valid(is_valid) def set_selected_items(self, asset_task_combinations=None): @@ -708,8 +715,7 @@ class TasksCombobox(QtWidgets.QComboBox): idx = self.findText(item_name) # Set current index (must be set to -1 if is invalid) self.setCurrentIndex(idx) - if idx < 0: - self.set_text(item_name) + self.set_text(item_name) def reset_to_origin(self): """Change to task names set with last `set_selected_items` call.""" diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 642bd17589..b74e95b227 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -84,7 +84,7 @@ class PublisherWindow(QtWidgets.QDialog): # Content # Subset widget - subset_frame = QtWidgets.QWidget(self) + subset_frame = QtWidgets.QFrame(self) subset_views_widget = BorderedLabelWidget( "Subsets to publish", subset_frame @@ -225,6 +225,9 @@ class PublisherWindow(QtWidgets.QDialog): controller.add_publish_validated_callback(self._on_publish_validated) controller.add_publish_stopped_callback(self._on_publish_stop) + # Store header for TrayPublisher + self._header_layout = header_layout + self.content_stacked_layout = content_stacked_layout self.publish_frame = publish_frame self.subset_frame = subset_frame From 0f3879e41c2218889ecae000249c56772dfc999d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:03:30 +0100 Subject: [PATCH 063/152] base of tray publisher tool --- openpype/style/style.css | 6 + openpype/tools/traypublisher/__init__.py | 6 + openpype/tools/traypublisher/window.py | 148 +++++++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 openpype/tools/traypublisher/__init__.py create mode 100644 openpype/tools/traypublisher/window.py diff --git a/openpype/style/style.css b/openpype/style/style.css index c96e87aa02..ba40b780ab 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1261,6 +1261,12 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:restart-btn-bg}; } +/* Tray publisher */ +#ChooseProjectLabel { + font-size: 15pt; + font-weight: 750; +} + /* Globally used names */ #Separator { background: {color:bg-menu-separator}; diff --git a/openpype/tools/traypublisher/__init__.py b/openpype/tools/traypublisher/__init__.py new file mode 100644 index 0000000000..188a234a9e --- /dev/null +++ b/openpype/tools/traypublisher/__init__.py @@ -0,0 +1,6 @@ +from .window import main + + +__all__ = ( + "main", +) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py new file mode 100644 index 0000000000..d6a5fe56f8 --- /dev/null +++ b/openpype/tools/traypublisher/window.py @@ -0,0 +1,148 @@ +from Qt import QtWidgets, QtCore + +import avalon.api +from avalon import io +from avalon.api import AvalonMongoDB +from openpype.tools.publisher import PublisherWindow +from openpype.tools.utils.constants import PROJECT_NAME_ROLE +from openpype.tools.utils.models import ( + ProjectModel, + ProjectSortFilterProxy +) + + +class StandaloneOverlayWidget(QtWidgets.QFrame): + project_selected = QtCore.Signal(str) + + def __init__(self, publisher_window): + super(StandaloneOverlayWidget, self).__init__(publisher_window) + self.setObjectName("OverlayFrame") + + # Create db connection for projects model + dbcon = AvalonMongoDB() + dbcon.install() + + header_label = QtWidgets.QLabel("Choose project", self) + header_label.setObjectName("ChooseProjectLabel") + # Create project models and view + projects_model = ProjectModel(dbcon) + projects_proxy = ProjectSortFilterProxy() + projects_proxy.setSourceModel(projects_model) + + projects_view = QtWidgets.QListView(self) + projects_view.setModel(projects_proxy) + projects_view.setEditTriggers( + QtWidgets.QAbstractItemView.NoEditTriggers + ) + + confirm_btn = QtWidgets.QPushButton("Choose", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + layout = QtWidgets.QGridLayout(self) + layout.addWidget(header_label, 0, 1, alignment=QtCore.Qt.AlignCenter) + layout.addWidget(projects_view, 1, 1) + layout.addLayout(btns_layout, 2, 1) + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 0) + layout.setColumnStretch(2, 1) + layout.setRowStretch(0, 0) + layout.setRowStretch(1, 1) + layout.setRowStretch(2, 0) + + projects_view.doubleClicked.connect(self._on_double_click) + confirm_btn.clicked.connect(self._on_confirm_click) + + self._projects_view = projects_view + self._projects_model = projects_model + self._confirm_btn = confirm_btn + + self._publisher_window = publisher_window + + def showEvent(self, event): + self._projects_model.refresh() + super(StandaloneOverlayWidget, self).showEvent(event) + + def _on_double_click(self): + self.set_selected_project() + + def _on_confirm_click(self): + self.set_selected_project() + + def set_selected_project(self): + index = self._projects_view.currentIndex() + + project_name = index.data(PROJECT_NAME_ROLE) + if not project_name: + return + + traypublisher.set_project_name(project_name) + self.setVisible(False) + self.project_selected.emit(project_name) + + +class TrayPublishWindow(PublisherWindow): + def __init__(self, *args, **kwargs): + super(TrayPublishWindow, self).__init__(reset_on_show=False) + + overlay_widget = StandaloneOverlayWidget(self) + + btns_widget = QtWidgets.QWidget(self) + + back_to_overlay_btn = QtWidgets.QPushButton( + "Change project", btns_widget + ) + save_btn = QtWidgets.QPushButton("Save", btns_widget) + # TODO implement save mechanism of tray publisher + save_btn.setVisible(False) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + + btns_layout.addWidget(save_btn, 0) + btns_layout.addWidget(back_to_overlay_btn, 0) + + self._header_layout.addWidget(btns_widget, 0) + + overlay_widget.project_selected.connect(self._on_project_select) + back_to_overlay_btn.clicked.connect(self._on_back_to_overlay) + save_btn.clicked.connect(self._on_tray_publish_save) + + self._back_to_overlay_btn = back_to_overlay_btn + self._overlay_widget = overlay_widget + + def _on_back_to_overlay(self): + self._overlay_widget.setVisible(True) + self._resize_overlay() + + def _resize_overlay(self): + self._overlay_widget.resize( + self.width(), + self.height() + ) + + def resizeEvent(self, event): + super(TrayPublishWindow, self).resizeEvent(event) + self._resize_overlay() + + def _on_project_select(self, project_name): + self.controller.save_changes() + self.controller.reset_project_data_cache() + io.Session["AVALON_PROJECT"] = project_name + io.install() + + self.reset() + if not self.controller.instances: + self._on_create_clicked() + + def _on_tray_publish_save(self): + self.controller.save_changes() + print("NOT YET IMPLEMENTED") + + +def main(): + app = QtWidgets.QApplication([]) + window = TrayPublishWindow() + window.show() + app.exec_() From 57e1af7ba8677da789e6c822ea62524a53cb3b66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:09:28 +0100 Subject: [PATCH 064/152] changed 'uuid' to 'instance_id' as 'uuid' may not work in maya --- openpype/hosts/testhost/api/instances.json | 12 ++++++------ openpype/hosts/testhost/api/pipeline.py | 6 +++--- openpype/pipeline/create/README.md | 4 ++-- openpype/pipeline/create/context.py | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/testhost/api/instances.json b/openpype/hosts/testhost/api/instances.json index 84021eff91..d955012514 100644 --- a/openpype/hosts/testhost/api/instances.json +++ b/openpype/hosts/testhost/api/instances.json @@ -8,7 +8,7 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "myVariant", - "uuid": "a485f148-9121-46a5-8157-aa64df0fb449", + "instance_id": "a485f148-9121-46a5-8157-aa64df0fb449", "creator_attributes": { "number_key": 10, "ha": 10 @@ -29,8 +29,8 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "myVariant2", - "uuid": "a485f148-9121-46a5-8157-aa64df0fb444", "creator_attributes": {}, + "instance_id": "a485f148-9121-46a5-8157-aa64df0fb444", "publish_attributes": { "CollectFtrackApi": { "add_ftrack_family": true @@ -47,8 +47,8 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "Main", - "uuid": "3607bc95-75f6-4648-a58d-e699f413d09f", "creator_attributes": {}, + "instance_id": "3607bc95-75f6-4648-a58d-e699f413d09f", "publish_attributes": { "CollectFtrackApi": { "add_ftrack_family": true @@ -65,7 +65,7 @@ "asset": "sq01_sh0020", "task": "Compositing", "variant": "Main2", - "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb", + "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8eb", "creator_attributes": {}, "publish_attributes": { "CollectFtrackApi": { @@ -83,7 +83,7 @@ "asset": "sq01_sh0020", "task": "Compositing", "variant": "Main2", - "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec", + "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8ec", "creator_attributes": {}, "publish_attributes": { "CollectFtrackApi": { @@ -101,7 +101,7 @@ "asset": "Alpaca_01", "task": "modeling", "variant": "Main", - "uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", + "instance_id": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", "creator_attributes": {}, "publish_attributes": {} } diff --git a/openpype/hosts/testhost/api/pipeline.py b/openpype/hosts/testhost/api/pipeline.py index 49f1d3f33d..1f5d680705 100644 --- a/openpype/hosts/testhost/api/pipeline.py +++ b/openpype/hosts/testhost/api/pipeline.py @@ -114,7 +114,7 @@ def update_instances(update_list): instances = HostContext.get_instances() for instance_data in instances: - instance_id = instance_data["uuid"] + instance_id = instance_data["instance_id"] if instance_id in updated_instances: new_instance_data = updated_instances[instance_id] old_keys = set(instance_data.keys()) @@ -132,10 +132,10 @@ def remove_instances(instances): current_instances = HostContext.get_instances() for instance in instances: - instance_id = instance.data["uuid"] + instance_id = instance.data["instance_id"] found_idx = None for idx, _instance in enumerate(current_instances): - if instance_id == _instance["uuid"]: + if instance_id == _instance["instance_id"]: found_idx = idx break diff --git a/openpype/pipeline/create/README.md b/openpype/pipeline/create/README.md index 9eef7c72a7..02b64e52ea 100644 --- a/openpype/pipeline/create/README.md +++ b/openpype/pipeline/create/README.md @@ -14,7 +14,7 @@ Except creating and removing instances are all changes not automatically propaga ## CreatedInstance -Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `uuid` which is identifier of the instance. +Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `instance_id` which is identifier of the instance. Family tells how should be instance processed and subset what name will published item have. - There are cases when subset is not fully filled during creation and may change during publishing. That is in most of cases caused because instance is related to other instance or instance data do not represent final product. @@ -26,7 +26,7 @@ Family tells how should be instance processed and subset what name will publishe ## Identifier that this data represents instance for publishing (automatically assigned) "id": "pyblish.avalon.instance", ## Identifier of this specific instance (automatically assigned) - "uuid": , + "instance_id": , ## Instance family (used from Creator) "family": , diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 4454d31d83..e11d32091f 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -361,7 +361,7 @@ class CreatedInstance: # their individual children but not on their own __immutable_keys = ( "id", - "uuid", + "instance_id", "family", "creator_identifier", "creator_attributes", @@ -434,8 +434,8 @@ class CreatedInstance: if data: self._data.update(data) - if not self._data.get("uuid"): - self._data["uuid"] = str(uuid4()) + if not self._data.get("instance_id"): + self._data["instance_id"] = str(uuid4()) self._asset_is_valid = self.has_set_asset self._task_is_valid = self.has_set_task @@ -551,7 +551,7 @@ class CreatedInstance: @property def id(self): """Instance identifier.""" - return self._data["uuid"] + return self._data["instance_id"] @property def data(self): From 2d88deb3510b181bfa36639bf9d7255355813892 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:10:29 +0100 Subject: [PATCH 065/152] implemented base of tray publisher host --- openpype/hosts/traypublisher/api/__init__.py | 20 ++ openpype/hosts/traypublisher/api/pipeline.py | 181 +++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 openpype/hosts/traypublisher/api/__init__.py create mode 100644 openpype/hosts/traypublisher/api/pipeline.py diff --git a/openpype/hosts/traypublisher/api/__init__.py b/openpype/hosts/traypublisher/api/__init__.py new file mode 100644 index 0000000000..c461c0c526 --- /dev/null +++ b/openpype/hosts/traypublisher/api/__init__.py @@ -0,0 +1,20 @@ +from .pipeline import ( + install, + ls, + + set_project_name, + get_context_title, + get_context_data, + update_context_data, +) + + +__all__ = ( + "install", + "ls", + + "set_project_name", + "get_context_title", + "get_context_data", + "update_context_data", +) diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py new file mode 100644 index 0000000000..83fe326ca4 --- /dev/null +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -0,0 +1,181 @@ +import os +import json +import tempfile +import atexit + +import avalon.api +import pyblish.api + +from openpype.pipeline import BaseCreator + +ROOT_DIR = os.path.dirname(os.path.dirname( + os.path.abspath(__file__) +)) +PUBLISH_PATH = os.path.join(ROOT_DIR, "plugins", "publish") +CREATE_PATH = os.path.join(ROOT_DIR, "plugins", "create") + + +class HostContext: + _context_json_path = None + + @staticmethod + def _on_exit(): + if ( + HostContext._context_json_path + and os.path.exists(HostContext._context_json_path) + ): + os.remove(HostContext._context_json_path) + + @classmethod + def get_context_json_path(cls): + if cls._context_json_path is None: + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="traypub_", suffix=".json" + ) + output_file.close() + cls._context_json_path = output_file.name + atexit.register(HostContext._on_exit) + print(cls._context_json_path) + return cls._context_json_path + + @classmethod + def _get_data(cls, group=None): + json_path = cls.get_context_json_path() + data = {} + if not os.path.exists(json_path): + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + else: + with open(json_path, "r") as json_stream: + content = json_stream.read() + if content: + data = json.loads(content) + if group is None: + return data + return data.get(group) + + @classmethod + def _save_data(cls, group, new_data): + json_path = cls.get_context_json_path() + data = cls._get_data() + data[group] = new_data + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + + @classmethod + def add_instance(cls, instance): + instances = cls.get_instances() + instances.append(instance) + cls.save_instances(instances) + + @classmethod + def get_instances(cls): + return cls._get_data("instances") or [] + + @classmethod + def save_instances(cls, instances): + cls._save_data("instances", instances) + + @classmethod + def get_context_data(cls): + return cls._get_data("context") or {} + + @classmethod + def save_context_data(cls, data): + cls._save_data("context", data) + + @classmethod + def get_project_name(cls): + return cls._get_data("project_name") + + @classmethod + def set_project_name(cls, project_name): + cls._save_data("project_name", project_name) + + @classmethod + def get_data_to_store(cls): + return { + "project_name": cls.get_project_name(), + "instances": cls.get_instances(), + "context": cls.get_context_data(), + } + + +def list_instances(): + return HostContext.get_instances() + + +def update_instances(update_list): + updated_instances = {} + for instance, _changes in update_list: + updated_instances[instance.id] = instance.data_to_store() + + instances = HostContext.get_instances() + for instance_data in instances: + instance_id = instance_data["instance_id"] + if instance_id in updated_instances: + new_instance_data = updated_instances[instance_id] + old_keys = set(instance_data.keys()) + new_keys = set(new_instance_data.keys()) + instance_data.update(new_instance_data) + for key in (old_keys - new_keys): + instance_data.pop(key) + + HostContext.save_instances(instances) + + +def remove_instances(instances): + if not isinstance(instances, (tuple, list)): + instances = [instances] + + current_instances = HostContext.get_instances() + for instance in instances: + instance_id = instance.data["instance_id"] + found_idx = None + for idx, _instance in enumerate(current_instances): + if instance_id == _instance["instance_id"]: + found_idx = idx + break + + if found_idx is not None: + current_instances.pop(found_idx) + HostContext.save_instances(current_instances) + + +def get_context_data(): + return HostContext.get_context_data() + + +def update_context_data(data, changes): + HostContext.save_context_data(data) + + +def get_context_title(): + return HostContext.get_project_name() + + +def ls(): + """Probably will never return loaded containers.""" + return [] + + +def install(): + """This is called before a project is known. + + Project is defined with 'set_project_name'. + """ + os.environ["AVALON_APP"] = "traypublisher" + + pyblish.api.register_host("traypublisher") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) + + +def set_project_name(project_name): + # Deregister project specific plugins and register new project plugins + old_project_name = HostContext.get_project_name() + if old_project_name is not None and old_project_name != project_name: + pass + os.environ["AVALON_PROJECT"] = project_name + avalon.api.Session["AVALON_PROJECT"] = project_name + HostContext.set_project_name(project_name) From d62a09729a3df1274a816b011ec2e5b840189b9b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:23:06 +0100 Subject: [PATCH 066/152] added creator for workfile family --- .../plugins/create/create_workfile.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/create/create_workfile.py diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py new file mode 100644 index 0000000000..38b25ea3c6 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/create/create_workfile.py @@ -0,0 +1,98 @@ +from openpype import resources +from openpype.hosts.traypublisher.api import pipeline +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib +) + + +class WorkfileCreator(Creator): + identifier = "workfile" + label = "Workfile" + family = "workfile" + description = "Publish backup of workfile" + + create_allow_context_change = True + + extensions = [ + # Maya + ".ma", ".mb", + # Nuke + ".nk", + # Hiero + ".hrox", + # Houdini + ".hip", ".hiplc", ".hipnc", + # Blender + ".blend", + # Celaction + ".scn", + # TVPaint + ".tvpp", + # Fusion + ".comp", + # Harmony + ".zip", + # Premiere + ".prproj", + # Resolve + ".drp", + # Photoshop + ".psd", ".psb", + # Aftereffects + ".aep" + ] + + def get_icon(self): + return resources.get_openpype_splash_filepath() + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def remove_instances(self, instances): + pipeline.remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def create(self, subset_name, data, pre_create_data): + # Pass precreate data to creator attributes + data["creator_attributes"] = pre_create_data + # Create new instance + new_instance = CreatedInstance(self.family, subset_name, data, self) + # Host implementation of storing metadata about instance + pipeline.HostContext.add_instance(new_instance.data_to_store()) + # Add instance to current context + self._add_instance_to_context(new_instance) + + def get_default_variants(self): + return [ + "Main" + ] + + def get_instance_attr_defs(self): + output = [ + lib.FileDef( + "filepath", + folders=False, + extensions=self.extensions, + label="Filepath" + ) + ] + return output + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attrobites + return self.get_instance_attr_defs() + + def get_detail_description(self): + return """# Publish workfile backup""" From 026d9688fec9b4f8c259e891760964a75fe26d57 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:23:35 +0100 Subject: [PATCH 067/152] added collector and validator for workfile family --- .../plugins/publish/collect_workfile.py | 31 +++++++++++++++++++ .../plugins/publish/validate_workfile.py | 24 ++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_workfile.py create mode 100644 openpype/hosts/traypublisher/plugins/publish/validate_workfile.py diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py new file mode 100644 index 0000000000..d48bace047 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py @@ -0,0 +1,31 @@ +import os +import pyblish.api + + +class CollectWorkfile(pyblish.api.InstancePlugin): + """Collect representation of workfile instances.""" + + label = "Collect Workfile" + order = pyblish.api.CollectorOrder - 0.49 + families = ["workfile"] + hosts = ["traypublisher"] + + def process(self, instance): + if "representations" not in instance.data: + instance.data["representations"] = [] + repres = instance.data["representations"] + + creator_attributes = instance.data["creator_attributes"] + filepath = creator_attributes["filepath"] + instance.data["sourceFilepath"] = filepath + + staging_dir = os.path.dirname(filepath) + filename = os.path.basename(filepath) + ext = os.path.splitext(filename)[-1] + + repres.append({ + "ext": ext, + "name": ext, + "stagingDir": staging_dir, + "files": filename + }) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py new file mode 100644 index 0000000000..88339d2aac --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py @@ -0,0 +1,24 @@ +import os +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateWorkfilePath(pyblish.api.InstancePlugin): + """Validate existence of workfile instance existence.""" + + label = "Collect Workfile" + order = pyblish.api.ValidatorOrder - 0.49 + families = ["workfile"] + hosts = ["traypublisher"] + + def process(self, instance): + filepath = instance.data["sourceFilepath"] + if not filepath: + raise PublishValidationError(( + "Filepath of 'workfile' instance \"{}\" is not set" + ).format(instance.data["name"])) + + if not os.path.exists(filepath): + raise PublishValidationError(( + "Filepath of 'workfile' instance \"{}\" does not exist: {}" + ).format(instance.data["name"], filepath)) From 1d1a07cc761c34ecc799cdf4cd88b1a350a0a59c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:23:41 +0100 Subject: [PATCH 068/152] added collector for source --- .../plugins/publish/collect_source.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 openpype/hosts/traypublisher/plugins/publish/collect_source.py diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_source.py b/openpype/hosts/traypublisher/plugins/publish/collect_source.py new file mode 100644 index 0000000000..6ff22be13a --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_source.py @@ -0,0 +1,24 @@ +import pyblish.api + + +class CollectSource(pyblish.api.ContextPlugin): + """Collecting instances from traypublisher host.""" + + label = "Collect source" + order = pyblish.api.CollectorOrder - 0.49 + hosts = ["traypublisher"] + + def process(self, context): + # get json paths from os and load them + source_name = "traypublisher" + for instance in context: + source = instance.data.get("source") + if not source: + instance.data["source"] = source_name + self.log.info(( + "Source of instance \"{}\" is changed to \"{}\"" + ).format(instance.data["name"], source_name)) + else: + self.log.info(( + "Source of instance \"{}\" was already set to \"{}\"" + ).format(instance.data["name"], source)) From cd7f54a8f51a3e79f6ac01bb4e1333c175dc06e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:24:05 +0100 Subject: [PATCH 069/152] created function to run detached process --- openpype/lib/__init__.py | 2 ++ openpype/lib/execute.py | 78 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index ebe7648ad7..8c3ebc8a3c 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -29,6 +29,7 @@ from .execute import ( get_linux_launcher_args, execute, run_subprocess, + run_detached_process, run_openpype_process, clean_envs_for_openpype_process, path_to_subprocess_arg, @@ -188,6 +189,7 @@ __all__ = [ "get_linux_launcher_args", "execute", "run_subprocess", + "run_detached_process", "run_openpype_process", "clean_envs_for_openpype_process", "path_to_subprocess_arg", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index afde844f2d..f2eb97c5f5 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -1,5 +1,9 @@ import os +import sys import subprocess +import platform +import json +import tempfile import distutils.spawn from .log import PypeLogger as Logger @@ -181,6 +185,80 @@ def run_openpype_process(*args, **kwargs): return run_subprocess(args, env=env, **kwargs) +def run_detached_process(args, **kwargs): + """Execute process with passed arguments as separated process. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_detached_openpype_process("run", "") + ``` + + Args: + *args (tuple): OpenPype cli arguments. + **kwargs (dict): Keyword arguments for for subprocess.Popen. + + Returns: + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). + """ + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + env = os.environ + + # Create copy of passed env + kwargs["env"] = {k: v for k, v in env.items()} + + low_platform = platform.system().lower() + if low_platform == "darwin": + new_args = ["open", "-na", args.pop(0), "--args"] + new_args.extend(args) + args = new_args + + elif low_platform == "windows": + flags = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + ) + kwargs["creationflags"] = flags + + if not sys.stdout: + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL + + elif low_platform == "linux" and get_linux_launcher_args() is not None: + json_data = { + "args": args, + "env": kwargs.pop("env") + } + json_temp = tempfile.NamedTemporaryFile( + mode="w", prefix="op_app_args", suffix=".json", delete=False + ) + json_temp.close() + json_temp_filpath = json_temp.name + with open(json_temp_filpath, "w") as stream: + json.dump(json_data, stream) + + new_args = get_linux_launcher_args() + new_args.append(json_temp_filpath) + + # Create mid-process which will launch application + process = subprocess.Popen(new_args, **kwargs) + # Wait until the process finishes + # - This is important! The process would stay in "open" state. + process.wait() + # Remove the temp file + os.remove(json_temp_filpath) + # Return process which is already terminated + return process + + process = subprocess.Popen(args, **kwargs) + return process + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. From 96981a05d3017c4f805c8a03fc70e613449bf55d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:24:44 +0100 Subject: [PATCH 070/152] install traypublish host in tool --- openpype/tools/traypublisher/window.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index d6a5fe56f8..34ba042e91 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -1,8 +1,21 @@ +"""Tray publisher is extending publisher tool. + +Adds ability to select project using overlay widget with list of projects. + +Tray publisher can be considered as host implementeation with creators and +publishing plugins. +""" + +import os from Qt import QtWidgets, QtCore import avalon.api from avalon import io from avalon.api import AvalonMongoDB +from openpype.hosts.traypublisher import ( + api as traypublisher +) +from openpype.hosts.traypublisher.api.pipeline import HostContext from openpype.tools.publisher import PublisherWindow from openpype.tools.utils.constants import PROJECT_NAME_ROLE from openpype.tools.utils.models import ( @@ -127,8 +140,10 @@ class TrayPublishWindow(PublisherWindow): self._resize_overlay() def _on_project_select(self, project_name): + # TODO register project specific plugin paths self.controller.save_changes() self.controller.reset_project_data_cache() + os.environ["AVALON_PROJECT"] = project_name io.Session["AVALON_PROJECT"] = project_name io.install() @@ -142,6 +157,7 @@ class TrayPublishWindow(PublisherWindow): def main(): + avalon.api.install(traypublisher) app = QtWidgets.QApplication([]) window = TrayPublishWindow() window.show() From f27d705577b49f5e17b1a7a070dad02c93386d79 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:25:12 +0100 Subject: [PATCH 071/152] added command line arguments to run tray publisher --- openpype/cli.py | 6 ++++++ openpype/pype_commands.py | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/openpype/cli.py b/openpype/cli.py index 0597c387d0..b9c80ca065 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -42,6 +42,12 @@ def standalonepublisher(): PypeCommands().launch_standalone_publisher() +@main.command() +def traypublisher(): + """Show new OpenPype Standalone publisher UI.""" + PypeCommands().launch_traypublisher() + + @main.command() @click.option("-d", "--debug", is_flag=True, help=("Run pype tray in debug mode")) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 47f5e7fcc0..9dc3e29337 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -80,6 +80,11 @@ class PypeCommands: from openpype.tools import standalonepublish standalonepublish.main() + @staticmethod + def launch_traypublisher(): + from openpype.tools import traypublisher + traypublisher.main() + @staticmethod def publish(paths, targets=None, gui=False): """Start headless publishing. From 7b9b1ef287cc06afdcf23f69aa4831b9dba510e9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:43:43 +0100 Subject: [PATCH 072/152] added missing method to clear project cache --- openpype/tools/publisher/control.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index ab2dffd489..04158ad05e 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -982,6 +982,9 @@ class PublisherController: self._publish_next_process() + def reset_project_data_cache(self): + self._asset_docs_cache.reset() + def collect_families_from_instances(instances, only_active=False): """Collect all families for passed publish instances. From 34af4ea3e3a0a33df76a47183e0e2cf742be2405 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:44:37 +0100 Subject: [PATCH 073/152] implemented traypublish module to show it in tray --- openpype/modules/base.py | 2 + openpype/modules/traypublish_action.py | 38 +++++++++++++++++++ .../defaults/system_settings/modules.json | 3 ++ .../schemas/system_schema/schema_modules.json | 14 +++++++ 4 files changed, 57 insertions(+) create mode 100644 openpype/modules/traypublish_action.py diff --git a/openpype/modules/base.py b/openpype/modules/base.py index d566692439..c601194a82 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -41,6 +41,7 @@ DEFAULT_OPENPYPE_MODULES = ( "project_manager_action", "settings_action", "standalonepublish_action", + "traypublish_action", "job_queue", "timers_manager", ) @@ -844,6 +845,7 @@ class TrayModulesManager(ModulesManager): "avalon", "clockify", "standalonepublish_tool", + "traypublish_tool", "log_viewer", "local_settings", "settings" diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py new file mode 100644 index 0000000000..039ce96206 --- /dev/null +++ b/openpype/modules/traypublish_action.py @@ -0,0 +1,38 @@ +import os +from openpype.lib import get_openpype_execute_args +from openpype.lib.execute import run_detached_process +from openpype.modules import OpenPypeModule +from openpype_interfaces import ITrayAction + + +class TrayPublishAction(OpenPypeModule, ITrayAction): + label = "Tray Publish (beta)" + name = "traypublish_tool" + + def initialize(self, modules_settings): + import openpype + self.enabled = modules_settings[self.name]["enabled"] + self.publish_paths = [ + os.path.join( + openpype.PACKAGE_DIR, + "hosts", + "traypublisher", + "plugins", + "publish" + ) + ] + + def tray_init(self): + return + + def on_action_trigger(self): + self.run_traypublisher() + + def connect_with_modules(self, enabled_modules): + """Collect publish paths from other modules.""" + publish_paths = self.manager.collect_plugin_paths()["publish"] + self.publish_paths.extend(publish_paths) + + def run_traypublisher(self): + args = get_openpype_execute_args("traypublisher") + run_detached_process(args) diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index d74269922f..70dc584360 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -191,6 +191,9 @@ "standalonepublish_tool": { "enabled": true }, + "traypublish_tool": { + "enabled": false + }, "project_manager": { "enabled": true }, diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 52595914ed..21c8163cea 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -233,6 +233,20 @@ } ] }, + { + "type": "dict", + "key": "traypublish_tool", + "label": "Tray Publish (beta)", + "collapsible": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "key": "project_manager", From bed0a09e6327fb63e04725fc184935a18b081b9c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 17:48:21 +0100 Subject: [PATCH 074/152] modified publish plugins to be able work without global context --- .../plugins/publish/collect_ftrack_api.py | 62 ++++++++++--------- .../publish/collect_anatomy_context_data.py | 56 +++++++++-------- .../publish/collect_anatomy_instance_data.py | 14 +++-- .../publish/collect_avalon_entities.py | 6 +- openpype/plugins/publish/integrate_new.py | 5 +- .../plugins/publish/validate_aseset_docs.py | 31 ++++++++++ 6 files changed, 113 insertions(+), 61 deletions(-) create mode 100644 openpype/plugins/publish/validate_aseset_docs.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py index a348617cfc..07af217fb6 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_api.py @@ -1,4 +1,3 @@ -import os import logging import pyblish.api import avalon.api @@ -43,37 +42,48 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): ).format(project_name)) project_entity = project_entities[0] + self.log.debug("Project found: {0}".format(project_entity)) - # Find asset entity - entity_query = ( - 'TypedContext where project_id is "{0}"' - ' and name is "{1}"' - ).format(project_entity["id"], asset_name) - self.log.debug("Asset entity query: < {0} >".format(entity_query)) - asset_entities = [] - for entity in session.query(entity_query).all(): - # Skip tasks - if entity.entity_type.lower() != "task": - asset_entities.append(entity) + asset_entity = None + if asset_name: + # Find asset entity + entity_query = ( + 'TypedContext where project_id is "{0}"' + ' and name is "{1}"' + ).format(project_entity["id"], asset_name) + self.log.debug("Asset entity query: < {0} >".format(entity_query)) + asset_entities = [] + for entity in session.query(entity_query).all(): + # Skip tasks + if entity.entity_type.lower() != "task": + asset_entities.append(entity) - if len(asset_entities) == 0: - raise AssertionError(( - "Entity with name \"{0}\" not found" - " in Ftrack project \"{1}\"." - ).format(asset_name, project_name)) + if len(asset_entities) == 0: + raise AssertionError(( + "Entity with name \"{0}\" not found" + " in Ftrack project \"{1}\"." + ).format(asset_name, project_name)) - elif len(asset_entities) > 1: - raise AssertionError(( - "Found more than one entity with name \"{0}\"" - " in Ftrack project \"{1}\"." - ).format(asset_name, project_name)) + elif len(asset_entities) > 1: + raise AssertionError(( + "Found more than one entity with name \"{0}\"" + " in Ftrack project \"{1}\"." + ).format(asset_name, project_name)) + + asset_entity = asset_entities[0] - asset_entity = asset_entities[0] self.log.debug("Asset found: {0}".format(asset_entity)) + task_entity = None # Find task entity if task is set - if task_name: + if not asset_entity: + self.log.warning( + "Asset entity is not set. Skipping query of task entity." + ) + elif not task_name: + self.log.warning("Task name is not set.") + else: task_query = ( 'Task where name is "{0}" and parent_id is "{1}"' ).format(task_name, asset_entity["id"]) @@ -88,10 +98,6 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): else: self.log.debug("Task entity found: {0}".format(task_entity)) - else: - task_entity = None - self.log.warning("Task name is not set.") - context.data["ftrackSession"] = session context.data["ftrackPythonModule"] = ftrack_api context.data["ftrackProject"] = project_entity diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py index b0474b93ce..bd8d9e50c4 100644 --- a/openpype/plugins/publish/collect_anatomy_context_data.py +++ b/openpype/plugins/publish/collect_anatomy_context_data.py @@ -44,42 +44,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): label = "Collect Anatomy Context Data" def process(self, context): - - task_name = api.Session["AVALON_TASK"] - project_entity = context.data["projectEntity"] - asset_entity = context.data["assetEntity"] - - asset_tasks = asset_entity["data"]["tasks"] - task_type = asset_tasks.get(task_name, {}).get("type") - - project_task_types = project_entity["config"]["tasks"] - task_code = project_task_types.get(task_type, {}).get("short_name") - - asset_parents = asset_entity["data"]["parents"] - hierarchy = "/".join(asset_parents) - - parent_name = project_entity["name"] - if asset_parents: - parent_name = asset_parents[-1] - context_data = { "project": { "name": project_entity["name"], "code": project_entity["data"].get("code") }, - "asset": asset_entity["name"], - "parent": parent_name, - "hierarchy": hierarchy, - "task": { - "name": task_name, - "type": task_type, - "short": task_code, - }, "username": context.data["user"], "app": context.data["hostName"] } + context.data["anatomyData"] = context_data + # add system general settings anatomy data system_general_data = get_system_general_anatomy_data() context_data.update(system_general_data) @@ -87,7 +63,33 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): datetime_data = context.data.get("datetimeData") or {} context_data.update(datetime_data) - context.data["anatomyData"] = context_data + asset_entity = context.data.get("assetEntity") + if asset_entity: + task_name = api.Session["AVALON_TASK"] + + asset_tasks = asset_entity["data"]["tasks"] + task_type = asset_tasks.get(task_name, {}).get("type") + + project_task_types = project_entity["config"]["tasks"] + task_code = project_task_types.get(task_type, {}).get("short_name") + + asset_parents = asset_entity["data"]["parents"] + hierarchy = "/".join(asset_parents) + + parent_name = project_entity["name"] + if asset_parents: + parent_name = asset_parents[-1] + + context_data.update({ + "asset": asset_entity["name"], + "parent": parent_name, + "hierarchy": hierarchy, + "task": { + "name": task_name, + "type": task_type, + "short": task_code, + } + }) self.log.info("Global anatomy Data collected") self.log.debug(json.dumps(context_data, indent=4)) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 74b556e28a..42836e796b 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -52,7 +52,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): def fill_missing_asset_docs(self, context): self.log.debug("Qeurying asset documents for instances.") - context_asset_doc = context.data["assetEntity"] + context_asset_doc = context.data.get("assetEntity") instances_with_missing_asset_doc = collections.defaultdict(list) for instance in context: @@ -69,7 +69,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Check if asset name is the same as what is in context # - they may be different, e.g. in NukeStudio - if context_asset_doc["name"] == _asset_name: + if context_asset_doc and context_asset_doc["name"] == _asset_name: instance.data["assetEntity"] = context_asset_doc else: @@ -212,7 +212,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self.log.debug("Storing anatomy data to instance data.") project_doc = context.data["projectEntity"] - context_asset_doc = context.data["assetEntity"] + context_asset_doc = context.data.get("assetEntity") project_task_types = project_doc["config"]["tasks"] @@ -240,7 +240,13 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Hiearchy asset_doc = instance.data.get("assetEntity") - if asset_doc and asset_doc["_id"] != context_asset_doc["_id"]: + if ( + asset_doc + and ( + not context_asset_doc + or asset_doc["_id"] != context_asset_doc["_id"] + ) + ): parents = asset_doc["data"].get("parents") or list() parent_name = project_doc["name"] if parents: diff --git a/openpype/plugins/publish/collect_avalon_entities.py b/openpype/plugins/publish/collect_avalon_entities.py index a6120d42fe..c099a2cf75 100644 --- a/openpype/plugins/publish/collect_avalon_entities.py +++ b/openpype/plugins/publish/collect_avalon_entities.py @@ -33,6 +33,11 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): ).format(project_name) self.log.debug("Collected Project \"{}\"".format(project_entity)) + context.data["projectEntity"] = project_entity + + if not asset_name: + self.log.info("Context is not set. Can't collect global data.") + return asset_entity = io.find_one({ "type": "asset", "name": asset_name, @@ -44,7 +49,6 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): self.log.debug("Collected Asset \"{}\"".format(asset_entity)) - context.data["projectEntity"] = project_entity context.data["assetEntity"] = asset_entity data = asset_entity['data'] diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bf214d9139..a706ccbab6 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -147,7 +147,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): project_entity = instance.data["projectEntity"] - context_asset_name = context.data["assetEntity"]["name"] + context_asset_name = None + context_asset_doc = context.data.get("assetEntity") + if context_asset_doc: + context_asset_name = context_asset_doc["name"] asset_name = instance.data["asset"] asset_entity = instance.data.get("assetEntity") diff --git a/openpype/plugins/publish/validate_aseset_docs.py b/openpype/plugins/publish/validate_aseset_docs.py new file mode 100644 index 0000000000..eed75cdf8a --- /dev/null +++ b/openpype/plugins/publish/validate_aseset_docs.py @@ -0,0 +1,31 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateContainers(pyblish.api.InstancePlugin): + """Validate existence of asset asset documents on instances. + + Without asset document it is not possible to publish the instance. + + If context has set asset document the validation is skipped. + + Plugin was added because there are cases when context asset is not defined + e.g. in tray publisher. + """ + + label = "Validate Asset docs" + order = pyblish.api.ValidatorOrder + + def process(self, instance): + context_asset_doc = instance.context.data.get("assetEntity") + if context_asset_doc: + return + + if instance.data.get("assetEntity"): + self.log.info("Instance have set asset document in it's data.") + + else: + raise PublishValidationError(( + "Instance \"{}\" don't have set asset" + " document which is needed for publishing." + ).format(instance.data["name"])) From aa9df7edd5fe2d0e693495a79dd7498b2fadc08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 21 Feb 2022 18:42:48 +0100 Subject: [PATCH 075/152] wip on integrating avalon functionality --- openpype/hosts/unreal/__init__.py | 7 +- openpype/hosts/unreal/api/helpers.py | 44 ++ openpype/hosts/unreal/api/lib.py | 18 +- openpype/hosts/unreal/api/pipeline.py | 388 ++++++++++++++++++ openpype/hosts/unreal/api/plugin.py | 7 +- openpype/hosts/unreal/integration/.gitignore | 35 ++ .../integration/Content/Python/init_unreal.py | 27 ++ .../hosts/unreal/integration/OpenPype.uplugin | 24 ++ openpype/hosts/unreal/integration/README.md | 11 + .../integration/Resources/openpype128.png | Bin 0 -> 14594 bytes .../integration/Resources/openpype40.png | Bin 0 -> 4884 bytes .../integration/Resources/openpype512.png | Bin 0 -> 85856 bytes .../integration/Source/Avalon/Avalon.Build.cs | 57 +++ .../Source/Avalon/Private/AssetContainer.cpp | 115 ++++++ .../Avalon/Private/AssetContainerFactory.cpp | 20 + .../Source/Avalon/Private/Avalon.cpp | 103 +++++ .../Source/Avalon/Private/AvalonLib.cpp | 48 +++ .../Avalon/Private/AvalonPublishInstance.cpp | 108 +++++ .../Private/AvalonPublishInstanceFactory.cpp | 20 + .../Avalon/Private/AvalonPythonBridge.cpp | 13 + .../Source/Avalon/Private/AvalonStyle.cpp | 69 ++++ .../Source/Avalon/Public/AssetContainer.h | 39 ++ .../Avalon/Public/AssetContainerFactory.h | 21 + .../integration/Source/Avalon/Public/Avalon.h | 21 + .../Source/Avalon/Public/AvalonLib.h | 19 + .../Avalon/Public/AvalonPublishInstance.h | 21 + .../Public/AvalonPublishInstanceFactory.h | 19 + .../Source/Avalon/Public/AvalonPythonBridge.h | 20 + .../Source/Avalon/Public/AvalonStyle.h | 22 + 29 files changed, 1282 insertions(+), 14 deletions(-) create mode 100644 openpype/hosts/unreal/api/helpers.py create mode 100644 openpype/hosts/unreal/api/pipeline.py create mode 100644 openpype/hosts/unreal/integration/.gitignore create mode 100644 openpype/hosts/unreal/integration/Content/Python/init_unreal.py create mode 100644 openpype/hosts/unreal/integration/OpenPype.uplugin create mode 100644 openpype/hosts/unreal/integration/README.md create mode 100644 openpype/hosts/unreal/integration/Resources/openpype128.png create mode 100644 openpype/hosts/unreal/integration/Resources/openpype40.png create mode 100644 openpype/hosts/unreal/integration/Resources/openpype512.png create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h create mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index 1280442916..e6ca1e833d 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -3,11 +3,12 @@ import os def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" - # Set AVALON_UNREAL_PLUGIN required for Unreal implementation + # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation unreal_plugin_path = os.path.join( - os.environ["OPENPYPE_REPOS_ROOT"], "repos", "avalon-unreal-integration" + os.environ["OPENPYPE_ROOT"], "openpype", "hosts", + "unreal", "integration" ) - env["AVALON_UNREAL_PLUGIN"] = unreal_plugin_path + env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path # Set default environments if are not set via settings defaults = { diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py new file mode 100644 index 0000000000..6fc89cf176 --- /dev/null +++ b/openpype/hosts/unreal/api/helpers.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +import unreal # noqa + + +class OpenPypeUnrealException(Exception): + pass + + +@unreal.uclass() +class OpenPypeHelpers(unreal.OpenPypeLib): + """Class wrapping some useful functions for OpenPype. + + This class is extending native BP class in OpenPype Integration Plugin. + + """ + + @unreal.ufunction(params=[str, unreal.LinearColor, bool]) + def set_folder_color(self, path: str, color: unreal.LinearColor) -> Bool: + """Set color on folder in Content Browser. + + This method sets color on folder in Content Browser. Unfortunately + there is no way to refresh Content Browser so new color isn't applied + immediately. They are saved to config file and appears correctly + only after Editor is restarted. + + Args: + path (str): Path to folder + color (:class:`unreal.LinearColor`): Color of the folder + + Example: + + AvalonHelpers().set_folder_color( + "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0) + ) + + Note: + This will take effect only after Editor is restarted. I couldn't + find a way to refresh it. Also this saves the color definition + into the project config, binding this path with color. So if you + delete this path and later re-create, it will set this color + again. + + """ + self.c_set_folder_color(path, color, False) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index 61dac46fac..e04606a333 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -169,11 +169,11 @@ def create_unreal_project(project_name: str, env: dict = None) -> None: """This will create `.uproject` file at specified location. - As there is no way I know to create project via command line, this is - easiest option. Unreal project file is basically JSON file. If we find - `AVALON_UNREAL_PLUGIN` environment variable we assume this is location - of Avalon Integration Plugin and we copy its content to project folder - and enable this plugin. + As there is no way I know to create a project via command line, this is + easiest option. Unreal project file is basically a JSON file. If we find + the `OPENPYPE_UNREAL_PLUGIN` environment variable we assume this is the + location of the Integration Plugin and we copy its content to the project + folder and enable this plugin. Args: project_name (str): Name of the project. @@ -254,14 +254,14 @@ def create_unreal_project(project_name: str, {"Name": "PythonScriptPlugin", "Enabled": True}, {"Name": "EditorScriptingUtilities", "Enabled": True}, {"Name": "SequencerScripting", "Enabled": True}, - {"Name": "Avalon", "Enabled": True} + {"Name": "OpenPype", "Enabled": True} ] } if dev_mode or preset["dev_mode"]: - # this will add project module and necessary source file to make it - # C++ project and to (hopefully) make Unreal Editor to compile all - # sources at start + # this will add the project module and necessary source file to + # make it a C++ project and to (hopefully) make Unreal Editor to + # compile all # sources at start data["Modules"] = [{ "Name": project_name, diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py new file mode 100644 index 0000000000..c255005f31 --- /dev/null +++ b/openpype/hosts/unreal/api/pipeline.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- +import sys +import pyblish.api +from avalon.pipeline import AVALON_CONTAINER_ID + +import unreal # noqa +from typing import List + +from openpype.tools.utils import host_tools + +from avalon import api + + +AVALON_CONTAINERS = "OpenPypeContainers" + + +def install(): + + pyblish.api.register_host("unreal") + _register_callbacks() + _register_events() + + +def _register_callbacks(): + """ + TODO: Implement callbacks if supported by UE4 + """ + pass + + +def _register_events(): + """ + TODO: Implement callbacks if supported by UE4 + """ + pass + + +def uninstall(): + pyblish.api.deregister_host("unreal") + + +class Creator(api.Creator): + hosts = ["unreal"] + asset_types = [] + + def process(self): + nodes = list() + + with unreal.ScopedEditorTransaction("Avalon Creating Instance"): + if (self.options or {}).get("useSelection"): + self.log.info("setting ...") + print("settings ...") + nodes = unreal.EditorUtilityLibrary.get_selected_assets() + + asset_paths = [a.get_path_name() for a in nodes] + self.name = move_assets_to_path( + "/Game", self.name, asset_paths + ) + + instance = create_publish_instance("/Game", self.name) + imprint(instance, self.data) + + return instance + + +class Loader(api.Loader): + hosts = ["unreal"] + + +def ls(): + """ + List all containers found in *Content Manager* of Unreal and return + metadata from them. Adding `objectName` to set. + """ + ar = unreal.AssetRegistryHelpers.get_asset_registry() + avalon_containers = ar.get_assets_by_class("AssetContainer", True) + + # get_asset_by_class returns AssetData. To get all metadata we need to + # load asset. get_tag_values() work only on metadata registered in + # Asset Registy Project settings (and there is no way to set it with + # python short of editing ini configuration file). + for asset_data in avalon_containers: + asset = asset_data.get_asset() + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = asset_data.asset_name + data = cast_map_to_str_dict(data) + + yield data + + +def parse_container(container): + """ + To get data from container, AssetContainer must be loaded. + + Args: + container(str): path to container + + Returns: + dict: metadata stored on container + """ + asset = unreal.EditorAssetLibrary.load_asset(container) + data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) + data["objectName"] = asset.get_name() + data = cast_map_to_str_dict(data) + + return data + + +def publish(): + """Shorthand to publish from within host""" + import pyblish.util + + return pyblish.util.publish() + + +def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): + + """Bundles *nodes* (assets) into a *container* and add metadata to it. + + Unreal doesn't support *groups* of assets that you can add metadata to. + But it does support folders that helps to organize asset. Unfortunately + those folders are just that - you cannot add any additional information + to them. `Avalon Integration Plugin`_ is providing way out - Implementing + `AssetContainer` Blueprint class. This class when added to folder can + handle metadata on it using standard + :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and + :func:`unreal.EditorAssetLibrary.get_metadata_tag_values()`. It also + stores and monitor all changes in assets in path where it resides. List of + those assets is available as `assets` property. + + This is list of strings starting with asset type and ending with its path: + `Material /Game/Avalon/Test/TestMaterial.TestMaterial` + + .. _Avalon Integration Plugin: + https://github.com/pypeclub/avalon-unreal-integration + + """ + # 1 - create directory for container + root = "/Game" + container_name = "{}{}".format(name, suffix) + new_name = move_assets_to_path(root, container_name, nodes) + + # 2 - create Asset Container there + path = "{}/{}".format(root, new_name) + create_container(container=container_name, path=path) + + namespace = path + + data = { + "schema": "openpype:container-2.0", + "id": AVALON_CONTAINER_ID, + "name": new_name, + "namespace": namespace, + "loader": str(loader), + "representation": context["representation"]["_id"], + } + # 3 - imprint data + imprint("{}/{}".format(path, container_name), data) + return path + + +def instantiate(root, name, data, assets=None, suffix="_INS"): + """ + Bundles *nodes* into *container* marking it with metadata as publishable + instance. If assets are provided, they are moved to new path where + `AvalonPublishInstance` class asset is created and imprinted with metadata. + + This can then be collected for publishing by Pyblish for example. + + Args: + root (str): root path where to create instance container + name (str): name of the container + data (dict): data to imprint on container + assets (list of str): list of asset paths to include in publish + instance + suffix (str): suffix string to append to instance name + """ + container_name = "{}{}".format(name, suffix) + + # if we specify assets, create new folder and move them there. If not, + # just create empty folder + if assets: + new_name = move_assets_to_path(root, container_name, assets) + else: + new_name = create_folder(root, name) + + path = "{}/{}".format(root, new_name) + create_publish_instance(instance=container_name, path=path) + + imprint("{}/{}".format(path, container_name), data) + + +def imprint(node, data): + loaded_asset = unreal.EditorAssetLibrary.load_asset(node) + for key, value in data.items(): + # Support values evaluated at imprint + if callable(value): + value = value() + # Unreal doesn't support NoneType in metadata values + if value is None: + value = "" + unreal.EditorAssetLibrary.set_metadata_tag( + loaded_asset, key, str(value) + ) + + with unreal.ScopedEditorTransaction("Avalon containerising"): + unreal.EditorAssetLibrary.save_asset(node) + + +def show_tools_popup(): + """Show popup with tools. + + Popup will disappear on click or loosing focus. + """ + from openpype.hosts.unreal.api import tools_ui + + tools_ui.show_tools_popup() + + +def show_tools_dialog(): + """Show dialog with tools. + + Dialog will stay visible. + """ + from openpype.hosts.unreal.api import tools_ui + + tools_ui.show_tools_dialog() + + +def show_creator(): + host_tools.show_creator() + + +def show_loader(): + host_tools.show_loader(use_context=True) + + +def show_publisher(): + host_tools.show_publish() + + +def show_manager(): + host_tools.show_scene_inventory() + + +def show_experimental_tools(): + host_tools.show_experimental_tools_dialog() + + +def create_folder(root: str, name: str) -> str: + """Create new folder + + If folder exists, append number at the end and try again, incrementing + if needed. + + Args: + root (str): path root + name (str): folder name + + Returns: + str: folder name + + Example: + >>> create_folder("/Game/Foo") + /Game/Foo + >>> create_folder("/Game/Foo") + /Game/Foo1 + + """ + eal = unreal.EditorAssetLibrary + index = 1 + while True: + if eal.does_directory_exist("{}/{}".format(root, name)): + name = "{}{}".format(name, index) + index += 1 + else: + eal.make_directory("{}/{}".format(root, name)) + break + + return name + + +def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: + """ + Moving (renaming) list of asset paths to new destination. + + Args: + root (str): root of the path (eg. `/Game`) + name (str): name of destination directory (eg. `Foo` ) + assets (list of str): list of asset paths + + Returns: + str: folder name + + Example: + This will get paths of all assets under `/Game/Test` and move them + to `/Game/NewTest`. If `/Game/NewTest` already exists, then resulting + path will be `/Game/NewTest1` + + >>> assets = unreal.EditorAssetLibrary.list_assets("/Game/Test") + >>> move_assets_to_path("/Game", "NewTest", assets) + NewTest + + """ + eal = unreal.EditorAssetLibrary + name = create_folder(root, name) + + unreal.log(assets) + for asset in assets: + loaded = eal.load_asset(asset) + eal.rename_asset( + asset, "{}/{}/{}".format(root, name, loaded.get_name()) + ) + + return name + + +def create_container(container: str, path: str) -> unreal.Object: + """ + Helper function to create Asset Container class on given path. + This Asset Class helps to mark given path as Container + and enable asset version control on it. + + Args: + container (str): Asset Container name + path (str): Path where to create Asset Container. This path should + point into container folder + + Returns: + :class:`unreal.Object`: instance of created asset + + Example: + + create_avalon_container( + "/Game/modelingFooCharacter_CON", + "modelingFooCharacter_CON" + ) + + """ + factory = unreal.AssetContainerFactory() + tools = unreal.AssetToolsHelpers().get_asset_tools() + + asset = tools.create_asset(container, path, None, factory) + return asset + + +def create_publish_instance(instance: str, path: str) -> unreal.Object: + """ + Helper function to create Avalon Publish Instance on given path. + This behaves similary as :func:`create_avalon_container`. + + Args: + path (str): Path where to create Publish Instance. + This path should point into container folder + instance (str): Publish Instance name + + Returns: + :class:`unreal.Object`: instance of created asset + + Example: + + create_publish_instance( + "/Game/modelingFooCharacter_INST", + "modelingFooCharacter_INST" + ) + + """ + factory = unreal.AvalonPublishInstanceFactory() + tools = unreal.AssetToolsHelpers().get_asset_tools() + asset = tools.create_asset(instance, path, None, factory) + return asset + + +def cast_map_to_str_dict(map) -> dict: + """Cast Unreal Map to dict. + + Helper function to cast Unreal Map object to plain old python + dict. This will also cast values and keys to str. Useful for + metadata dicts. + + Args: + map: Unreal Map object + + Returns: + dict + + """ + return {str(key): str(value) for (key, value) in map.items()} diff --git a/openpype/hosts/unreal/api/plugin.py b/openpype/hosts/unreal/api/plugin.py index 5a6b236730..2327fc09c8 100644 --- a/openpype/hosts/unreal/api/plugin.py +++ b/openpype/hosts/unreal/api/plugin.py @@ -1,5 +1,8 @@ -from avalon import api +# -*- coding: utf-8 -*- +from abc import ABC + import openpype.api +import avalon.api class Creator(openpype.api.Creator): @@ -7,6 +10,6 @@ class Creator(openpype.api.Creator): defaults = ['Main'] -class Loader(api.Loader): +class Loader(avalon.api.Loader, ABC): """This serves as skeleton for future OpenPype specific functionality""" pass diff --git a/openpype/hosts/unreal/integration/.gitignore b/openpype/hosts/unreal/integration/.gitignore new file mode 100644 index 0000000000..b32a6f55e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/.gitignore @@ -0,0 +1,35 @@ +# Prerequisites +*.d + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod +*.smod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.exe +*.out +*.app + +/Binaries +/Intermediate diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py new file mode 100644 index 0000000000..48e931bb04 --- /dev/null +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -0,0 +1,27 @@ +import unreal + +avalon_detected = True +try: + from avalon import api + from avalon import unreal as avalon_unreal +except ImportError as exc: + avalon_detected = False + unreal.log_error("Avalon: cannot load avalon [ {} ]".format(exc)) + +if avalon_detected: + api.install(avalon_unreal) + + +@unreal.uclass() +class AvalonIntegration(unreal.AvalonPythonBridge): + @unreal.ufunction(override=True) + def RunInPython_Popup(self): + unreal.log_warning("Avalon: showing tools popup") + if avalon_detected: + avalon_unreal.show_tools_popup() + + @unreal.ufunction(override=True) + def RunInPython_Dialog(self): + unreal.log_warning("Avalon: showing tools dialog") + if avalon_detected: + avalon_unreal.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/OpenPype.uplugin b/openpype/hosts/unreal/integration/OpenPype.uplugin new file mode 100644 index 0000000000..4c7a74403c --- /dev/null +++ b/openpype/hosts/unreal/integration/OpenPype.uplugin @@ -0,0 +1,24 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "OpenPype", + "Description": "OpenPype Integration", + "Category": "OpenPype.Integration", + "CreatedBy": "Ondrej Samohel", + "CreatedByURL": "https://openpype.io", + "DocsURL": "https://openpype.io/docs/artist_hosts_unreal", + "MarketplaceURL": "", + "SupportURL": "https://pype.club/", + "CanContainContent": true, + "IsBetaVersion": true, + "IsExperimentalVersion": false, + "Installed": false, + "Modules": [ + { + "Name": "OpenPype", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +} \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/README.md b/openpype/hosts/unreal/integration/README.md new file mode 100644 index 0000000000..a32d89aab8 --- /dev/null +++ b/openpype/hosts/unreal/integration/README.md @@ -0,0 +1,11 @@ +# OpenPype Unreal Integration plugin + +This is plugin for Unreal Editor, creating menu for [OpenPype](https://github.com/getavalon) tools to run. + +## How does this work + +Plugin is creating basic menu items in **Window/OpenPype** section of Unreal Editor main menu and a button +on the main toolbar with associated menu. Clicking on those menu items is calling callbacks that are +declared in c++ but needs to be implemented during Unreal Editor +startup in `Plugins/OpenPype/Content/Python/init_unreal.py` - this should be executed by Unreal Editor +automatically. diff --git a/openpype/hosts/unreal/integration/Resources/openpype128.png b/openpype/hosts/unreal/integration/Resources/openpype128.png new file mode 100644 index 0000000000000000000000000000000000000000..abe8a807ef40f00b75d7446d020a2437732c7583 GIT binary patch literal 14594 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 literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/Resources/openpype512.png b/openpype/hosts/unreal/integration/Resources/openpype512.png new file mode 100644 index 0000000000000000000000000000000000000000..97c4d4326bc16ba6dfb45d35c4362d8bc15900ae GIT binary patch literal 85856 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 bCJFu@j}?W0K?Ok;00000NkvXXu0mjf9`4Bp literal 0 HcmV?d00001 diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs b/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs new file mode 100644 index 0000000000..5068e37d80 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs @@ -0,0 +1,57 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +using UnrealBuildTool; + +public class Avalon : ModuleRules +{ + public Avalon(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + PublicIncludePaths.AddRange( + new string[] { + // ... add public include paths required here ... + } + ); + + + PrivateIncludePaths.AddRange( + new string[] { + // ... add other private include paths required here ... + } + ); + + + PublicDependencyModuleNames.AddRange( + new string[] + { + "Core", + // ... add other public dependencies that you statically link with here ... + } + ); + + + PrivateDependencyModuleNames.AddRange( + new string[] + { + "Projects", + "InputCore", + "UnrealEd", + "LevelEditor", + "CoreUObject", + "Engine", + "Slate", + "SlateCore", + // ... add private dependencies that you statically link with here ... + } + ); + + + DynamicallyLoadedModuleNames.AddRange( + new string[] + { + // ... add any modules that your module loads dynamically here ... + } + ); + } +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp new file mode 100644 index 0000000000..c766f87a8e --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp @@ -0,0 +1,115 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#include "AssetContainer.h" +#include "AssetRegistryModule.h" +#include "Misc/PackageName.h" +#include "Engine.h" +#include "Containers/UnrealString.h" + +UAssetContainer::UAssetContainer(const FObjectInitializer& ObjectInitializer) +: UAssetUserData(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAssetContainer::GetPathName(); + UE_LOG(LogTemp, Warning, TEXT("UAssetContainer %s"), *path); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAssetContainer::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAssetContainer::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAssetContainer::OnAssetRenamed); +} + +void UAssetContainer::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + assets.Add(assetPath); + assetsData.Add(AssetData); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAssetContainer::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAssetContainer::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + assetsData.Remove(AssetData); + } + } +} + +void UAssetContainer::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAssetContainer::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + assetsData.Remove(AssetData); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} + diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp new file mode 100644 index 0000000000..b943150bdd --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp @@ -0,0 +1,20 @@ +#include "AssetContainerFactory.h" +#include "AssetContainer.h" + +UAssetContainerFactory::UAssetContainerFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAssetContainer::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAssetContainerFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAssetContainer* AssetContainer = NewObject(InParent, Class, Name, Flags); + return AssetContainer; +} + +bool UAssetContainerFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp new file mode 100644 index 0000000000..ed782f4870 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp @@ -0,0 +1,103 @@ +#include "Avalon.h" +#include "LevelEditor.h" +#include "AvalonPythonBridge.h" +#include "AvalonStyle.h" + + +static const FName AvalonTabName("Avalon"); + +#define LOCTEXT_NAMESPACE "FAvalonModule" + +// This function is triggered when the plugin is staring up +void FAvalonModule::StartupModule() +{ + + FAvalonStyle::Initialize(); + FAvalonStyle::SetIcon("Logo", "openpype40"); + + // Create the Extender that will add content to the menu + FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); + + TSharedPtr MenuExtender = MakeShareable(new FExtender()); + TSharedPtr ToolbarExtender = MakeShareable(new FExtender()); + + MenuExtender->AddMenuExtension( + "LevelEditor", + EExtensionHook::After, + NULL, + FMenuExtensionDelegate::CreateRaw(this, &FAvalonModule::AddMenuEntry) + ); + ToolbarExtender->AddToolBarExtension( + "Settings", + EExtensionHook::After, + NULL, + FToolBarExtensionDelegate::CreateRaw(this, &FAvalonModule::AddToobarEntry)); + + + LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); + LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender); + +} + +void FAvalonModule::ShutdownModule() +{ + FAvalonStyle::Shutdown(); +} + + +void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +{ + // Create Section + MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); + { + // Create a Submenu inside of the Section + MenuBuilder.AddMenuEntry( + FText::FromString("Tools..."), + FText::FromString("Pipeline tools"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup)) + ); + + MenuBuilder.AddMenuEntry( + FText::FromString("Tools dialog..."), + FText::FromString("Pipeline tools dialog"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuDialog)) + ); + + } + MenuBuilder.EndSection(); +} + +void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) +{ + ToolbarBuilder.BeginSection(TEXT("OpenPype")); + { + ToolbarBuilder.AddToolBarButton( + FUIAction( + FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup), + NULL, + FIsActionChecked() + + ), + NAME_None, + LOCTEXT("OpenPype_label", "OpenPype"), + LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), + FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo") + ); + } + ToolbarBuilder.EndSection(); +} + + +void FAvalonModule::MenuPopup() { + UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); + bridge->RunInPython_Popup(); +} + +void FAvalonModule::MenuDialog() { + UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); + bridge->RunInPython_Dialog(); +} + +IMPLEMENT_MODULE(FAvalonModule, Avalon) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp new file mode 100644 index 0000000000..312656424c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp @@ -0,0 +1,48 @@ +#include "AvalonLib.h" +#include "Misc/Paths.h" +#include "Misc/ConfigCacheIni.h" +#include "UObject/UnrealType.h" + +/** + * Sets color on folder icon on given path + * @param InPath - path to folder + * @param InFolderColor - color of the folder + * @warning This color will appear only after Editor restart. Is there a better way? + */ + +void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +{ + auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) + { + // Saves the color of the folder to the config + if (FPaths::FileExists(GEditorPerProjectIni)) + { + GConfig->SetString(TEXT("PathColor"), *InPath, *InFolderColor.ToString(), GEditorPerProjectIni); + } + + }; + + SaveColorInternal(FolderPath, FolderColor); + +} +/** + * Returns all poperties on given object + * @param cls - class + * @return TArray of properties + */ +TArray UAvalonLib::GetAllProperties(UClass* cls) +{ + TArray Ret; + if (cls != nullptr) + { + for (TFieldIterator It(cls); It; ++It) + { + FProperty* Property = *It; + if (Property->HasAnyPropertyFlags(EPropertyFlags::CPF_Edit)) + { + Ret.Add(Property->GetName()); + } + } + } + return Ret; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp new file mode 100644 index 0000000000..2bb31a4853 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp @@ -0,0 +1,108 @@ +#pragma once + +#include "AvalonPublishInstance.h" +#include "AssetRegistryModule.h" + + +UAvalonPublishInstance::UAvalonPublishInstance(const FObjectInitializer& ObjectInitializer) + : UObject(ObjectInitializer) +{ + FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); + FString path = UAvalonPublishInstance::GetPathName(); + FARFilter Filter; + Filter.PackagePaths.Add(FName(*path)); + + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAvalonPublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAvalonPublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAvalonPublishInstance::OnAssetRenamed); +} + +void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AvalonPublishInstance") + { + assets.Add(assetPath); + UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); + } + } +} + +void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + + // take interest only in paths starting with path of current container + FString path = UAvalonPublishInstance::GetPathName(); + FString lpp = FPackageName::GetLongPackagePath(*path); + + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AvalonPublishInstance") + { + // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); + assets.Remove(assetPath); + } + } +} + +void UAvalonPublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +{ + TArray split; + + // get directory of current container + FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); + + // get asset path and class + FString assetPath = AssetData.GetFullName(); + FString assetFName = AssetData.AssetClass.ToString(); + + // split path + assetPath.ParseIntoArray(split, TEXT(" "), true); + + FString assetDir = FPackageName::GetLongPackagePath(*split[1]); + if (assetDir.StartsWith(*selfDir)) + { + // exclude self + if (assetFName != "AssetContainer") + { + + assets.Remove(str); + assets.Add(assetPath); + // UE_LOG(LogTemp, Warning, TEXT("%s: asset renamed %s"), *lpp, *str); + } + } +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp new file mode 100644 index 0000000000..e14a14f1e5 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "AvalonPublishInstanceFactory.h" +#include "AvalonPublishInstance.h" + +UAvalonPublishInstanceFactory::UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UAvalonPublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UAvalonPublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UAvalonPublishInstance* AvalonPublishInstance = NewObject(InParent, Class, Name, Flags); + return AvalonPublishInstance; +} + +bool UAvalonPublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp new file mode 100644 index 0000000000..8642ab6b63 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp @@ -0,0 +1,13 @@ +#include "AvalonPythonBridge.h" + +UAvalonPythonBridge* UAvalonPythonBridge::Get() +{ + TArray AvalonPythonBridgeClasses; + GetDerivedClasses(UAvalonPythonBridge::StaticClass(), AvalonPythonBridgeClasses); + int32 NumClasses = AvalonPythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp new file mode 100644 index 0000000000..5b3d1269b0 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp @@ -0,0 +1,69 @@ +#include "AvalonStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + + +TUniquePtr< FSlateStyleSet > FAvalonStyle::AvalonStyleInstance = nullptr; + +void FAvalonStyle::Initialize() +{ + if (!AvalonStyleInstance.IsValid()) + { + AvalonStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*AvalonStyleInstance); + } +} + +void FAvalonStyle::Shutdown() +{ + if (AvalonStyleInstance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*AvalonStyleInstance); + AvalonStyleInstance.Reset(); + } +} + +FName FAvalonStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("AvalonStyle")); + return StyleSetName; +} + +FName FAvalonStyle::GetContextName() +{ + static FName ContextName(TEXT("OpenPype")); + return ContextName; +} + +#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) + +const FVector2D Icon40x40(40.0f, 40.0f); + +TUniquePtr< FSlateStyleSet > FAvalonStyle::Create() +{ + TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); + Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("Avalon/Resources")); + + return Style; +} + +void FAvalonStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +{ + FSlateStyleSet* Style = AvalonStyleInstance.Get(); + + FString Name(GetContextName().ToString()); + Name = Name + "." + StyleName; + Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); + + + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); +} + +#undef IMAGE_BRUSH + +const ISlateStyle& FAvalonStyle::Get() +{ + check(AvalonStyleInstance); + return *AvalonStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h new file mode 100644 index 0000000000..1195f95cba --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h @@ -0,0 +1,39 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "UObject/NoExportTypes.h" +#include "Engine/AssetUserData.h" +#include "AssetData.h" +#include "AssetContainer.generated.h" + +/** + * + */ +UCLASS(Blueprintable) +class AVALON_API UAssetContainer : public UAssetUserData +{ + GENERATED_BODY() + +public: + + UAssetContainer(const FObjectInitializer& ObjectInitalizer); + // ~UAssetContainer(); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; + + // There seems to be no reflection option to expose array of FAssetData + /* + UPROPERTY(Transient, BlueprintReadOnly, Category = "Python", meta=(DisplayName="Assets Data")) + TArray assetsData; + */ +private: + TArray assetsData; + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; + + diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h new file mode 100644 index 0000000000..62b6e73640 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h @@ -0,0 +1,21 @@ +// Fill out your copyright notice in the Description page of Project Settings. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AssetContainerFactory.generated.h" + +/** + * + */ +UCLASS() +class AVALON_API UAssetContainerFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAssetContainerFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h new file mode 100644 index 0000000000..2dd6a825ab --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h @@ -0,0 +1,21 @@ +// Copyright 1998-2019 Epic Games, Inc. All Rights Reserved. + +#pragma once + +#include "Engine.h" + + +class FAvalonModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; + +private: + + void AddMenuEntry(FMenuBuilder& MenuBuilder); + void AddToobarEntry(FToolBarBuilder& ToolbarBuilder); + void MenuPopup(); + void MenuDialog(); + +}; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h new file mode 100644 index 0000000000..da3369970c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h @@ -0,0 +1,19 @@ +#pragma once + +#include "Engine.h" +#include "AvalonLib.generated.h" + + +UCLASS(Blueprintable) +class AVALON_API UAvalonLib : public UObject +{ + + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static void CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd); + + UFUNCTION(BlueprintCallable, Category = Python) + static TArray GetAllProperties(UClass* cls); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h new file mode 100644 index 0000000000..7678f78924 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h @@ -0,0 +1,21 @@ +#pragma once + +#include "Engine.h" +#include "AvalonPublishInstance.generated.h" + + +UCLASS(Blueprintable) +class AVALON_API UAvalonPublishInstance : public UObject +{ + GENERATED_BODY() + +public: + UAvalonPublishInstance(const FObjectInitializer& ObjectInitalizer); + + UPROPERTY(EditAnywhere, BlueprintReadOnly) + TArray assets; +private: + void OnAssetAdded(const FAssetData& AssetData); + void OnAssetRemoved(const FAssetData& AssetData); + void OnAssetRenamed(const FAssetData& AssetData, const FString& str); +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h new file mode 100644 index 0000000000..79e781c60c --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h @@ -0,0 +1,19 @@ +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "AvalonPublishInstanceFactory.generated.h" + +/** + * + */ +UCLASS() +class AVALON_API UAvalonPublishInstanceFactory : public UFactory +{ + GENERATED_BODY() + +public: + UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual bool ShouldShowInNewMenu() const override; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h new file mode 100644 index 0000000000..db4b16d53f --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h @@ -0,0 +1,20 @@ +#pragma once +#include "Engine.h" +#include "AvalonPythonBridge.generated.h" + +UCLASS(Blueprintable) +class UAvalonPythonBridge : public UObject +{ + GENERATED_BODY() + +public: + UFUNCTION(BlueprintCallable, Category = Python) + static UAvalonPythonBridge* Get(); + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Popup() const; + + UFUNCTION(BlueprintImplementableEvent, Category = Python) + void RunInPython_Dialog() const; + +}; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h new file mode 100644 index 0000000000..ffb2bc7aa4 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h @@ -0,0 +1,22 @@ +#pragma once +#include "CoreMinimal.h" + +class FSlateStyleSet; +class ISlateStyle; + + +class FAvalonStyle +{ +public: + static void Initialize(); + static void Shutdown(); + static const ISlateStyle& Get(); + static FName GetStyleSetName(); + static FName GetContextName(); + + static void SetIcon(const FString& StyleName, const FString& ResourcePath); + +private: + static TUniquePtr< FSlateStyleSet > Create(); + static TUniquePtr< FSlateStyleSet > AvalonStyleInstance; +}; \ No newline at end of file From 70537ecfd441d30d12df3ecde4b4f2fba0b14337 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 19:00:50 +0100 Subject: [PATCH 076/152] moved io install into host api --- openpype/hosts/traypublisher/api/pipeline.py | 7 +++---- openpype/tools/traypublisher/window.py | 5 ----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py index 83fe326ca4..a39e5641ae 100644 --- a/openpype/hosts/traypublisher/api/pipeline.py +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -3,6 +3,7 @@ import json import tempfile import atexit +from avalon import io import avalon.api import pyblish.api @@ -172,10 +173,8 @@ def install(): def set_project_name(project_name): - # Deregister project specific plugins and register new project plugins - old_project_name = HostContext.get_project_name() - if old_project_name is not None and old_project_name != project_name: - pass + # TODO Deregister project specific plugins and register new project plugins os.environ["AVALON_PROJECT"] = project_name avalon.api.Session["AVALON_PROJECT"] = project_name + io.install() HostContext.set_project_name(project_name) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index 34ba042e91..fc9493be0a 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -6,11 +6,9 @@ Tray publisher can be considered as host implementeation with creators and publishing plugins. """ -import os from Qt import QtWidgets, QtCore import avalon.api -from avalon import io from avalon.api import AvalonMongoDB from openpype.hosts.traypublisher import ( api as traypublisher @@ -143,9 +141,6 @@ class TrayPublishWindow(PublisherWindow): # TODO register project specific plugin paths self.controller.save_changes() self.controller.reset_project_data_cache() - os.environ["AVALON_PROJECT"] = project_name - io.Session["AVALON_PROJECT"] = project_name - io.install() self.reset() if not self.controller.instances: From f69a9055bd393cf3956018d8a9d3382b52763b68 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 19:01:25 +0100 Subject: [PATCH 077/152] changed icon of workfile creator --- openpype/hosts/traypublisher/plugins/create/create_workfile.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py index 38b25ea3c6..2db4770bbc 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_workfile.py +++ b/openpype/hosts/traypublisher/plugins/create/create_workfile.py @@ -1,4 +1,3 @@ -from openpype import resources from openpype.hosts.traypublisher.api import pipeline from openpype.pipeline import ( Creator, @@ -45,7 +44,7 @@ class WorkfileCreator(Creator): ] def get_icon(self): - return resources.get_openpype_splash_filepath() + return "fa.file" def collect_instances(self): for instance_data in pipeline.list_instances(): From 5bbfca8dc5c9dfe178a60336300c5681ab1bc5c9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Feb 2022 19:32:03 +0100 Subject: [PATCH 078/152] hound fixes --- openpype/tools/publisher/publish_report_viewer/widgets.py | 1 + openpype/tools/publisher/publish_report_viewer/window.py | 4 ++-- openpype/tools/traypublisher/window.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index 0b17efb614..fd226ea0e4 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -253,6 +253,7 @@ class DetailsPopup(QtWidgets.QDialog): self._center_widget = center_widget self._first_show = True + self._layout = layout def showEvent(self, event): layout = self.layout() diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py index 8ca075e4d2..678884677c 100644 --- a/openpype/tools/publisher/publish_report_viewer/window.py +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -123,12 +123,12 @@ class LoadedFilesMopdel(QtGui.QStandardItemModel): return new_items = [] - for filepath in filtered_paths: + for normalized_path in filtered_paths: try: with open(normalized_path, "r") as stream: data = json.load(stream) report = PublishReport(data) - except Exception as exc: + except Exception: # TODO handle errors continue diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index fc9493be0a..53f8ca450a 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -13,7 +13,6 @@ from avalon.api import AvalonMongoDB from openpype.hosts.traypublisher import ( api as traypublisher ) -from openpype.hosts.traypublisher.api.pipeline import HostContext from openpype.tools.publisher import PublisherWindow from openpype.tools.utils.constants import PROJECT_NAME_ROLE from openpype.tools.utils.models import ( From fe4a15dc4faeb3627bbbc38cc294fc9ef36feb8c Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Mon, 21 Feb 2022 21:14:59 +0100 Subject: [PATCH 079/152] Remove unused code --- openpype/hosts/houdini/api/__init__.py | 6 +- openpype/hosts/houdini/api/lib.py | 123 ------------------------- 2 files changed, 2 insertions(+), 127 deletions(-) diff --git a/openpype/hosts/houdini/api/__init__.py b/openpype/hosts/houdini/api/__init__.py index e1500aa5f5..fddf7ab98d 100644 --- a/openpype/hosts/houdini/api/__init__.py +++ b/openpype/hosts/houdini/api/__init__.py @@ -24,8 +24,7 @@ from .lib import ( lsattrs, read, - maintained_selection, - unique_name + maintained_selection ) @@ -51,8 +50,7 @@ __all__ = [ "lsattrs", "read", - "maintained_selection", - "unique_name" + "maintained_selection" ] # Backwards API compatibility diff --git a/openpype/hosts/houdini/api/lib.py b/openpype/hosts/houdini/api/lib.py index 72f1c8e71f..6212e721b3 100644 --- a/openpype/hosts/houdini/api/lib.py +++ b/openpype/hosts/houdini/api/lib.py @@ -99,65 +99,6 @@ def get_id_required_nodes(): return list(nodes) -def get_additional_data(container): - """Not implemented yet!""" - return container - - -def set_parameter_callback(node, parameter, language, callback): - """Link a callback to a parameter of a node - - Args: - node(hou.Node): instance of the nodee - parameter(str): name of the parameter - language(str): name of the language, e.g.: python - callback(str): command which needs to be triggered - - Returns: - None - - """ - - template_grp = node.parmTemplateGroup() - template = template_grp.find(parameter) - if not template: - return - - script_language = (hou.scriptLanguage.Python if language == "python" else - hou.scriptLanguage.Hscript) - - template.setScriptCallbackLanguage(script_language) - template.setScriptCallback(callback) - - template.setTags({"script_callback": callback, - "script_callback_language": language.lower()}) - - # Replace the existing template with the adjusted one - template_grp.replace(parameter, template) - - node.setParmTemplateGroup(template_grp) - - -def set_parameter_callbacks(node, parameter_callbacks): - """Set callbacks for multiple parameters of a node - - Args: - node(hou.Node): instance of a hou.Node - parameter_callbacks(dict): collection of parameter and callback data - example: {"active" : - {"language": "python", - "callback": "print('hello world)'"} - } - Returns: - None - """ - for parameter, data in parameter_callbacks.items(): - language = data["language"] - callback = data["callback"] - - set_parameter_callback(node, parameter, language, callback) - - def get_output_parameter(node): """Return the render output parameter name of the given node @@ -189,19 +130,6 @@ def get_output_parameter(node): raise TypeError("Node type '%s' not supported" % node_type) -@contextmanager -def attribute_values(node, data): - - previous_attrs = {key: node.parm(key).eval() for key in data.keys()} - try: - node.setParms(data) - yield - except Exception as exc: - pass - finally: - node.setParms(previous_attrs) - - def set_scene_fps(fps): hou.setFps(fps) @@ -349,10 +277,6 @@ def render_rop(ropnode): raise RuntimeError("Render failed: {0}".format(exc)) -def children_as_string(node): - return [c.name() for c in node.children()] - - def imprint(node, data): """Store attributes with value on a node @@ -473,53 +397,6 @@ def read(node): parameter in node.spareParms()} -def unique_name(name, format="%03d", namespace="", prefix="", suffix="", - separator="_"): - """Return unique `name` - - The function takes into consideration an optional `namespace` - and `suffix`. The suffix is included in evaluating whether a - name exists - such as `name` + "_GRP" - but isn't included - in the returned value. - - If a namespace is provided, only names within that namespace - are considered when evaluating whether the name is unique. - - Arguments: - format (str, optional): The `name` is given a number, this determines - how this number is formatted. Defaults to a padding of 2. - E.g. my_name01, my_name02. - namespace (str, optional): Only consider names within this namespace. - suffix (str, optional): Only consider names with this suffix. - - Example: - >>> name = hou.node("/obj").createNode("geo", name="MyName") - >>> assert hou.node("/obj/MyName") - True - >>> unique = unique_name(name) - >>> assert hou.node("/obj/{}".format(unique)) - False - - """ - - iteration = 1 - - parts = [prefix, name, format % iteration, suffix] - if namespace: - parts.insert(0, namespace) - - unique = separator.join(parts) - children = children_as_string(hou.node("/obj")) - while unique in children: - iteration += 1 - unique = separator.join(parts) - - if suffix: - return unique[:-len(suffix)] - - return unique - - @contextmanager def maintained_selection(): """Maintain selection during context From a931353b793c47ccf2fa9c4e6222c95e9ab48b4b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 22 Feb 2022 12:43:30 +0100 Subject: [PATCH 080/152] Refactor - removed requirements and build from dev section Flattened dev category --- website/docs/dev_introduction.md | 8 ++++++-- website/sidebars.js | 12 ++---------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/website/docs/dev_introduction.md b/website/docs/dev_introduction.md index 22a23fc523..af17f30692 100644 --- a/website/docs/dev_introduction.md +++ b/website/docs/dev_introduction.md @@ -5,6 +5,10 @@ sidebar_label: Introduction --- -Here you should find additional information targetted on developers who would like to contribute or dive deeper into OpenPype platform +Here you should find additional information targeted on developers who would like to contribute or dive deeper into OpenPype platform -Currently there are details about automatic testing, in the future this should be location for API definition and documentation \ No newline at end of file +Currently there are details about automatic testing, in the future this should be location for API definition and documentation + +Check also: +- [Requirements](dev_requirements.md) +- [Build](dev_build.md) \ No newline at end of file diff --git a/website/sidebars.js b/website/sidebars.js index d819796991..f1b77871f3 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -134,15 +134,7 @@ module.exports = { ], Dev: [ "dev_introduction", - { - type: "category", - label: "Dev documentation", - items: [ - "dev_requirements", - "dev_build", - "dev_testing", - "dev_contribute", - ], - } + "dev_testing", + "dev_contribute" ] }; From 2da1f5e1e46f35e7d0f8383b537247ff403844d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 11:47:02 +0100 Subject: [PATCH 081/152] Uset task ids from asset versions before tasks are removed --- .../action_delete_asset.py | 24 ++++++++++++--- .../default_modules/ftrack/lib/avalon_sync.py | 30 ++++++++++++++++--- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py index 676dd80e93..94385a36c5 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_delete_asset.py @@ -3,8 +3,9 @@ import uuid from datetime import datetime from bson.objectid import ObjectId -from openpype_modules.ftrack.lib import BaseAction, statics_icon from avalon.api import AvalonMongoDB +from openpype_modules.ftrack.lib import BaseAction, statics_icon +from openpype_modules.ftrack.lib.avalon_sync import create_chunks class DeleteAssetSubset(BaseAction): @@ -554,8 +555,8 @@ class DeleteAssetSubset(BaseAction): ftrack_proc_txt, ", ".join(ftrack_ids_to_delete) )) - entities_by_link_len = ( - self._filter_entities_to_delete(ftrack_ids_to_delete, session) + entities_by_link_len = self._prepare_entities_before_delete( + ftrack_ids_to_delete, session ) for link_len in sorted(entities_by_link_len.keys(), reverse=True): for entity in entities_by_link_len[link_len]: @@ -609,7 +610,7 @@ class DeleteAssetSubset(BaseAction): return self.report_handle(report_messages, project_name, event) - def _filter_entities_to_delete(self, ftrack_ids_to_delete, session): + def _prepare_entities_before_delete(self, ftrack_ids_to_delete, session): """Filter children entities to avoid CircularDependencyError.""" joined_ids_to_delete = ", ".join( ["\"{}\"".format(id) for id in ftrack_ids_to_delete] @@ -638,6 +639,21 @@ class DeleteAssetSubset(BaseAction): parent_ids_to_delete.append(entity["id"]) to_delete_entities.append(entity) + # Unset 'task_id' from AssetVersion entities + # - when task is deleted the asset version is not marked for deletion + task_ids = set( + entity["id"] + for entity in to_delete_entities + if entity.entity_type.lower() == "task" + ) + for chunk in create_chunks(task_ids): + asset_versions = session.query(( + "select id, task_id from AssetVersion where task_id in ({})" + ).format(self.join_query_keys(chunk))).all() + for asset_version in asset_versions: + asset_version["task_id"] = None + session.commit() + entities_by_link_len = collections.defaultdict(list) for entity in to_delete_entities: entities_by_link_len[len(entity["link"])].append(entity) diff --git a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py index 06e8784287..db7c592c9b 100644 --- a/openpype/modules/default_modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/default_modules/ftrack/lib/avalon_sync.py @@ -33,6 +33,30 @@ CURRENT_DOC_SCHEMAS = { } +def create_chunks(iterable, chunk_size=None): + """Separate iterable into multiple chunks by size. + + Args: + iterable(list|tuple|set): Object that will be separated into chunks. + chunk_size(int): Size of one chunk. Default value is 200. + + Returns: + list: Chunked items. + """ + chunks = [] + if not iterable: + return chunks + + tupled_iterable = tuple(iterable) + iterable_size = len(tupled_iterable) + if chunk_size is None: + chunk_size = 200 + + for idx in range(0, iterable_size, chunk_size): + chunks.append(tupled_iterable[idx:idx + chunk_size]) + return chunks + + def check_regex(name, entity_type, in_schema=None, schema_patterns=None): schema_name = "asset-3.0" if in_schema: @@ -1147,10 +1171,8 @@ class SyncEntitiesFactory: ids_len = len(tupled_ids) chunk_size = int(5000 / ids_len) all_links = [] - for idx in range(0, ids_len, chunk_size): - entity_ids_joined = join_query_keys( - tupled_ids[idx:idx + chunk_size] - ) + for chunk in create_chunks(ftrack_ids, chunk_size): + entity_ids_joined = join_query_keys(chunk) all_links.extend(self.session.query(( "select from_id, to_id from" From 2c876c862cba739a106b96fed903d49cc32770d1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 14:04:11 +0100 Subject: [PATCH 082/152] Remove unused import --- .../plugins/publish/validate_expected_and_rendered_files.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index e0c1f14e7c..c0c7ffbbcf 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -1,5 +1,4 @@ import os -import json import requests import pyblish.api From 3a1b09ccce0e70bea58bff313ec03c0db52f9f1d Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 14:51:24 +0100 Subject: [PATCH 083/152] Also allow 'sequences' with a single frame --- openpype/lib/delivery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/delivery.py b/openpype/lib/delivery.py index 01fcc907ed..a61603fa05 100644 --- a/openpype/lib/delivery.py +++ b/openpype/lib/delivery.py @@ -17,7 +17,7 @@ def collect_frames(files): Returns: (dict): {'/asset/subset_v001.0001.png': '0001', ....} """ - collections, remainder = clique.assemble(files) + collections, remainder = clique.assemble(files, minimum_items=1) sources_and_frames = {} if collections: From 239de9061446072988b401dce49ef350dbeee6e5 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Feb 2022 14:56:11 +0100 Subject: [PATCH 084/152] Better logging for case where frame might not be captured from source filename --- .../publish/validate_expected_and_rendered_files.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py index c0c7ffbbcf..d49e314179 100644 --- a/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py +++ b/openpype/modules/deadline/plugins/publish/validate_expected_and_rendered_files.py @@ -127,6 +127,14 @@ class ValidateExpectedFiles(pyblish.api.InstancePlugin): file_name_template = frame_placeholder = None for file_name, frame in sources_and_frames.items(): + + # There might be cases where clique was unable to collect + # collections in `collect_frames` - thus we capture that case + if frame is None: + self.log.warning("Unable to detect frame from filename: " + "{}".format(file_name)) + continue + frame_placeholder = "#" * len(frame) file_name_template = os.path.basename( file_name.replace(frame, frame_placeholder)) From fc4b7e45737afb8b26907e0f21c6e587ab819756 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 16:12:54 +0100 Subject: [PATCH 085/152] set context environments even for non host applications --- openpype/hooks/pre_global_host_data.py | 11 ++--------- openpype/lib/applications.py | 4 +++- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index bae967e25f..9b82e36171 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -14,14 +14,6 @@ class GlobalHostDataHook(PreLaunchHook): def execute(self): """Prepare global objects to `data` that will be used for sure.""" - if not self.application.is_host: - self.log.info( - "Skipped hook {}. Application is not marked as host.".format( - self.__class__.__name__ - ) - ) - return - self.prepare_global_data() if not self.data.get("asset_doc"): @@ -49,7 +41,8 @@ class GlobalHostDataHook(PreLaunchHook): "log": self.log }) - prepare_host_environments(temp_data, self.launch_context.env_group) + if app.is_host: + prepare_host_environments(temp_data, self.launch_context.env_group) prepare_context_environments(temp_data) temp_data.pop("log") diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 393c83e9be..30e671cfad 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1508,10 +1508,12 @@ def prepare_context_environments(data, env_group=None): "AVALON_PROJECT": project_doc["name"], "AVALON_ASSET": asset_doc["name"], "AVALON_TASK": task_name, - "AVALON_APP": app.host_name, "AVALON_APP_NAME": app.full_name, "AVALON_WORKDIR": workdir } + if app.is_host: + context_env["AVALON_APP"]: app.host_name + log.debug( "Context environments set:\n{}".format( json.dumps(context_env, indent=4) From bc86cd279c40933c6f9ed84a5724bd2997df1196 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 18:47:09 +0100 Subject: [PATCH 086/152] showing report does not use publishing logic --- openpype/tools/publisher/control.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 04158ad05e..5a84b1d8ca 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -184,11 +184,21 @@ class PublishReport: self._stored_plugins.append(plugin) + plugin_data_item = self._create_plugin_data_item(plugin) + + self._plugin_data_with_plugin.append({ + "plugin": plugin, + "data": plugin_data_item + }) + self._plugin_data.append(plugin_data_item) + return plugin_data_item + + def _create_plugin_data_item(self, plugin): label = None if hasattr(plugin, "label"): label = plugin.label - plugin_data_item = { + return { "name": plugin.__name__, "label": label, "order": plugin.order, @@ -197,12 +207,6 @@ class PublishReport: "skipped": False, "passed": False } - self._plugin_data_with_plugin.append({ - "plugin": plugin, - "data": plugin_data_item - }) - self._plugin_data.append(plugin_data_item) - return plugin_data_item def set_plugin_skipped(self): """Set that current plugin has been skipped.""" @@ -252,7 +256,7 @@ class PublishReport: if publish_plugins: for plugin in publish_plugins: if plugin not in self._stored_plugins: - plugins_data.append(self._add_plugin_data_item(plugin)) + plugins_data.append(self._create_plugin_data_item(plugin)) crashed_file_paths = {} if self._publish_discover_result is not None: From ca82674145885b4ba432a8addc3ca5375220fd8a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Feb 2022 18:47:25 +0100 Subject: [PATCH 087/152] fix multipath result --- openpype/widgets/attribute_defs/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 5aa76d8754..87b98e2378 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -433,7 +433,7 @@ class MultiFilesWidget(QtWidgets.QFrame): filenames = index.data(FILENAMES_ROLE) for filename in filenames: filepaths.add(os.path.join(dirpath, filename)) - return filepaths + return list(filepaths) def set_filters(self, folders_allowed, exts_filter): self._files_proxy_model.set_allow_folders(folders_allowed) From d167439969646ba55ba18b4b9538d7cda3b8eb0d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:14:13 +0100 Subject: [PATCH 088/152] flame: hook Flame to ftrack, rename to babypublisher --- .../export_preset/openpype_seg_thumbnails_jpg.xml | 0 .../export_preset/openpype_seg_video_h264.xml | 0 .../modules/__init__.py | 0 .../modules/app_utils.py | 0 .../modules/ftrack_lib.py | 0 .../modules/panel_app.py | 2 +- .../modules/uiwidgets.py | 0 .../openpype_babypublisher.py} | 2 +- 8 files changed, 2 insertions(+), 2 deletions(-) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/export_preset/openpype_seg_thumbnails_jpg.xml (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/export_preset/openpype_seg_video_h264.xml (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/__init__.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/app_utils.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/ftrack_lib.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/panel_app.py (99%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack => openpype_babypublisher}/modules/uiwidgets.py (100%) rename openpype/hosts/flame/startup/{openpype_flame_to_ftrack/openpype_flame_to_ftrack.py => openpype_babypublisher/openpype_babypublisher.py} (95%) diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_thumbnails_jpg.xml rename to openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/export_preset/openpype_seg_video_h264.xml rename to openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/__init__.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/__init__.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/__init__.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/__init__.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/app_utils.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/app_utils.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/ftrack_lib.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py similarity index 99% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 648f902872..4f14f0c28a 100644 --- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -78,7 +78,7 @@ class FlameToFtrackPanel(object): # creating ui self.window.setMinimumSize(1500, 600) - self.window.setWindowTitle('Sequence Shots to Ftrack') + self.window.setWindowTitle('OpenPype: Baby-publisher') self.window.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) self.window.setAttribute(QtCore.Qt.WA_DeleteOnClose) self.window.setFocusPolicy(QtCore.Qt.StrongFocus) diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/uiwidgets.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py similarity index 100% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/modules/uiwidgets.py rename to openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py diff --git a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py similarity index 95% rename from openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py rename to openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py index 5a72706ba1..fc69f75866 100644 --- a/openpype/hosts/flame/startup/openpype_flame_to_ftrack/openpype_flame_to_ftrack.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py @@ -30,7 +30,7 @@ def scope_sequence(selection): def get_media_panel_custom_ui_actions(): return [ { - "name": "OpenPype: Ftrack", + "name": "OpenPype: Baby-publisher", "actions": [ { "name": "Create Shots", From 193541ba04d6f2687bd3b5854dd70df5c4827931 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:16:19 +0100 Subject: [PATCH 089/152] Flame: get shot name from sequnce.shot_name attribute --- .../startup/openpype_babypublisher/modules/panel_app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 4f14f0c28a..a2093ec271 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -492,11 +492,11 @@ class FlameToFtrackPanel(object): # Add timeline segment to tree QtWidgets.QTreeWidgetItem(self.tree, [ - str(sequence.name)[1:-1], # seq - str(segment.name)[1:-1], # shot + sequence.name.get_value(), # seq name + segment.shot_name.get_value(), # shot name str(clip_duration), # clip duration shot_description, # shot description - str(segment.comment)[1:-1] # task description + segment.comment.get_value() # task description ]).setFlags( QtCore.Qt.ItemIsEditable | QtCore.Qt.ItemIsEnabled From 2ea402faad359fe5ff7d03a429e10d929ed91fce Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:26:05 +0100 Subject: [PATCH 090/152] Flame: rename to babypublisher a user settings namespace --- .../flame/startup/openpype_babypublisher/modules/app_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py index b255d8d3f5..e639c3f482 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/app_utils.py @@ -8,7 +8,7 @@ PLUGIN_DIR = os.path.dirname(os.path.dirname(__file__)) EXPORT_PRESETS_DIR = os.path.join(PLUGIN_DIR, "export_preset") CONFIG_DIR = os.path.join(os.path.expanduser( - "~/.openpype"), "openpype_flame_to_ftrack") + "~/.openpype"), "openpype_babypublisher") @contextmanager From bbb653a54d05ce0d547ec8c5ce3c65c3fdcf5665 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:26:51 +0100 Subject: [PATCH 091/152] flame: rename to babypublisher panel class --- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 2 +- .../startup/openpype_babypublisher/openpype_babypublisher.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index a2093ec271..4cf5b9923f 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -37,7 +37,7 @@ class MainWindow(QtWidgets.QWidget): event.accept() -class FlameToFtrackPanel(object): +class FlameBabyPublisherPanel(object): session = None temp_data_dir = None processed_components = [] diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py index fc69f75866..839b38c510 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py @@ -19,7 +19,7 @@ def flame_panel_executor(selection): print("panel_app module removed from sys.modules") import panel_app - panel_app.FlameToFtrackPanel(selection) + panel_app.FlameBabyPublisherPanel(selection) def scope_sequence(selection): From 51ffb9f3a0dd5c98f036316b56182505b72a0a6a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 20:27:15 +0100 Subject: [PATCH 092/152] flame: babypublisher use Qt.py --- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 2 +- .../flame/startup/openpype_babypublisher/modules/uiwidgets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 4cf5b9923f..bcb98c8afd 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -1,4 +1,4 @@ -from PySide2 import QtWidgets, QtCore +from Qt import QtWidgets, QtCore import uiwidgets import app_utils diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py index 0d4807a4ea..c6db875df0 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/uiwidgets.py @@ -1,4 +1,4 @@ -from PySide2 import QtWidgets, QtCore +from Qt import QtWidgets, QtCore class FlameLabel(QtWidgets.QLabel): From c298c39ba8008b21634d33a4e76df624c44340df Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:15:37 +0100 Subject: [PATCH 093/152] flame: babypublisher swap segment name for shot name in presets --- .../export_preset/openpype_seg_thumbnails_jpg.xml | 2 +- .../export_preset/openpype_seg_video_h264.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml index fa43ceece7..44a7bd9770 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml +++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_thumbnails_jpg.xml @@ -29,7 +29,7 @@ Jpeg 923688 - <segment name> + <shot name> 100 2 4 diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml index 3ca185b8b4..e3c6ab90ae 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml +++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml @@ -27,7 +27,7 @@ QuickTime - <segment name> + <shot name> 0 PCS_709 None From dcb9d97703db793f060630029cad60c79c176184 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:15:49 +0100 Subject: [PATCH 094/152] flame: improving code --- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index bcb98c8afd..469826be50 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -472,10 +472,10 @@ class FlameBabyPublisherPanel(object): for tracks in ver.tracks: for segment in tracks.segments: print(segment.attributes) - if str(segment.name)[1:-1] == "": + if segment.name.get_value() == "": continue # get clip frame duration - record_duration = str(segment.record_duration)[1:-1] + record_duration = segment.record_duration.get_value() clip_duration = app_utils.timecode_to_frames( record_duration, frame_rate) From e36fe36cdccf20406fe8deaf2135e2d2db5a1af4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:16:05 +0100 Subject: [PATCH 095/152] flame: fixing reloading modules --- .../startup/openpype_babypublisher/openpype_babypublisher.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py index 839b38c510..4675d163e3 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/openpype_babypublisher.py @@ -16,9 +16,10 @@ def flame_panel_executor(selection): if "panel_app" in sys.modules.keys(): print("panel_app module is already loaded") del sys.modules["panel_app"] + import panel_app + reload(panel_app) # noqa print("panel_app module removed from sys.modules") - import panel_app panel_app.FlameBabyPublisherPanel(selection) From bac0e287a4d34aa4b02b00a1f6b2d3c58823eb55 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:41:08 +0100 Subject: [PATCH 096/152] flame: filter out segments and tracks which are empty and hidden --- .../openpype_babypublisher/modules/panel_app.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index 469826be50..e087549dc4 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -469,13 +469,17 @@ class FlameBabyPublisherPanel(object): for sequence in self.selection: frame_rate = float(str(sequence.frame_rate)[:-4]) for ver in sequence.versions: - for tracks in ver.tracks: - for segment in tracks.segments: + for track in ver.tracks: + if len(track.segments) == 0 and track.hidden: + continue + for segment in track.segments: print(segment.attributes) if segment.name.get_value() == "": continue + if segment.hidden.get_value() is True: + continue # get clip frame duration - record_duration = segment.record_duration.get_value() + record_duration = str(segment.record_duration)[1:-1] clip_duration = app_utils.timecode_to_frames( record_duration, frame_rate) From 2cfbe3282c1635d14b03ee6da8ee0ab88b2dd66d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Feb 2022 21:55:43 +0100 Subject: [PATCH 097/152] flame: fixing preset name for shot name --- .../export_preset/openpype_seg_video_h264.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml index e3c6ab90ae..1d2c5a28bb 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml +++ b/openpype/hosts/flame/startup/openpype_babypublisher/export_preset/openpype_seg_video_h264.xml @@ -43,7 +43,7 @@ 2021 /profiles/.33622016/HDTV_720p_8Mbits.cdxprof - <segment name>_<video codec> + <shot name>_<video codec> 50 2 4 From 040688ca62904847225b9ea8b0c108b28cab9fb2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 09:55:42 +0100 Subject: [PATCH 098/152] fixed app and context data fill --- openpype/hooks/pre_global_host_data.py | 5 ++-- openpype/lib/__init__.py | 4 +-- openpype/lib/applications.py | 38 ++++++++++++++------------ 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/openpype/hooks/pre_global_host_data.py b/openpype/hooks/pre_global_host_data.py index 9b82e36171..4c85a511ed 100644 --- a/openpype/hooks/pre_global_host_data.py +++ b/openpype/hooks/pre_global_host_data.py @@ -2,7 +2,7 @@ from openpype.api import Anatomy from openpype.lib import ( PreLaunchHook, EnvironmentPrepData, - prepare_host_environments, + prepare_app_environments, prepare_context_environments ) @@ -41,8 +41,7 @@ class GlobalHostDataHook(PreLaunchHook): "log": self.log }) - if app.is_host: - prepare_host_environments(temp_data, self.launch_context.env_group) + prepare_app_environments(temp_data, self.launch_context.env_group) prepare_context_environments(temp_data) temp_data.pop("log") diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index ebe7648ad7..f79c03ed57 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -130,7 +130,7 @@ from .applications import ( PostLaunchHook, EnvironmentPrepData, - prepare_host_environments, + prepare_app_environments, prepare_context_environments, get_app_environments_for_context, apply_project_environments_value @@ -261,7 +261,7 @@ __all__ = [ "PreLaunchHook", "PostLaunchHook", "EnvironmentPrepData", - "prepare_host_environments", + "prepare_app_environments", "prepare_context_environments", "get_app_environments_for_context", "apply_project_environments_value", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 30e671cfad..0b51a6629c 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1295,7 +1295,7 @@ def get_app_environments_for_context( "env": env }) - prepare_host_environments(data, env_group) + prepare_app_environments(data, env_group) prepare_context_environments(data, env_group) # Discard avalon connection @@ -1316,7 +1316,7 @@ def _merge_env(env, current_env): return result -def prepare_host_environments(data, env_group=None, implementation_envs=True): +def prepare_app_environments(data, env_group=None, implementation_envs=True): """Modify launch environments based on launched app and context. Args: @@ -1474,6 +1474,22 @@ def prepare_context_environments(data, env_group=None): ) app = data["app"] + context_env = { + "AVALON_PROJECT": project_doc["name"], + "AVALON_ASSET": asset_doc["name"], + "AVALON_TASK": task_name, + "AVALON_APP_NAME": app.full_name + } + + log.debug( + "Context environments set:\n{}".format( + json.dumps(context_env, indent=4) + ) + ) + data["env"].update(context_env) + if not app.is_host: + return + workdir_data = get_workdir_data( project_doc, asset_doc, task_name, app.host_name ) @@ -1504,22 +1520,8 @@ def prepare_context_environments(data, env_group=None): "Couldn't create workdir because: {}".format(str(exc)) ) - context_env = { - "AVALON_PROJECT": project_doc["name"], - "AVALON_ASSET": asset_doc["name"], - "AVALON_TASK": task_name, - "AVALON_APP_NAME": app.full_name, - "AVALON_WORKDIR": workdir - } - if app.is_host: - context_env["AVALON_APP"]: app.host_name - - log.debug( - "Context environments set:\n{}".format( - json.dumps(context_env, indent=4) - ) - ) - data["env"].update(context_env) + data["env"]["AVALON_APP"] = app.host_name + data["env"]["AVALON_WORKDIR"] = workdir _prepare_last_workfile(data, workdir) From e0670d34eb9a6779f959f5d908247928806afeea Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 11:42:07 +0100 Subject: [PATCH 099/152] flame: fixing getting already created entity --- .../openpype_babypublisher/modules/ftrack_lib.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index c2168016c6..90311a5ac5 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -389,13 +389,17 @@ class FtrackEntityOperator: return entity def get_ftrack_entity(self, session, type, name, parent): - query = '{} where name is "{}" and project_id is "{}"'.format( + query_no_parent = '{} where name is "{}" and project_id is "{}"'.format( type, name, self.project_entity["id"]) + query_with_parent = ( + '{} where name is "{}" and project_id is "{}" ' + 'and parent_id is {}').format( + type, name, self.project_entity["id"], parent["id"]) - try: - entity = session.query(query).one() - except Exception: - entity = None + entity = ( + session.query(query_no_parent).first() or + session.query(query_with_parent).first() + ) # if entity doesnt exist then create one if not entity: From 4e9131ccd8153cc3bb1f2aaa683c5819f55894f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 11:49:44 +0100 Subject: [PATCH 100/152] tray publisher can be enabled using loclal settings --- openpype/modules/interfaces.py | 2 + openpype/modules/traypublish_action.py | 15 +- .../defaults/system_settings/modules.json | 3 - .../schemas/system_schema/schema_modules.json | 14 -- openpype/tools/experimental_tools/dialog.py | 5 +- .../tools/experimental_tools/tools_def.py | 132 ++++++++++-------- .../local_settings/experimental_widget.py | 2 +- 7 files changed, 91 insertions(+), 82 deletions(-) diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 7c301c15b4..13cbea690b 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -122,6 +122,7 @@ class ITrayAction(ITrayModule): admin_action = False _admin_submenu = None + _action_item = None @property @abstractmethod @@ -149,6 +150,7 @@ class ITrayAction(ITrayModule): tray_menu.addAction(action) action.triggered.connect(self.on_action_trigger) + self._action_item = action def tray_start(self): return diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py index 039ce96206..033e24da88 100644 --- a/openpype/modules/traypublish_action.py +++ b/openpype/modules/traypublish_action.py @@ -11,7 +11,7 @@ class TrayPublishAction(OpenPypeModule, ITrayAction): def initialize(self, modules_settings): import openpype - self.enabled = modules_settings[self.name]["enabled"] + self.enabled = True self.publish_paths = [ os.path.join( openpype.PACKAGE_DIR, @@ -21,9 +21,20 @@ class TrayPublishAction(OpenPypeModule, ITrayAction): "publish" ) ] + self._experimental_tools = None def tray_init(self): - return + from openpype.tools.experimental_tools import ExperimentalTools + + self._experimental_tools = ExperimentalTools() + + def tray_menu(self, *args, **kwargs): + super(TrayPublishAction, self).tray_menu(*args, **kwargs) + traypublisher = self._experimental_tools.get("traypublisher") + visible = False + if traypublisher and traypublisher.enabled: + visible = True + self._action_item.setVisible(visible) def on_action_trigger(self): self.run_traypublisher() diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 70dc584360..d74269922f 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -191,9 +191,6 @@ "standalonepublish_tool": { "enabled": true }, - "traypublish_tool": { - "enabled": false - }, "project_manager": { "enabled": true }, diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 21c8163cea..52595914ed 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -233,20 +233,6 @@ } ] }, - { - "type": "dict", - "key": "traypublish_tool", - "label": "Tray Publish (beta)", - "collapsible": true, - "checkbox_key": "enabled", - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - } - ] - }, { "type": "dict", "key": "project_manager", diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py index 295afbe68d..0099492207 100644 --- a/openpype/tools/experimental_tools/dialog.py +++ b/openpype/tools/experimental_tools/dialog.py @@ -82,7 +82,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): tool_btns_layout.addWidget(tool_btns_label, 0) experimental_tools = ExperimentalTools( - parent=parent, filter_hosts=True + parent_widget=parent, refresh=False ) # Main layout @@ -116,7 +116,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): self._experimental_tools.refresh_availability() buttons_to_remove = set(self._buttons_by_tool_identifier.keys()) - for idx, tool in enumerate(self._experimental_tools.tools): + tools = self._experimental_tools.get_tools_for_host() + for idx, tool in enumerate(tools): identifier = tool.identifier if identifier in buttons_to_remove: buttons_to_remove.remove(identifier) diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py index 316359c0f3..fa2971dc1d 100644 --- a/openpype/tools/experimental_tools/tools_def.py +++ b/openpype/tools/experimental_tools/tools_def.py @@ -5,7 +5,32 @@ from openpype.settings import get_local_settings LOCAL_EXPERIMENTAL_KEY = "experimental_tools" -class ExperimentalTool: +class ExperimentalTool(object): + """Definition of experimental tool. + + Definition is used in local settings. + + Args: + identifier (str): String identifier of tool (unique). + label (str): Label shown in UI. + """ + def __init__(self, identifier, label, tooltip): + self.identifier = identifier + self.label = label + self.tooltip = tooltip + self._enabled = True + + @property + def enabled(self): + """Is tool enabled and button is clickable.""" + return self._enabled + + def set_enabled(self, enabled=True): + """Change if tool is enabled.""" + self._enabled = enabled + + +class ExperimentalHostTool(ExperimentalTool): """Definition of experimental tool. Definition is used in local settings and in experimental tools dialog. @@ -19,12 +44,10 @@ class ExperimentalTool: Some tools may not be available in all hosts. """ def __init__( - self, identifier, label, callback, tooltip, hosts_filter=None + self, identifier, label, tooltip, callback, hosts_filter=None ): - self.identifier = identifier - self.label = label + super(ExperimentalHostTool, self).__init__(identifier, label, tooltip) self.callback = callback - self.tooltip = tooltip self.hosts_filter = hosts_filter self._enabled = True @@ -33,18 +56,9 @@ class ExperimentalTool: return host_name in self.hosts_filter return True - @property - def enabled(self): - """Is tool enabled and button is clickable.""" - return self._enabled - - def set_enabled(self, enabled=True): - """Change if tool is enabled.""" - self._enabled = enabled - - def execute(self): + def execute(self, *args, **kwargs): """Trigger registered callback.""" - self.callback() + self.callback(*args, **kwargs) class ExperimentalTools: @@ -53,57 +67,36 @@ class ExperimentalTools: To add/remove experimental tool just add/remove tool to `experimental_tools` variable in __init__ function. - Args: - parent (QtWidgets.QWidget): Parent widget for tools. - host_name (str): Name of host in which context we're now. Environment - value 'AVALON_APP' is used when not passed. - filter_hosts (bool): Should filter tools. By default is set to 'True' - when 'host_name' is passed. Is always set to 'False' if 'host_name' - is not defined. + --- Example tool (callback will just print on click) --- + def example_callback(*args): + print("Triggered tool") + + experimental_tools = [ + ExperimentalHostTool( + "example", + "Example experimental tool", + example_callback, + "Example tool tooltip." + ) + ] + --- """ - def __init__(self, parent=None, host_name=None, filter_hosts=None): + def __init__(self, parent_widget=None, refresh=True): # Definition of experimental tools experimental_tools = [ - ExperimentalTool( + ExperimentalHostTool( "publisher", "New publisher", - self._show_publisher, - "Combined creation and publishing into one tool." + "Combined creation and publishing into one tool.", + self._show_publisher + ), + ExperimentalTool( + "traypublisher", + "New Standalone Publisher", + "Standalone publisher using new publisher. Requires restart" ) ] - # --- Example tool (callback will just print on click) --- - # def example_callback(*args): - # print("Triggered tool") - # - # experimental_tools = [ - # ExperimentalTool( - # "example", - # "Example experimental tool", - # example_callback, - # "Example tool tooltip." - # ) - # ] - - # Try to get host name from env variable `AVALON_APP` - if not host_name: - host_name = os.environ.get("AVALON_APP") - - # Decide if filtering by host name should happen - if filter_hosts is None: - filter_hosts = host_name is not None - - if filter_hosts and not host_name: - filter_hosts = False - - # Filter tools by host name - if filter_hosts: - experimental_tools = [ - tool - for tool in experimental_tools - if tool.is_available_for_host(host_name) - ] - # Store tools by identifier tools_by_identifier = {} for tool in experimental_tools: @@ -115,10 +108,13 @@ class ExperimentalTools: self._tools_by_identifier = tools_by_identifier self._tools = experimental_tools - self._parent_widget = parent + self._parent_widget = parent_widget self._publisher_tool = None + if refresh: + self.refresh_availability() + @property def tools(self): """Tools in list. @@ -139,6 +135,22 @@ class ExperimentalTools: """ return self._tools_by_identifier + def get(self, tool_identifier): + """Get tool by identifier.""" + return self.tools_by_identifier.get(tool_identifier) + + def get_tools_for_host(self, host_name=None): + if not host_name: + host_name = os.environ.get("AVALON_APP") + tools = [] + for tool in self.tools: + if ( + isinstance(tool, ExperimentalHostTool) + and tool.is_available_for_host(host_name) + ): + tools.append(tool) + return tools + def refresh_availability(self): """Reload local settings and check if any tool changed ability.""" local_settings = get_local_settings() diff --git a/openpype/tools/settings/local_settings/experimental_widget.py b/openpype/tools/settings/local_settings/experimental_widget.py index e863d9afb0..22ef952356 100644 --- a/openpype/tools/settings/local_settings/experimental_widget.py +++ b/openpype/tools/settings/local_settings/experimental_widget.py @@ -28,7 +28,7 @@ class LocalExperimentalToolsWidgets(QtWidgets.QWidget): layout.addRow(empty_label) - experimental_defs = ExperimentalTools(filter_hosts=False) + experimental_defs = ExperimentalTools(refresh=False) checkboxes_by_identifier = {} for tool in experimental_defs.tools: checkbox = QtWidgets.QCheckBox(self) From 93b8366e602a1c02a73885a5b960802d2a4ad4dc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 11:42:07 +0100 Subject: [PATCH 101/152] Revert "flame: fixing getting already created entity" This reverts commit e0670d34eb9a6779f959f5d908247928806afeea. --- .../openpype_babypublisher/modules/ftrack_lib.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index 90311a5ac5..c2168016c6 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -389,17 +389,13 @@ class FtrackEntityOperator: return entity def get_ftrack_entity(self, session, type, name, parent): - query_no_parent = '{} where name is "{}" and project_id is "{}"'.format( + query = '{} where name is "{}" and project_id is "{}"'.format( type, name, self.project_entity["id"]) - query_with_parent = ( - '{} where name is "{}" and project_id is "{}" ' - 'and parent_id is {}').format( - type, name, self.project_entity["id"], parent["id"]) - entity = ( - session.query(query_no_parent).first() or - session.query(query_with_parent).first() - ) + try: + entity = session.query(query).one() + except Exception: + entity = None # if entity doesnt exist then create one if not entity: From 93f64acff0e91eeeeb27551e14a8e9513d79acef Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 11:51:54 +0100 Subject: [PATCH 102/152] flame: code simplification --- .../startup/openpype_babypublisher/modules/ftrack_lib.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index c2168016c6..0a601a8804 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -392,10 +392,7 @@ class FtrackEntityOperator: query = '{} where name is "{}" and project_id is "{}"'.format( type, name, self.project_entity["id"]) - try: - entity = session.query(query).one() - except Exception: - entity = None + entity = session.query(query).first() # if entity doesnt exist then create one if not entity: From f4d27e591dea8cc3f63a0f209a65f9411a06000c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 12:10:00 +0100 Subject: [PATCH 103/152] flame: making sure task is created only once --- .../modules/ftrack_lib.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index 0a601a8804..7bf28ae5a7 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -363,6 +363,7 @@ class FtrackEntityOperator: def __init__(self, session, project_entity): self.session = session self.project_entity = project_entity + self.existing_tasks = [] def commit(self): try: @@ -427,10 +428,21 @@ class FtrackEntityOperator: return parents def create_task(self, task_type, task_types, parent): - existing_task = [ + _exising_tasks = [ child for child in parent['children'] if child.entity_type.lower() == 'task' - if child['name'].lower() in task_type.lower() + ] + + # add task into existing tasks if they are not already there + for _t in _exising_tasks: + if _t in self.existing_tasks: + continue + self.existing_tasks.append(_t) + + existing_task = [ + task for task in self.existing_tasks + if task['name'].lower() in task_type.lower() + if task['parent'] == parent ] if existing_task: @@ -442,4 +454,5 @@ class FtrackEntityOperator: }) task["type"] = task_types[task_type] + self.existing_tasks.append(task) return task From 3015c998db50bf3540487f9a392a0fee64d09105 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 12:24:16 +0100 Subject: [PATCH 104/152] Changed label of tray action --- openpype/modules/traypublish_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py index 033e24da88..39163b8eb8 100644 --- a/openpype/modules/traypublish_action.py +++ b/openpype/modules/traypublish_action.py @@ -6,7 +6,7 @@ from openpype_interfaces import ITrayAction class TrayPublishAction(OpenPypeModule, ITrayAction): - label = "Tray Publish (beta)" + label = "New Publish (beta)" name = "traypublish_tool" def initialize(self, modules_settings): From f985e58bb9687d19e63c05dce061063fce35e77e Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Feb 2022 12:27:13 +0100 Subject: [PATCH 105/152] Removed forgotten lines --- openpype/lib/anatomy.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 8f2f09a803..3bcd6169e4 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -67,10 +67,7 @@ class Anatomy: " to load data for specific project." )) - from .avalon_context import get_project_code - self.project_name = project_name - self.project_code = get_project_code(project_name) self._data = self._prepare_anatomy_data( get_anatomy_settings(project_name, site_name) From df9a398cc67b8d4a75554c9566688b76f1ab64d1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Feb 2022 13:11:31 +0100 Subject: [PATCH 106/152] flame: existing tasks adding to object variable --- .../flame/startup/openpype_babypublisher/modules/ftrack_lib.py | 3 ++- .../flame/startup/openpype_babypublisher/modules/panel_app.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py index 7bf28ae5a7..0e84a5ef52 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/ftrack_lib.py @@ -360,10 +360,11 @@ class FtrackComponentCreator: class FtrackEntityOperator: + existing_tasks = [] + def __init__(self, session, project_entity): self.session = session self.project_entity = project_entity - self.existing_tasks = [] def commit(self): try: diff --git a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py index e087549dc4..1e8011efaa 100644 --- a/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py +++ b/openpype/hosts/flame/startup/openpype_babypublisher/modules/panel_app.py @@ -33,6 +33,7 @@ class MainWindow(QtWidgets.QWidget): self.panel_class.clear_temp_data() self.panel_class.close() clear_inner_modules() + ftrack_lib.FtrackEntityOperator.existing_tasks = [] # now the panel can be closed event.accept() From 8d7f56c9e822cc3b71c9d928cf8da153c68d1804 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:11:40 +0100 Subject: [PATCH 107/152] added more methods to StringTemplate --- openpype/lib/path_templates.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index b51951851f..3b0e9ad3cc 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -126,6 +126,19 @@ class StringTemplate(object): self._parts = self.find_optional_parts(new_parts) + def __str__(self): + return self.template + + def __repr__(self): + return "<{}> {}".format(self.__class__.__name__, self.template) + + def __contains__(self, other): + return other in self.template + + def replace(self, *args, **kwargs): + self._template = self.template.replace(*args, **kwargs) + return self + @property def template(self): return self._template From 899e59b05919a8ebd3830fd381a81aded412d5fc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:12:09 +0100 Subject: [PATCH 108/152] fix which template key is used for getting last workfile --- openpype/lib/applications.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 393c83e9be..f6182c1846 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -28,7 +28,8 @@ from .profiles_filtering import filter_profiles from .local_settings import get_openpype_username from .avalon_context import ( get_workdir_data, - get_workdir_with_workdir_data + get_workdir_with_workdir_data, + get_workfile_template_key ) from .python_module_tools import ( @@ -1587,14 +1588,15 @@ def _prepare_last_workfile(data, workdir): last_workfile_path = data.get("last_workfile_path") or "" if not last_workfile_path: extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(app.host_name) - if extensions: anatomy = data["anatomy"] + project_settings = data["project_settings"] + task_type = workdir_data["task"]["type"] + template_key = get_workfile_template_key( + task_type, app.host_name, project_settings=project_settings + ) # Find last workfile - file_template = anatomy.templates["work"]["file"] - # Replace {task} by '{task[name]}' for backward compatibility - if '{task}' in file_template: - file_template = file_template.replace('{task}', '{task[name]}') + file_template = str(anatomy.templates[template_key]["file"]) workdir_data.update({ "version": 1, From 43839e63f131e0bd3fc32d1d91fdb61b1db1e96d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 15:36:15 +0100 Subject: [PATCH 109/152] TemplatesDict does not return objected templates with 'templates' attribute --- openpype/lib/anatomy.py | 61 +++++++++++-------- openpype/lib/path_templates.py | 11 +++- .../action_create_folders.py | 1 - 3 files changed, 46 insertions(+), 27 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 3bcd6169e4..3d56c1f1ba 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -402,7 +402,9 @@ class AnatomyTemplates(TemplatesDict): return self.templates.get(key, default) def reset(self): + self._raw_templates = None self._templates = None + self._objected_templates = None @property def project_name(self): @@ -414,13 +416,21 @@ class AnatomyTemplates(TemplatesDict): @property def templates(self): + self._validate_discovery() + return self._templates + + @property + def objected_templates(self): + self._validate_discovery() + return self._objected_templates + + def _validate_discovery(self): if self.project_name != self.loaded_project: - self._templates = None + self.reset() if self._templates is None: self._discover() self.loaded_project = self.project_name - return self._templates def _format_value(self, value, data): if isinstance(value, RootItem): @@ -434,31 +444,34 @@ class AnatomyTemplates(TemplatesDict): def set_templates(self, templates): if not templates: - self._raw_templates = None - self._templates = None - else: - self._raw_templates = copy.deepcopy(templates) - templates = copy.deepcopy(templates) - v_queue = collections.deque() - v_queue.append(templates) - while v_queue: - item = v_queue.popleft() - if not isinstance(item, dict): - continue + self.reset() + return - for key in tuple(item.keys()): - value = item[key] - if isinstance(value, dict): - v_queue.append(value) + self._raw_templates = copy.deepcopy(templates) + templates = copy.deepcopy(templates) + v_queue = collections.deque() + v_queue.append(templates) + while v_queue: + item = v_queue.popleft() + if not isinstance(item, dict): + continue - elif ( - isinstance(value, StringType) - and "{task}" in value - ): - item[key] = value.replace("{task}", "{task[name]}") + for key in tuple(item.keys()): + value = item[key] + if isinstance(value, dict): + v_queue.append(value) - solved_templates = self.solve_template_inner_links(templates) - self._templates = self.create_ojected_templates(solved_templates) + elif ( + isinstance(value, StringType) + and "{task}" in value + ): + item[key] = value.replace("{task}", "{task[name]}") + + solved_templates = self.solve_template_inner_links(templates) + self._templates = solved_templates + self._objected_templates = self.create_ojected_templates( + solved_templates + ) def default_templates(self): """Return default templates data with solved inner keys.""" diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 3b0e9ad3cc..370ffdd27c 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -227,15 +227,18 @@ class TemplatesDict(object): def __init__(self, templates=None): self._raw_templates = None self._templates = None + self._objected_templates = None self.set_templates(templates) def set_templates(self, templates): if templates is None: self._raw_templates = None self._templates = None + self._objected_templates = None elif isinstance(templates, dict): self._raw_templates = copy.deepcopy(templates) - self._templates = self.create_ojected_templates(templates) + self._templates = templates + self._objected_templates = self.create_ojected_templates(templates) else: raise TypeError("<{}> argument must be a dict, not {}.".format( self.__class__.__name__, str(type(templates)) @@ -255,6 +258,10 @@ class TemplatesDict(object): def templates(self): return self._templates + @property + def objected_templates(self): + return self._objected_templates + @classmethod def create_ojected_templates(cls, templates): if not isinstance(templates, dict): @@ -325,7 +332,7 @@ class TemplatesDict(object): if env_key not in data: data[env_key] = val - solved = self._solve_dict(self.templates, data) + solved = self._solve_dict(self.objected_templates, data) output = TemplatesResultDict(solved) output.strict = strict diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py index 8bbef9ad73..d15a865124 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_folders.py @@ -97,7 +97,6 @@ class CreateFolders(BaseAction): all_entities = self.get_notask_children(entity) anatomy = Anatomy(project_name) - project_settings = get_project_settings(project_name) work_keys = ["work", "folder"] work_template = anatomy.templates From e9c67e35bc2d294d1aa9a4319f4d67022079d1af Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Feb 2022 17:21:52 +0100 Subject: [PATCH 110/152] fixed used values passed to TemplateResult --- openpype/lib/path_templates.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/lib/path_templates.py b/openpype/lib/path_templates.py index 370ffdd27c..62bfdf774a 100644 --- a/openpype/lib/path_templates.py +++ b/openpype/lib/path_templates.py @@ -171,7 +171,7 @@ class StringTemplate(object): missing_keys |= result.missing_optional_keys solved = result.solved - used_values = result.split_keys_to_subdicts(result.used_values) + used_values = result.get_clean_used_values() return TemplateResult( result.output, @@ -485,7 +485,7 @@ class TemplatePartResult: self._missing_optional_keys = set() self._invalid_optional_types = {} - # Used values stored by key + # Used values stored by key with origin type # - key without any padding or key modifiers # - value from filling data # Example: {"version": 1} @@ -584,6 +584,15 @@ class TemplatePartResult: data[last_key] = value return output + def get_clean_used_values(self): + new_used_values = {} + for key, value in self.used_values.items(): + if isinstance(value, FormatObject): + value = str(value) + new_used_values[key] = value + + return self.split_keys_to_subdicts(new_used_values) + def add_realy_used_value(self, key, value): self._realy_used_values[key] = value @@ -724,7 +733,7 @@ class FormattingPart: formatted_value = self.template.format(**fill_data) result.add_realy_used_value(key, formatted_value) - result.add_used_value(existence_check, value) + result.add_used_value(existence_check, formatted_value) result.add_output(formatted_value) return result From 8c83016910467e6999daa2e186494e9ee256b7e3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 25 Feb 2022 10:01:04 +0100 Subject: [PATCH 111/152] search is not case sensitive --- openpype/tools/settings/settings/search_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/search_dialog.py b/openpype/tools/settings/settings/search_dialog.py index 3f987c0010..c02670c180 100644 --- a/openpype/tools/settings/settings/search_dialog.py +++ b/openpype/tools/settings/settings/search_dialog.py @@ -30,7 +30,7 @@ class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): regex = self.filterRegExp() if not regex.isEmpty() and regex.isValid(): pattern = regex.pattern() - compiled_regex = re.compile(pattern) + compiled_regex = re.compile(pattern, re.IGNORECASE) source_model = self.sourceModel() # Check current index itself in all columns From c7c324c4bb28e92ad05273582a125a994374246f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 25 Feb 2022 10:01:16 +0100 Subject: [PATCH 112/152] text change timer is singleshot --- openpype/tools/settings/settings/search_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/settings/settings/search_dialog.py b/openpype/tools/settings/settings/search_dialog.py index c02670c180..e6538cfe67 100644 --- a/openpype/tools/settings/settings/search_dialog.py +++ b/openpype/tools/settings/settings/search_dialog.py @@ -75,6 +75,7 @@ class SearchEntitiesDialog(QtWidgets.QDialog): filter_changed_timer = QtCore.QTimer() filter_changed_timer.setInterval(200) + filter_changed_timer.setSingleShot(True) view.selectionModel().selectionChanged.connect( self._on_selection_change From 1574a24953aac9fb581329c8a10313d8500b6ffe Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 25 Feb 2022 10:01:47 +0100 Subject: [PATCH 113/152] fix crashed entity creation handling --- openpype/tools/settings/settings/categories.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 14e25a54d8..663d497c36 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -715,7 +715,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget): self._outdated_version_label, self._require_restart_label, } - if self.entity.require_restart: + if self.is_modifying_defaults or self.entity is None: + require_restart = False + else: + require_restart = self.entity.require_restart + + if require_restart: visible_label = self._require_restart_label elif self._is_loaded_version_outdated: visible_label = self._outdated_version_label From 546b6cca98c1e58a500a4b52a461f7ec9f58ff5f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Feb 2022 15:34:15 +0100 Subject: [PATCH 114/152] Moved dev pages to Dev Added page for Admins about releases --- website/docs/admin_builds.md | 20 ++++++++++++++++++++ website/docs/dev_introduction.md | 4 ---- website/sidebars.js | 5 +++-- 3 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 website/docs/admin_builds.md diff --git a/website/docs/admin_builds.md b/website/docs/admin_builds.md new file mode 100644 index 0000000000..3a02cd5baf --- /dev/null +++ b/website/docs/admin_builds.md @@ -0,0 +1,20 @@ +--- +id: admin_builds +title: Builds and Releases +sidebar_label: Builds +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +Admins might find prepared builds on https://github.com/pypeclub/OpenPype/releases for all major platforms. + +### Currently supported OS versions +- Windows 10+ +- Ubuntu 20 +- Centos 7.9 +- MacOS Mohave + +In case your studio requires build for different OS version, or any specific build, please take a look at +[Requirements](dev_requirements.md) and [Build](dev_build.md) for more details how to create binaries to distribute. + \ No newline at end of file diff --git a/website/docs/dev_introduction.md b/website/docs/dev_introduction.md index af17f30692..5b48635a08 100644 --- a/website/docs/dev_introduction.md +++ b/website/docs/dev_introduction.md @@ -8,7 +8,3 @@ sidebar_label: Introduction Here you should find additional information targeted on developers who would like to contribute or dive deeper into OpenPype platform Currently there are details about automatic testing, in the future this should be location for API definition and documentation - -Check also: -- [Requirements](dev_requirements.md) -- [Build](dev_build.md) \ No newline at end of file diff --git a/website/sidebars.js b/website/sidebars.js index f1b77871f3..16af1e1151 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -46,8 +46,7 @@ module.exports = { type: "category", label: "Getting Started", items: [ - "dev_requirements", - "dev_build", + "admin_builds", "admin_distribute", "admin_use", "admin_openpype_commands", @@ -134,6 +133,8 @@ module.exports = { ], Dev: [ "dev_introduction", + "dev_requirements", + "dev_build", "dev_testing", "dev_contribute" ] From 4d914fe2572e41f13169c2033db19dd8c26bec38 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Feb 2022 16:28:09 +0100 Subject: [PATCH 115/152] Fixed more detailed versions --- website/docs/admin_builds.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/docs/admin_builds.md b/website/docs/admin_builds.md index 3a02cd5baf..a4e0e77242 100644 --- a/website/docs/admin_builds.md +++ b/website/docs/admin_builds.md @@ -9,11 +9,11 @@ import TabItem from '@theme/TabItem'; Admins might find prepared builds on https://github.com/pypeclub/OpenPype/releases for all major platforms. -### Currently supported OS versions -- Windows 10+ -- Ubuntu 20 -- Centos 7.9 -- MacOS Mohave +### Currently built on OS versions +- Windows 10 +- Ubuntu 20.04 +- Centos 7.6 +- MacOS Mohave (10.14.6) In case your studio requires build for different OS version, or any specific build, please take a look at [Requirements](dev_requirements.md) and [Build](dev_build.md) for more details how to create binaries to distribute. From d72183e0a0b3936cc87d4dd5cb36e346feb82f58 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Feb 2022 20:09:00 +0100 Subject: [PATCH 116/152] Update openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../openpype_flame_to_ftrack/modules/ftrack_lib.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 7a0efe079e..6256265730 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -139,17 +139,7 @@ class FtrackComponentCreator: if name == "ftrackreview-mp4": duration = data["duration"] - handle_start = data.get("handleStart", None) - handle_end = data.get("handleEnd", None) - if handle_start is not None: - duration += handle_start - if handle_end is not None: - duration += handle_end - if handle_start is None and handle_end is None: - # Backwards compatibility; old style 'handles' - # We multiply by two because old-style handles defined - # both the handle start and handle end - duration += data.get("handles", 0) * 2 + handles = data["handles"] fps = data["fps"] component_data["metadata"] = { From d32f2f946f540a265070b7af12bb1fd32db07d86 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Fri, 25 Feb 2022 20:09:05 +0100 Subject: [PATCH 117/152] Update openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../openpype_flame_to_ftrack/modules/ftrack_lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 6256265730..7e2ef381a3 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -145,7 +145,7 @@ class FtrackComponentCreator: component_data["metadata"] = { 'ftr_meta': json.dumps({ 'frameIn': int(0), - 'frameOut': int(duration), + 'frameOut': int(duration + (handles * 2)), 'frameRate': float(fps) }) } From 2501c47ca7e396f8d725b2e0edebf504c9e7d7fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Sat, 26 Feb 2022 10:00:30 +0100 Subject: [PATCH 118/152] remove modifications in flame file --- .../openpype_flame_to_ftrack/modules/ftrack_lib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py index 7e2ef381a3..26b197ee1d 100644 --- a/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py +++ b/openpype/hosts/flame/api/utility_scripts/openpype_flame_to_ftrack/modules/ftrack_lib.py @@ -138,9 +138,7 @@ class FtrackComponentCreator: if name == "ftrackreview-mp4": duration = data["duration"] - handles = data["handles"] - fps = data["fps"] component_data["metadata"] = { 'ftr_meta': json.dumps({ From 9617d3068bc1cab36128d1c9eebc5861ad5452cf Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Feb 2022 14:35:07 +0100 Subject: [PATCH 119/152] alpha slider is painted correctly --- .../widgets/color_widgets/color_inputs.py | 148 +++++++++++------- 1 file changed, 90 insertions(+), 58 deletions(-) diff --git a/openpype/widgets/color_widgets/color_inputs.py b/openpype/widgets/color_widgets/color_inputs.py index 6f5d4baa02..d6564ca29b 100644 --- a/openpype/widgets/color_widgets/color_inputs.py +++ b/openpype/widgets/color_widgets/color_inputs.py @@ -8,42 +8,56 @@ class AlphaSlider(QtWidgets.QSlider): def __init__(self, *args, **kwargs): super(AlphaSlider, self).__init__(*args, **kwargs) self._mouse_clicked = False + self._handle_size = 0 + self.setSingleStep(1) self.setMinimum(0) self.setMaximum(255) self.setValue(255) - self._checkerboard = None - - def checkerboard(self): - if self._checkerboard is None: - self._checkerboard = draw_checkerboard_tile( - 3, QtGui.QColor(173, 173, 173), QtGui.QColor(27, 27, 27) - ) - return self._checkerboard + self._handle_brush = QtGui.QBrush(QtGui.QColor(127, 127, 127)) def mousePressEvent(self, event): self._mouse_clicked = True if event.button() == QtCore.Qt.LeftButton: - self._set_value_to_pos(event.pos().x()) + self._set_value_to_pos(event.pos()) return event.accept() return super(AlphaSlider, self).mousePressEvent(event) - def _set_value_to_pos(self, pos_x): - value = ( - self.maximum() - self.minimum() - ) * pos_x / self.width() + self.minimum() - self.setValue(value) - def mouseMoveEvent(self, event): if self._mouse_clicked: - self._set_value_to_pos(event.pos().x()) + self._set_value_to_pos(event.pos()) + super(AlphaSlider, self).mouseMoveEvent(event) def mouseReleaseEvent(self, event): self._mouse_clicked = True super(AlphaSlider, self).mouseReleaseEvent(event) + def _set_value_to_pos(self, pos): + if self.orientation() == QtCore.Qt.Horizontal: + self._set_value_to_pos_x(pos.x()) + else: + self._set_value_to_pos_y(pos.y()) + + def _set_value_to_pos_x(self, pos_x): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_x -= half_handle + width = self.width() - handle_size + value = ((_range * pos_x) / width) + self.minimum() + self.setValue(value) + + def _set_value_to_pos_y(self, pos_y): + _range = self.maximum() - self.minimum() + handle_size = self._handle_size + half_handle = handle_size / 2 + pos_y = self.height() - pos_y - half_handle + height = self.height() - handle_size + value = (_range * pos_y / height) + self.minimum() + self.setValue(value) + def paintEvent(self, event): painter = QtGui.QPainter(self) opt = QtWidgets.QStyleOptionSlider() @@ -52,64 +66,82 @@ class AlphaSlider(QtWidgets.QSlider): painter.fillRect(event.rect(), QtCore.Qt.transparent) painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing) + + horizontal = self.orientation() == QtCore.Qt.Horizontal + rect = self.style().subControlRect( QtWidgets.QStyle.CC_Slider, opt, QtWidgets.QStyle.SC_SliderGroove, self ) - final_height = 9 - offset_top = 0 - if rect.height() > final_height: - offset_top = int((rect.height() - final_height) / 2) - rect = QtCore.QRect( - rect.x(), - offset_top, - rect.width(), - final_height - ) - pix_rect = QtCore.QRect(event.rect()) - pix_rect.setX(rect.x()) - pix_rect.setWidth(rect.width() - (2 * rect.x())) - pix = QtGui.QPixmap(pix_rect.width(), pix_rect.height()) - pix_painter = QtGui.QPainter(pix) - pix_painter.drawTiledPixmap(pix_rect, self.checkerboard()) + _range = self.maximum() - self.minimum() + _offset = self.value() - self.minimum() + if horizontal: + _handle_half = rect.height() / 2 + _handle_size = _handle_half * 2 + width = rect.width() - _handle_size + pos_x = ((width / _range) * _offset) + pos_y = rect.center().y() - _handle_half + 1 + else: + _handle_half = rect.width() / 2 + _handle_size = _handle_half * 2 + height = rect.height() - _handle_size + pos_x = rect.center().x() - _handle_half + 1 + pos_y = height - ((height / _range) * _offset) + + handle_rect = QtCore.QRect( + pos_x, pos_y, _handle_size, _handle_size + ) + + self._handle_size = _handle_size + _offset = 2 + _size = _handle_size - _offset + if horizontal: + if rect.height() > _size: + new_rect = QtCore.QRect(0, 0, rect.width(), _size) + center_point = QtCore.QPoint( + rect.center().x(), handle_rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.height() / 2 + + else: + if rect.width() > _size: + new_rect = QtCore.QRect(0, 0, _size, rect.height()) + center_point = QtCore.QPoint( + handle_rect.center().x(), rect.center().y() + ) + new_rect.moveCenter(center_point) + rect = new_rect + + ratio = rect.width() / 2 + + painter.save() + clip_path = QtGui.QPainterPath() + clip_path.addRoundedRect(rect, ratio, ratio) + painter.setClipPath(clip_path) + checker_size = int(_handle_size / 3) + if checker_size == 0: + checker_size = 1 + checkerboard = draw_checkerboard_tile( + checker_size, QtGui.QColor(173, 173, 173), QtGui.QColor(27, 27, 27) + ) + painter.drawTiledPixmap(rect, checkerboard) gradient = QtGui.QLinearGradient(rect.topLeft(), rect.bottomRight()) gradient.setColorAt(0, QtCore.Qt.transparent) gradient.setColorAt(1, QtCore.Qt.white) - pix_painter.fillRect(pix_rect, gradient) - pix_painter.end() - - brush = QtGui.QBrush(pix) - painter.save() painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(brush) - ratio = rect.height() / 2 - painter.drawRoundedRect(rect, ratio, ratio) + painter.fillRect(rect, gradient) painter.restore() - _handle_rect = self.style().subControlRect( - QtWidgets.QStyle.CC_Slider, - opt, - QtWidgets.QStyle.SC_SliderHandle, - self - ) - - handle_rect = QtCore.QRect(rect) - if offset_top > 1: - height = handle_rect.height() - handle_rect.setY(handle_rect.y() - 1) - handle_rect.setHeight(height + 2) - handle_rect.setX(_handle_rect.x()) - handle_rect.setWidth(handle_rect.height()) - painter.save() - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(QtGui.QColor(127, 127, 127)) + painter.setBrush(self._handle_brush) painter.drawEllipse(handle_rect) - painter.restore() From 5fddb5c17ad66952b06174cbfd589d122b7a2699 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Feb 2022 14:35:47 +0100 Subject: [PATCH 120/152] thickness of circles in triangle and outline are calculated using ceil and not floor --- openpype/widgets/color_widgets/color_triangle.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/widgets/color_widgets/color_triangle.py b/openpype/widgets/color_widgets/color_triangle.py index f4a86c4fa5..e15b9e9f65 100644 --- a/openpype/widgets/color_widgets/color_triangle.py +++ b/openpype/widgets/color_widgets/color_triangle.py @@ -1,5 +1,5 @@ from enum import Enum -from math import floor, sqrt, sin, cos, acos, pi as PI +from math import floor, ceil, sqrt, sin, cos, acos, pi as PI from Qt import QtWidgets, QtCore, QtGui TWOPI = PI * 2 @@ -187,10 +187,10 @@ class QtColorTriangle(QtWidgets.QWidget): self.outer_radius = (size - 1) / 2 self.pen_width = int( - floor(self.outer_radius / self.ellipse_thick_ratio) + ceil(self.outer_radius / self.ellipse_thick_ratio) ) self.ellipse_size = int( - floor(self.outer_radius / self.ellipse_size_ratio) + ceil(self.outer_radius / self.ellipse_size_ratio) ) cx = float(self.contentsRect().center().x()) @@ -542,10 +542,10 @@ class QtColorTriangle(QtWidgets.QWidget): self.outer_radius = (size - 1) / 2 self.pen_width = int( - floor(self.outer_radius / self.ellipse_thick_ratio) + ceil(self.outer_radius / self.ellipse_thick_ratio) ) self.ellipse_size = int( - floor(self.outer_radius / self.ellipse_size_ratio) + ceil(self.outer_radius / self.ellipse_size_ratio) ) cx = float(self.contentsRect().center().x()) From 266ca700c9e6012c861bdc4989c9d2b6aa8954b4 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Mon, 28 Feb 2022 18:07:40 +0100 Subject: [PATCH 121/152] temporary fix for foreign pull request numbers in CI --- tools/ci_tools.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/ci_tools.py b/tools/ci_tools.py index e5ca0c2c28..aeb367af38 100644 --- a/tools/ci_tools.py +++ b/tools/ci_tools.py @@ -19,7 +19,10 @@ def get_release_type_github(Log, github_token): match = re.search("pull request #(\d+)", line) if match: pr_number = match.group(1) - pr = repo.get_pull(int(pr_number)) + try: + pr = repo.get_pull(int(pr_number)) + except: + continue for label in pr.labels: labels.add(label.name) From d0baf4f7009f5d8f61885286d9f8c5f13d6dd48d Mon Sep 17 00:00:00 2001 From: OpenPype Date: Mon, 28 Feb 2022 17:19:09 +0000 Subject: [PATCH 122/152] [Automated] Bump version --- CHANGELOG.md | 107 ++++++++++++-------------------------------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 31 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3babdceafb..c945569545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,121 +1,72 @@ # Changelog -## [3.9.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) **Deprecated:** -- Loader: Remove default family states for hosts from code [\#2706](https://github.com/pypeclub/OpenPype/pull/2706) +- Houdini: Remove unused code [\#2779](https://github.com/pypeclub/OpenPype/pull/2779) ### 📖 Documentation +- Documentation: fixed broken links [\#2799](https://github.com/pypeclub/OpenPype/pull/2799) +- Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) +- Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) - Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) -- documentation: add example to `repack-version` command [\#2669](https://github.com/pypeclub/OpenPype/pull/2669) - -**🆕 New features** - -- Flame: loading clips to reels [\#2622](https://github.com/pypeclub/OpenPype/pull/2622) +- Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) **🚀 Enhancements** +- General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) +- Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) +- Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) +- Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) - Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) - Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734) -- Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) -- Project Manager: Disable add task, add asset and save button when not in a project [\#2727](https://github.com/pypeclub/OpenPype/pull/2727) -- dropbox handle big file [\#2718](https://github.com/pypeclub/OpenPype/pull/2718) -- Fusion Move PR: Minor tweaks to Fusion integration [\#2716](https://github.com/pypeclub/OpenPype/pull/2716) -- Nuke: prerender with review knob [\#2691](https://github.com/pypeclub/OpenPype/pull/2691) -- Maya configurable unit validator [\#2680](https://github.com/pypeclub/OpenPype/pull/2680) -- General: Add settings for CleanUpFarm and disable the plugin by default [\#2679](https://github.com/pypeclub/OpenPype/pull/2679) -- Project Manager: Only allow scroll wheel edits when spinbox is active [\#2678](https://github.com/pypeclub/OpenPype/pull/2678) -- Ftrack: Sync description to assets [\#2670](https://github.com/pypeclub/OpenPype/pull/2670) -- General: FFmpeg conversion also check attribute string length [\#2635](https://github.com/pypeclub/OpenPype/pull/2635) -- Global: adding studio name/code to anatomy template formatting data [\#2630](https://github.com/pypeclub/OpenPype/pull/2630) -- Houdini: Load Arnold .ass procedurals into Houdini [\#2606](https://github.com/pypeclub/OpenPype/pull/2606) -- Deadline: Simplify GlobalJobPreLoad logic [\#2605](https://github.com/pypeclub/OpenPype/pull/2605) -- Houdini: Implement Arnold .ass standin extraction from Houdini \(also support .ass.gz\) [\#2603](https://github.com/pypeclub/OpenPype/pull/2603) +- RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) **🐛 Bug fixes** +- Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) +- Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) +- resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) +- Flame: Fix version string in default settings [\#2783](https://github.com/pypeclub/OpenPype/pull/2783) +- After Effects: Fix typo in name `afftereffects` -\> `aftereffects` [\#2768](https://github.com/pypeclub/OpenPype/pull/2768) +- Avoid renaming udim indexes [\#2765](https://github.com/pypeclub/OpenPype/pull/2765) +- Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) - Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) - Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) -- Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) - Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) - Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) - Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731) -- General: Fix loading of unused chars in xml format [\#2729](https://github.com/pypeclub/OpenPype/pull/2729) -- TVPaint: Set objectName with members [\#2725](https://github.com/pypeclub/OpenPype/pull/2725) -- General: Don't use 'objectName' from loaded references [\#2715](https://github.com/pypeclub/OpenPype/pull/2715) -- Settings: Studio Project anatomy is queried using right keys [\#2711](https://github.com/pypeclub/OpenPype/pull/2711) -- Local Settings: Additional applications don't break UI [\#2710](https://github.com/pypeclub/OpenPype/pull/2710) -- Houdini: Fix refactor of Houdini host move for CreateArnoldAss [\#2704](https://github.com/pypeclub/OpenPype/pull/2704) -- LookAssigner: Fix imports after moving code to OpenPype repository [\#2701](https://github.com/pypeclub/OpenPype/pull/2701) -- Multiple hosts: unify menu style across hosts [\#2693](https://github.com/pypeclub/OpenPype/pull/2693) -- Maya Redshift fixes [\#2692](https://github.com/pypeclub/OpenPype/pull/2692) -- Maya: fix fps validation popup [\#2685](https://github.com/pypeclub/OpenPype/pull/2685) -- Houdini Explicitly collect correct frame name even in case of single frame render when `frameStart` is provided [\#2676](https://github.com/pypeclub/OpenPype/pull/2676) -- hiero: fix effect collector name and order [\#2673](https://github.com/pypeclub/OpenPype/pull/2673) -- Maya: Fix menu callbacks [\#2671](https://github.com/pypeclub/OpenPype/pull/2671) -- Launcher: Fix access to 'data' attribute on actions [\#2659](https://github.com/pypeclub/OpenPype/pull/2659) -- Houdini: fix usd family in loader and integrators [\#2631](https://github.com/pypeclub/OpenPype/pull/2631) +- Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) **Merged pull requests:** +- Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) +- Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) +- Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) +- SyncServer: Moved module one hierarchy level higher [\#2791](https://github.com/pypeclub/OpenPype/pull/2791) +- Royal render: Move module one hierarchy level higher [\#2790](https://github.com/pypeclub/OpenPype/pull/2790) +- Deadline: Move module one hierarchy level higher [\#2789](https://github.com/pypeclub/OpenPype/pull/2789) +- Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) +- Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) +- Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) - Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) +- Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) - Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713) -- TVPaint: Plugin build without dependencies [\#2705](https://github.com/pypeclub/OpenPype/pull/2705) -- Webpublisher: Photoshop create a beauty png [\#2689](https://github.com/pypeclub/OpenPype/pull/2689) -- Ftrack: Hierarchical attributes are queried properly [\#2682](https://github.com/pypeclub/OpenPype/pull/2682) -- Maya: Add Validate Frame Range settings [\#2661](https://github.com/pypeclub/OpenPype/pull/2661) -- Harmony: move to Openpype [\#2657](https://github.com/pypeclub/OpenPype/pull/2657) -- General: Show applications without integration in project [\#2656](https://github.com/pypeclub/OpenPype/pull/2656) -- Maya: cleanup duplicate rendersetup code [\#2642](https://github.com/pypeclub/OpenPype/pull/2642) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.2-nightly.3...3.8.2) -### 📖 Documentation - -- Cosmetics: Fix common typos in openpype/website [\#2617](https://github.com/pypeclub/OpenPype/pull/2617) - -**🚀 Enhancements** - -- General: Project backup tools [\#2629](https://github.com/pypeclub/OpenPype/pull/2629) -- nuke: adding clear button to write nodes [\#2627](https://github.com/pypeclub/OpenPype/pull/2627) -- Ftrack: Family to Asset type mapping is in settings [\#2602](https://github.com/pypeclub/OpenPype/pull/2602) - -**🐛 Bug fixes** - -- Fix pulling of cx\_freeze 6.10 [\#2628](https://github.com/pypeclub/OpenPype/pull/2628) - -**Merged pull requests:** - -- Docker: enhance dockerfiles with metadata, fix pyenv initialization [\#2647](https://github.com/pypeclub/OpenPype/pull/2647) -- WebPublisher: fix instance duplicates [\#2641](https://github.com/pypeclub/OpenPype/pull/2641) -- Fix - safer pulling of task name for webpublishing from PS [\#2613](https://github.com/pypeclub/OpenPype/pull/2613) - ## [3.8.1](https://github.com/pypeclub/OpenPype/tree/3.8.1) (2022-02-01) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.1-nightly.3...3.8.1) -**🚀 Enhancements** - -- Webpublisher: Thumbnail extractor [\#2600](https://github.com/pypeclub/OpenPype/pull/2600) - -**🐛 Bug fixes** - -- Release/3.8.0 [\#2619](https://github.com/pypeclub/OpenPype/pull/2619) -- hotfix: OIIO tool path - add extension on windows [\#2618](https://github.com/pypeclub/OpenPype/pull/2618) -- Settings: Enum does not store empty string if has single item to select [\#2615](https://github.com/pypeclub/OpenPype/pull/2615) - -**Merged pull requests:** - -- Bump pillow from 8.4.0 to 9.0.0 [\#2595](https://github.com/pypeclub/OpenPype/pull/2595) - ## [3.8.0](https://github.com/pypeclub/OpenPype/tree/3.8.0) (2022-01-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.8.0-nightly.7...3.8.0) diff --git a/openpype/version.py b/openpype/version.py index cb3658a827..0a799462ed 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.3" +__version__ = "3.9.0-nightly.4" diff --git a/pyproject.toml b/pyproject.toml index 052ed92bbc..44bc0acbcc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.3" # OpenPype +version = "3.9.0-nightly.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From a6392f131ee69b4b5c6979f1da577fae66dd656c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Feb 2022 18:56:00 +0100 Subject: [PATCH 123/152] use AVALON_APP to get value for "app" key --- openpype/hosts/nuke/api/lib.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 6faf6cd108..dba7ec1b85 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1,6 +1,5 @@ import os import re -import sys import six import platform import contextlib @@ -679,10 +678,10 @@ def get_render_path(node): } nuke_imageio_writes = get_created_node_imageio_setting(**data_preset) + host_name = os.environ.get("AVALON_APP") - application = lib.get_application(os.environ["AVALON_APP_NAME"]) data.update({ - "application": application, + "app": host_name, "nuke_imageio_writes": nuke_imageio_writes }) @@ -805,18 +804,14 @@ def create_write_node(name, data, input=None, prenodes=None, ''' imageio_writes = get_created_node_imageio_setting(**data) - app_manager = ApplicationManager() - app_name = os.environ.get("AVALON_APP_NAME") - if app_name: - app = app_manager.applications.get(app_name) - for knob in imageio_writes["knobs"]: if knob["name"] == "file_type": representation = knob["value"] + host_name = os.environ.get("AVALON_APP") try: data.update({ - "app": app.host_name, + "app": host_name, "imageio_writes": imageio_writes, "representation": representation, }) From 79c1fe27129f64251658685a03d9871b6bfbc5ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 11:04:10 +0100 Subject: [PATCH 124/152] replace lambda with custom function callback --- openpype/tools/settings/settings/base.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index bbfbc58627..d4ad84996c 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -12,6 +12,22 @@ from .lib import create_deffered_value_change_timer from .constants import DEFAULT_PROJECT_LABEL +class _Callback: + """Callback wrapper which stores it's args and kwargs. + + Using lambda has few issues if local variables are passed to called + functions in loop it may change the value of the variable in already + stored callback. + """ + def __init__(self, func, *args, **kwargs): + self._func = func + self._args = args + self._kwargs = kwargs + + def __call__(self): + self._func(*self._args, **self._kwargs) + + class BaseWidget(QtWidgets.QWidget): allow_actions = True @@ -325,7 +341,11 @@ class BaseWidget(QtWidgets.QWidget): action = QtWidgets.QAction(project_name) submenu.addAction(action) - actions_mapping[action] = lambda: self._apply_values_from_project( + # Use custom callback object instead of lambda + # - project_name value is changed each value so all actions will + # use the same source project + actions_mapping[action] = _Callback( + self._apply_values_from_project, project_name ) menu.addMenu(submenu) From 713b82b19c6fe6c4bae7fcc57bdd3662e30eecc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 1 Mar 2022 11:32:03 +0100 Subject: [PATCH 125/152] renaming integration --- .gitmodules | 5 +- openpype/hosts/unreal/api/__init__.py | 2 +- openpype/hosts/unreal/api/helpers.py | 2 +- openpype/hosts/unreal/api/lib.py | 18 ++--- openpype/hosts/unreal/api/pipeline.py | 74 ++++++++----------- .../unreal/hooks/pre_workfile_preparation.py | 4 +- .../integration/Content/Python/init_unreal.py | 31 ++++---- .../Private/AvalonPublishInstanceFactory.cpp | 20 ----- .../Avalon/Private/AvalonPythonBridge.cpp | 13 ---- .../Source/Avalon/Private/AvalonStyle.cpp | 69 ----------------- .../OpenPype.Build.cs} | 2 +- .../Private/AssetContainer.cpp | 0 .../Private/AssetContainerFactory.cpp | 0 .../Private/OpenPype.cpp} | 46 ++++++------ .../Private/OpenPypeLib.cpp} | 6 +- .../Private/OpenPypePublishInstance.cpp} | 30 ++++---- .../OpenPypePublishInstanceFactory.cpp | 20 +++++ .../OpenPype/Private/OpenPypePythonBridge.cpp | 13 ++++ .../Source/OpenPype/Private/OpenPypeStyle.cpp | 70 ++++++++++++++++++ .../Public/AssetContainer.h | 0 .../Public/AssetContainerFactory.h | 2 +- .../Avalon.h => OpenPype/Public/OpenPype.h} | 2 +- .../Public/OpenPypeLib.h} | 2 +- .../Public/OpenPypePublishInstance.h} | 6 +- .../Public/OpenPypePublishInstanceFactory.h} | 6 +- .../Public/OpenPypePythonBridge.h} | 6 +- .../Public/OpenPypeStyle.h} | 2 +- .../unreal/plugins/create/create_camera.py | 2 +- .../unreal/plugins/create/create_layout.py | 3 +- .../unreal/plugins/create/create_look.py | 14 ++-- .../plugins/create/create_staticmeshfbx.py | 8 +- .../load/load_alembic_geometrycache.py | 22 +++--- .../plugins/load/load_alembic_skeletalmesh.py | 19 ++--- .../plugins/load/load_alembic_staticmesh.py | 21 +++--- .../unreal/plugins/load/load_animation.py | 24 +++--- .../hosts/unreal/plugins/load/load_camera.py | 16 ++-- .../hosts/unreal/plugins/load/load_layout.py | 49 ++++++------ .../hosts/unreal/plugins/load/load_rig.py | 25 ++++--- .../unreal/plugins/load/load_staticmeshfbx.py | 26 ++++--- .../plugins/publish/collect_current_file.py | 9 ++- .../plugins/publish/collect_instances.py | 10 ++- .../unreal/plugins/publish/extract_camera.py | 8 +- .../unreal/plugins/publish/extract_layout.py | 7 +- .../unreal/plugins/publish/extract_look.py | 15 ++-- 44 files changed, 376 insertions(+), 353 deletions(-) delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp delete mode 100644 openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp rename openpype/hosts/unreal/integration/Source/{Avalon/Avalon.Build.cs => OpenPype/OpenPype.Build.cs} (96%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Private/AssetContainer.cpp (100%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Private/AssetContainerFactory.cpp (100%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/Avalon.cpp => OpenPype/Private/OpenPype.cpp} (56%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/AvalonLib.cpp => OpenPype/Private/OpenPypeLib.cpp} (84%) rename openpype/hosts/unreal/integration/Source/{Avalon/Private/AvalonPublishInstance.cpp => OpenPype/Private/OpenPypePublishInstance.cpp} (67%) create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp create mode 100644 openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Public/AssetContainer.h (100%) rename openpype/hosts/unreal/integration/Source/{Avalon => OpenPype}/Public/AssetContainerFactory.h (89%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/Avalon.h => OpenPype/Public/OpenPype.h} (87%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonLib.h => OpenPype/Public/OpenPypeLib.h} (88%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPublishInstance.h => OpenPype/Public/OpenPypePublishInstance.h} (65%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPublishInstanceFactory.h => OpenPype/Public/OpenPypePublishInstanceFactory.h} (61%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonPythonBridge.h => OpenPype/Public/OpenPypePythonBridge.h} (71%) rename openpype/hosts/unreal/integration/Source/{Avalon/Public/AvalonStyle.h => OpenPype/Public/OpenPypeStyle.h} (95%) diff --git a/.gitmodules b/.gitmodules index 67b820a247..9920ceaad6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "repos/avalon-core"] path = repos/avalon-core - url = https://github.com/pypeclub/avalon-core.git -[submodule "repos/avalon-unreal-integration"] - path = repos/avalon-unreal-integration - url = https://github.com/pypeclub/avalon-unreal-integration.git \ No newline at end of file + url = https://github.com/pypeclub/avalon-core.git \ No newline at end of file diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index 38469e0ddb..df86c09073 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -16,7 +16,7 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") def install(): - """Install Unreal configuration for Avalon.""" + """Install Unreal configuration for OpenPype.""" print("-=" * 40) logo = '''. . diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py index 6fc89cf176..555133eae0 100644 --- a/openpype/hosts/unreal/api/helpers.py +++ b/openpype/hosts/unreal/api/helpers.py @@ -29,7 +29,7 @@ class OpenPypeHelpers(unreal.OpenPypeLib): Example: - AvalonHelpers().set_folder_color( + OpenPypeHelpers().set_folder_color( "/Game/Path", unreal.LinearColor(a=1.0, r=1.0, g=0.5, b=0) ) diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/api/lib.py index e04606a333..d4a776e892 100644 --- a/openpype/hosts/unreal/api/lib.py +++ b/openpype/hosts/unreal/api/lib.py @@ -230,18 +230,18 @@ def create_unreal_project(project_name: str, ue_id = "{" + loaded_modules.get("BuildId") + "}" plugins_path = None - if os.path.isdir(env.get("AVALON_UNREAL_PLUGIN", "")): + if os.path.isdir(env.get("OPENPYPE_UNREAL_PLUGIN", "")): # copy plugin to correct path under project plugins_path = pr_dir / "Plugins" - avalon_plugin_path = plugins_path / "Avalon" - if not avalon_plugin_path.is_dir(): - avalon_plugin_path.mkdir(parents=True, exist_ok=True) + openpype_plugin_path = plugins_path / "OpenPype" + if not openpype_plugin_path.is_dir(): + openpype_plugin_path.mkdir(parents=True, exist_ok=True) dir_util._path_created = {} - dir_util.copy_tree(os.environ.get("AVALON_UNREAL_PLUGIN"), - avalon_plugin_path.as_posix()) + dir_util.copy_tree(os.environ.get("OPENPYPE_UNREAL_PLUGIN"), + openpype_plugin_path.as_posix()) - if not (avalon_plugin_path / "Binaries").is_dir() \ - or not (avalon_plugin_path / "Intermediate").is_dir(): + if not (openpype_plugin_path / "Binaries").is_dir() \ + or not (openpype_plugin_path / "Intermediate").is_dir(): dev_mode = True # data for project file @@ -304,7 +304,7 @@ def _prepare_cpp_project(project_file: Path, engine_path: Path) -> None: """Prepare CPP Unreal Project. This function will add source files needed for project to be - rebuild along with the avalon integration plugin. + rebuild along with the OpenPype integration plugin. There seems not to be automated way to do it from command line. But there might be way to create at least those target and build files diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index c255005f31..02c89abadd 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -1,21 +1,17 @@ # -*- coding: utf-8 -*- -import sys import pyblish.api from avalon.pipeline import AVALON_CONTAINER_ID import unreal # noqa from typing import List - from openpype.tools.utils import host_tools - from avalon import api -AVALON_CONTAINERS = "OpenPypeContainers" +OPENPYPE_CONTAINERS = "OpenPypeContainers" def install(): - pyblish.api.register_host("unreal") _register_callbacks() _register_events() @@ -46,7 +42,7 @@ class Creator(api.Creator): def process(self): nodes = list() - with unreal.ScopedEditorTransaction("Avalon Creating Instance"): + with unreal.ScopedEditorTransaction("OpenPype Creating Instance"): if (self.options or {}).get("useSelection"): self.log.info("setting ...") print("settings ...") @@ -63,23 +59,21 @@ class Creator(api.Creator): return instance -class Loader(api.Loader): - hosts = ["unreal"] - - def ls(): - """ - List all containers found in *Content Manager* of Unreal and return + """List all containers. + + List all found in *Content Manager* of Unreal and return metadata from them. Adding `objectName` to set. + """ ar = unreal.AssetRegistryHelpers.get_asset_registry() - avalon_containers = ar.get_assets_by_class("AssetContainer", True) + openpype_containers = ar.get_assets_by_class("AssetContainer", True) # get_asset_by_class returns AssetData. To get all metadata we need to # load asset. get_tag_values() work only on metadata registered in - # Asset Registy Project settings (and there is no way to set it with + # Asset Registry Project settings (and there is no way to set it with # python short of editing ini configuration file). - for asset_data in avalon_containers: + for asset_data in openpype_containers: asset = asset_data.get_asset() data = unreal.EditorAssetLibrary.get_metadata_tag_values(asset) data["objectName"] = asset_data.asset_name @@ -89,8 +83,7 @@ def ls(): def parse_container(container): - """ - To get data from container, AssetContainer must be loaded. + """To get data from container, AssetContainer must be loaded. Args: container(str): path to container @@ -107,20 +100,19 @@ def parse_container(container): def publish(): - """Shorthand to publish from within host""" + """Shorthand to publish from within host.""" import pyblish.util return pyblish.util.publish() def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): - """Bundles *nodes* (assets) into a *container* and add metadata to it. Unreal doesn't support *groups* of assets that you can add metadata to. But it does support folders that helps to organize asset. Unfortunately those folders are just that - you cannot add any additional information - to them. `Avalon Integration Plugin`_ is providing way out - Implementing + to them. OpenPype Integration Plugin is providing way out - Implementing `AssetContainer` Blueprint class. This class when added to folder can handle metadata on it using standard :func:`unreal.EditorAssetLibrary.set_metadata_tag()` and @@ -129,10 +121,7 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): those assets is available as `assets` property. This is list of strings starting with asset type and ending with its path: - `Material /Game/Avalon/Test/TestMaterial.TestMaterial` - - .. _Avalon Integration Plugin: - https://github.com/pypeclub/avalon-unreal-integration + `Material /Game/OpenPype/Test/TestMaterial.TestMaterial` """ # 1 - create directory for container @@ -160,10 +149,11 @@ def containerise(name, namespace, nodes, context, loader=None, suffix="_CON"): def instantiate(root, name, data, assets=None, suffix="_INS"): - """ - Bundles *nodes* into *container* marking it with metadata as publishable - instance. If assets are provided, they are moved to new path where - `AvalonPublishInstance` class asset is created and imprinted with metadata. + """Bundles *nodes* into *container*. + + Marking it with metadata as publishable instance. If assets are provided, + they are moved to new path where `OpenPypePublishInstance` class asset is + created and imprinted with metadata. This can then be collected for publishing by Pyblish for example. @@ -174,6 +164,7 @@ def instantiate(root, name, data, assets=None, suffix="_INS"): assets (list of str): list of asset paths to include in publish instance suffix (str): suffix string to append to instance name + """ container_name = "{}{}".format(name, suffix) @@ -203,7 +194,7 @@ def imprint(node, data): loaded_asset, key, str(value) ) - with unreal.ScopedEditorTransaction("Avalon containerising"): + with unreal.ScopedEditorTransaction("OpenPype containerising"): unreal.EditorAssetLibrary.save_asset(node) @@ -248,7 +239,7 @@ def show_experimental_tools(): def create_folder(root: str, name: str) -> str: - """Create new folder + """Create new folder. If folder exists, append number at the end and try again, incrementing if needed. @@ -281,8 +272,7 @@ def create_folder(root: str, name: str) -> str: def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: - """ - Moving (renaming) list of asset paths to new destination. + """Moving (renaming) list of asset paths to new destination. Args: root (str): root of the path (eg. `/Game`) @@ -316,8 +306,8 @@ def move_assets_to_path(root: str, name: str, assets: List[str]) -> str: def create_container(container: str, path: str) -> unreal.Object: - """ - Helper function to create Asset Container class on given path. + """Helper function to create Asset Container class on given path. + This Asset Class helps to mark given path as Container and enable asset version control on it. @@ -331,7 +321,7 @@ def create_container(container: str, path: str) -> unreal.Object: Example: - create_avalon_container( + create_container( "/Game/modelingFooCharacter_CON", "modelingFooCharacter_CON" ) @@ -345,9 +335,9 @@ def create_container(container: str, path: str) -> unreal.Object: def create_publish_instance(instance: str, path: str) -> unreal.Object: - """ - Helper function to create Avalon Publish Instance on given path. - This behaves similary as :func:`create_avalon_container`. + """Helper function to create OpenPype Publish Instance on given path. + + This behaves similarly as :func:`create_openpype_container`. Args: path (str): Path where to create Publish Instance. @@ -365,13 +355,13 @@ def create_publish_instance(instance: str, path: str) -> unreal.Object: ) """ - factory = unreal.AvalonPublishInstanceFactory() + factory = unreal.OpenPypePublishInstanceFactory() tools = unreal.AssetToolsHelpers().get_asset_tools() asset = tools.create_asset(instance, path, None, factory) return asset -def cast_map_to_str_dict(map) -> dict: +def cast_map_to_str_dict(umap) -> dict: """Cast Unreal Map to dict. Helper function to cast Unreal Map object to plain old python @@ -379,10 +369,10 @@ def cast_map_to_str_dict(map) -> dict: metadata dicts. Args: - map: Unreal Map object + umap: Unreal Map object Returns: dict """ - return {str(key): str(value) for (key, value) in map.items()} + return {str(key): str(value) for (key, value) in umap.items()} diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 880dba5cfb..6b787f4da7 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -136,9 +136,9 @@ class UnrealPrelaunchHook(PreLaunchHook): f"{self.signature} creating unreal " f"project [ {unreal_project_name} ]" )) - # Set "AVALON_UNREAL_PLUGIN" to current process environment for + # Set "OPENPYPE_UNREAL_PLUGIN" to current process environment for # execution of `create_unreal_project` - env_key = "AVALON_UNREAL_PLUGIN" + env_key = "OPENPYPE_UNREAL_PLUGIN" if self.launch_context.env.get(env_key): os.environ[env_key] = self.launch_context.env[env_key] diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py index 48e931bb04..4445abb1b0 100644 --- a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -1,27 +1,32 @@ import unreal -avalon_detected = True +openpype_detected = True try: from avalon import api - from avalon import unreal as avalon_unreal except ImportError as exc: - avalon_detected = False - unreal.log_error("Avalon: cannot load avalon [ {} ]".format(exc)) + openpype_detected = False + unreal.log_error("Avalon: cannot load Avalon [ {} ]".format(exc)) -if avalon_detected: - api.install(avalon_unreal) +try: + from openpype.host.unreal import api as openpype_host +except ImportError as exc: + openpype_detected = False + unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) + +if openpype_detected: + api.install(openpype_host) @unreal.uclass() -class AvalonIntegration(unreal.AvalonPythonBridge): +class OpenPypeIntegration(unreal.OpenPypePythonBridge): @unreal.ufunction(override=True) def RunInPython_Popup(self): - unreal.log_warning("Avalon: showing tools popup") - if avalon_detected: - avalon_unreal.show_tools_popup() + unreal.log_warning("OpenPype: showing tools popup") + if openpype_detected: + openpype_host.show_tools_popup() @unreal.ufunction(override=True) def RunInPython_Dialog(self): - unreal.log_warning("Avalon: showing tools dialog") - if avalon_detected: - avalon_unreal.show_tools_dialog() + unreal.log_warning("OpenPype: showing tools dialog") + if openpype_detected: + openpype_host.show_tools_dialog() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp deleted file mode 100644 index e14a14f1e5..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstanceFactory.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "AvalonPublishInstanceFactory.h" -#include "AvalonPublishInstance.h" - -UAvalonPublishInstanceFactory::UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer) - : UFactory(ObjectInitializer) -{ - SupportedClass = UAvalonPublishInstance::StaticClass(); - bCreateNew = false; - bEditorImport = true; -} - -UObject* UAvalonPublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) -{ - UAvalonPublishInstance* AvalonPublishInstance = NewObject(InParent, Class, Name, Flags); - return AvalonPublishInstance; -} - -bool UAvalonPublishInstanceFactory::ShouldShowInNewMenu() const { - return false; -} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp deleted file mode 100644 index 8642ab6b63..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPythonBridge.cpp +++ /dev/null @@ -1,13 +0,0 @@ -#include "AvalonPythonBridge.h" - -UAvalonPythonBridge* UAvalonPythonBridge::Get() -{ - TArray AvalonPythonBridgeClasses; - GetDerivedClasses(UAvalonPythonBridge::StaticClass(), AvalonPythonBridgeClasses); - int32 NumClasses = AvalonPythonBridgeClasses.Num(); - if (NumClasses > 0) - { - return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); - } - return nullptr; -}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp b/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp deleted file mode 100644 index 5b3d1269b0..0000000000 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonStyle.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "AvalonStyle.h" -#include "Framework/Application/SlateApplication.h" -#include "Styling/SlateStyle.h" -#include "Styling/SlateStyleRegistry.h" - - -TUniquePtr< FSlateStyleSet > FAvalonStyle::AvalonStyleInstance = nullptr; - -void FAvalonStyle::Initialize() -{ - if (!AvalonStyleInstance.IsValid()) - { - AvalonStyleInstance = Create(); - FSlateStyleRegistry::RegisterSlateStyle(*AvalonStyleInstance); - } -} - -void FAvalonStyle::Shutdown() -{ - if (AvalonStyleInstance.IsValid()) - { - FSlateStyleRegistry::UnRegisterSlateStyle(*AvalonStyleInstance); - AvalonStyleInstance.Reset(); - } -} - -FName FAvalonStyle::GetStyleSetName() -{ - static FName StyleSetName(TEXT("AvalonStyle")); - return StyleSetName; -} - -FName FAvalonStyle::GetContextName() -{ - static FName ContextName(TEXT("OpenPype")); - return ContextName; -} - -#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) - -const FVector2D Icon40x40(40.0f, 40.0f); - -TUniquePtr< FSlateStyleSet > FAvalonStyle::Create() -{ - TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); - Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("Avalon/Resources")); - - return Style; -} - -void FAvalonStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) -{ - FSlateStyleSet* Style = AvalonStyleInstance.Get(); - - FString Name(GetContextName().ToString()); - Name = Name + "." + StyleName; - Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); - - - FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); -} - -#undef IMAGE_BRUSH - -const ISlateStyle& FAvalonStyle::Get() -{ - check(AvalonStyleInstance); - return *AvalonStyleInstance; -} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs similarity index 96% rename from openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs rename to openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs index 5068e37d80..cf50041aed 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Avalon.Build.cs +++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs @@ -2,7 +2,7 @@ using UnrealBuildTool; -public class Avalon : ModuleRules +public class OpenPype : ModuleRules { public Avalon(ReadOnlyTargetRules Target) : base(Target) { diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainer.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainer.cpp diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AssetContainerFactory.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/AssetContainerFactory.cpp diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp similarity index 56% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp index ed782f4870..65da780ad6 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/Avalon.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp @@ -1,19 +1,19 @@ #include "Avalon.h" #include "LevelEditor.h" -#include "AvalonPythonBridge.h" -#include "AvalonStyle.h" +#include "OpenPypePythonBridge.h" +#include "OpenPypeStyle.h" -static const FName AvalonTabName("Avalon"); +static const FName OpenPypeTabName("OpenPype"); -#define LOCTEXT_NAMESPACE "FAvalonModule" +#define LOCTEXT_NAMESPACE "FOpenPypeModule" // This function is triggered when the plugin is staring up -void FAvalonModule::StartupModule() +void FOpenPypeModule::StartupModule() { - FAvalonStyle::Initialize(); - FAvalonStyle::SetIcon("Logo", "openpype40"); + FOpenPypeStyle::Initialize(); + FOpenPypeStyle::SetIcon("Logo", "openpype40"); // Create the Extender that will add content to the menu FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked("LevelEditor"); @@ -25,13 +25,13 @@ void FAvalonModule::StartupModule() "LevelEditor", EExtensionHook::After, NULL, - FMenuExtensionDelegate::CreateRaw(this, &FAvalonModule::AddMenuEntry) + FMenuExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddMenuEntry) ); ToolbarExtender->AddToolBarExtension( "Settings", EExtensionHook::After, NULL, - FToolBarExtensionDelegate::CreateRaw(this, &FAvalonModule::AddToobarEntry)); + FToolBarExtensionDelegate::CreateRaw(this, &FOpenPypeModule::AddToobarEntry)); LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender); @@ -39,13 +39,13 @@ void FAvalonModule::StartupModule() } -void FAvalonModule::ShutdownModule() +void FOpenPypeModule::ShutdownModule() { - FAvalonStyle::Shutdown(); + FOpenPypeStyle::Shutdown(); } -void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) +void FOpenPypeModule::AddMenuEntry(FMenuBuilder& MenuBuilder) { // Create Section MenuBuilder.BeginSection("OpenPype", TAttribute(FText::FromString("OpenPype"))); @@ -54,22 +54,22 @@ void FAvalonModule::AddMenuEntry(FMenuBuilder& MenuBuilder) MenuBuilder.AddMenuEntry( FText::FromString("Tools..."), FText::FromString("Pipeline tools"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup)) + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup)) ); MenuBuilder.AddMenuEntry( FText::FromString("Tools dialog..."), FText::FromString("Pipeline tools dialog"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo"), - FUIAction(FExecuteAction::CreateRaw(this, &FAvalonModule::MenuDialog)) + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo"), + FUIAction(FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuDialog)) ); } MenuBuilder.EndSection(); } -void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) +void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) { ToolbarBuilder.BeginSection(TEXT("OpenPype")); { @@ -83,21 +83,21 @@ void FAvalonModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) NAME_None, LOCTEXT("OpenPype_label", "OpenPype"), LOCTEXT("OpenPype_tooltip", "OpenPype Tools"), - FSlateIcon(FAvalonStyle::GetStyleSetName(), "OpenPype.Logo") + FSlateIcon(FOpenPypeStyle::GetStyleSetName(), "OpenPype.Logo") ); } ToolbarBuilder.EndSection(); } -void FAvalonModule::MenuPopup() { - UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); +void FOpenPypeModule::MenuPopup() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); bridge->RunInPython_Popup(); } -void FAvalonModule::MenuDialog() { - UAvalonPythonBridge* bridge = UAvalonPythonBridge::Get(); +void FOpenPypeModule::MenuDialog() { + UOpenPypePythonBridge* bridge = UOpenPypePythonBridge::Get(); bridge->RunInPython_Dialog(); } -IMPLEMENT_MODULE(FAvalonModule, Avalon) +IMPLEMENT_MODULE(FOpenPypeModule, OpenPype) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp similarity index 84% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp index 312656424c..5facab7b8b 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonLib.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeLib.cpp @@ -1,4 +1,4 @@ -#include "AvalonLib.h" +#include "OpenPypeLib.h" #include "Misc/Paths.h" #include "Misc/ConfigCacheIni.h" #include "UObject/UnrealType.h" @@ -10,7 +10,7 @@ * @warning This color will appear only after Editor restart. Is there a better way? */ -void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) +void UOpenPypeLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, bool bForceAdd) { auto SaveColorInternal = [](FString InPath, FLinearColor InFolderColor) { @@ -30,7 +30,7 @@ void UAvalonLib::CSetFolderColor(FString FolderPath, FLinearColor FolderColor, b * @param cls - class * @return TArray of properties */ -TArray UAvalonLib::GetAllProperties(UClass* cls) +TArray UOpenPypeLib::GetAllProperties(UClass* cls) { TArray Ret; if (cls != nullptr) diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp similarity index 67% rename from openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp rename to openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp index 2bb31a4853..4f1e846c0b 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Private/AvalonPublishInstance.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstance.cpp @@ -1,28 +1,28 @@ #pragma once -#include "AvalonPublishInstance.h" +#include "OpenPypePublishInstance.h" #include "AssetRegistryModule.h" -UAvalonPublishInstance::UAvalonPublishInstance(const FObjectInitializer& ObjectInitializer) +UOpenPypePublishInstance::UOpenPypePublishInstance(const FObjectInitializer& ObjectInitializer) : UObject(ObjectInitializer) { FAssetRegistryModule& AssetRegistryModule = FModuleManager::LoadModuleChecked("AssetRegistry"); - FString path = UAvalonPublishInstance::GetPathName(); + FString path = UOpenPypePublishInstance::GetPathName(); FARFilter Filter; Filter.PackagePaths.Add(FName(*path)); - AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UAvalonPublishInstance::OnAssetAdded); - AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UAvalonPublishInstance::OnAssetRemoved); - AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UAvalonPublishInstance::OnAssetRenamed); + AssetRegistryModule.Get().OnAssetAdded().AddUObject(this, &UOpenPypePublishInstance::OnAssetAdded); + AssetRegistryModule.Get().OnAssetRemoved().AddUObject(this, &UOpenPypePublishInstance::OnAssetRemoved); + AssetRegistryModule.Get().OnAssetRenamed().AddUObject(this, &UOpenPypePublishInstance::OnAssetRenamed); } -void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) +void UOpenPypePublishInstance::OnAssetAdded(const FAssetData& AssetData) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class @@ -38,7 +38,7 @@ void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) if (assetDir.StartsWith(*selfDir)) { // exclude self - if (assetFName != "AvalonPublishInstance") + if (assetFName != "OpenPypePublishInstance") { assets.Add(assetPath); UE_LOG(LogTemp, Log, TEXT("%s: asset added to %s"), *selfFullPath, *selfDir); @@ -46,12 +46,12 @@ void UAvalonPublishInstance::OnAssetAdded(const FAssetData& AssetData) } } -void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) +void UOpenPypePublishInstance::OnAssetRemoved(const FAssetData& AssetData) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class @@ -64,13 +64,13 @@ void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) FString assetDir = FPackageName::GetLongPackagePath(*split[1]); // take interest only in paths starting with path of current container - FString path = UAvalonPublishInstance::GetPathName(); + FString path = UOpenPypePublishInstance::GetPathName(); FString lpp = FPackageName::GetLongPackagePath(*path); if (assetDir.StartsWith(*selfDir)) { // exclude self - if (assetFName != "AvalonPublishInstance") + if (assetFName != "OpenPypePublishInstance") { // UE_LOG(LogTemp, Warning, TEXT("%s: asset removed"), *lpp); assets.Remove(assetPath); @@ -78,12 +78,12 @@ void UAvalonPublishInstance::OnAssetRemoved(const FAssetData& AssetData) } } -void UAvalonPublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) +void UOpenPypePublishInstance::OnAssetRenamed(const FAssetData& AssetData, const FString& str) { TArray split; // get directory of current container - FString selfFullPath = UAvalonPublishInstance::GetPathName(); + FString selfFullPath = UOpenPypePublishInstance::GetPathName(); FString selfDir = FPackageName::GetLongPackagePath(*selfFullPath); // get asset path and class diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp new file mode 100644 index 0000000000..e61964c689 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePublishInstanceFactory.cpp @@ -0,0 +1,20 @@ +#include "OpenPypePublishInstanceFactory.h" +#include "OpenPypePublishInstance.h" + +UOpenPypePublishInstanceFactory::UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer) + : UFactory(ObjectInitializer) +{ + SupportedClass = UOpenPypePublishInstance::StaticClass(); + bCreateNew = false; + bEditorImport = true; +} + +UObject* UOpenPypePublishInstanceFactory::FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UOpenPypePublishInstance* OpenPypePublishInstance = NewObject(InParent, Class, Name, Flags); + return OpenPypePublishInstance; +} + +bool UOpenPypePublishInstanceFactory::ShouldShowInNewMenu() const { + return false; +} diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp new file mode 100644 index 0000000000..767f089374 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -0,0 +1,13 @@ +#include "OpenPypePythonBridge.h" + +UOpenPypePythonBridge* UOpenPypePythonBridge::Get() +{ + TArray OpenPypePythonBridgeClasses; + GetDerivedClasses(UAvalonPythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + int32 NumClasses = OpenPypePythonBridgeClasses.Num(); + if (NumClasses > 0) + { + return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + } + return nullptr; +}; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp new file mode 100644 index 0000000000..a51c2d6aa5 --- /dev/null +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypeStyle.cpp @@ -0,0 +1,70 @@ +#include "OpenPypeStyle.h" +#include "Framework/Application/SlateApplication.h" +#include "Styling/SlateStyle.h" +#include "Styling/SlateStyleRegistry.h" + + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::OpenPypeStyleInstance = nullptr; + +void FOpenPypeStyle::Initialize() +{ + if (!OpenPypeStyleInstance.IsValid()) + { + OpenPypeStyleInstance = Create(); + FSlateStyleRegistry::RegisterSlateStyle(*OpenPypeStyleInstance); + } +} + +void FOpenPypeStyle::Shutdown() +{ + if (OpenPypeStyleInstance.IsValid()) + { + FSlateStyleRegistry::UnRegisterSlateStyle(*OpenPypeStyleInstance); + OpenPypeStyleInstance.Reset(); + } +} + +FName FOpenPypeStyle::GetStyleSetName() +{ + static FName StyleSetName(TEXT("OpenPypeStyle")); + return StyleSetName; +} + +FName FOpenPypeStyle::GetContextName() +{ + static FName ContextName(TEXT("OpenPype")); + return ContextName; +} + +#define IMAGE_BRUSH(RelativePath, ...) FSlateImageBrush( Style->RootToContentDir( RelativePath, TEXT(".png") ), __VA_ARGS__ ) + +const FVector2D Icon40x40(40.0f, 40.0f); + +TUniquePtr< FSlateStyleSet > FOpenPypeStyle::Create() +{ + TUniquePtr< FSlateStyleSet > Style = MakeUnique(GetStyleSetName()); + Style->SetContentRoot(FPaths::ProjectPluginsDir() / TEXT("OpenPype/Resources")); + + return Style; +} + +void FOpenPypeStyle::SetIcon(const FString& StyleName, const FString& ResourcePath) +{ + FSlateStyleSet* Style = OpenPypeStyleInstance.Get(); + + FString Name(GetContextName().ToString()); + Name = Name + "." + StyleName; + Style->Set(*Name, new FSlateImageBrush(Style->RootToContentDir(ResourcePath, TEXT(".png")), Icon40x40)); + + + FSlateApplication::Get().GetRenderer()->ReloadTextureResources(); +} + +#undef IMAGE_BRUSH + +const ISlateStyle& FOpenPypeStyle::Get() +{ + check(OpenPypeStyleInstance); + return *OpenPypeStyleInstance; + return *OpenPypeStyleInstance; +} diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h similarity index 100% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainer.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h similarity index 89% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h index 62b6e73640..331ce6bb50 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AssetContainerFactory.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainerFactory.h @@ -10,7 +10,7 @@ * */ UCLASS() -class AVALON_API UAssetContainerFactory : public UFactory +class OPENPYPE_API UAssetContainerFactory : public UFactory { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h similarity index 87% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h index 2dd6a825ab..db3f299354 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/Avalon.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPype.h @@ -5,7 +5,7 @@ #include "Engine.h" -class FAvalonModule : public IModuleInterface +class FOpenPypeModule : public IModuleInterface { public: virtual void StartupModule() override; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h similarity index 88% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h index da3369970c..3b4afe1408 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonLib.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h @@ -5,7 +5,7 @@ UCLASS(Blueprintable) -class AVALON_API UAvalonLib : public UObject +class OPENPYPE_API UOpenPypeLib : public UObject { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h similarity index 65% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h index 7678f78924..0a27a078d7 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstance.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstance.h @@ -1,16 +1,16 @@ #pragma once #include "Engine.h" -#include "AvalonPublishInstance.generated.h" +#include "OpenPypePublishInstance.generated.h" UCLASS(Blueprintable) -class AVALON_API UAvalonPublishInstance : public UObject +class OPENPYPE_API UOpenPypePublishInstance : public UObject { GENERATED_BODY() public: - UAvalonPublishInstance(const FObjectInitializer& ObjectInitalizer); + UOpenPypePublishInstance(const FObjectInitializer& ObjectInitalizer); UPROPERTY(EditAnywhere, BlueprintReadOnly) TArray assets; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h similarity index 61% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h index 79e781c60c..a2b3abe13e 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPublishInstanceFactory.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePublishInstanceFactory.h @@ -2,18 +2,18 @@ #include "CoreMinimal.h" #include "Factories/Factory.h" -#include "AvalonPublishInstanceFactory.generated.h" +#include "OpenPypePublishInstanceFactory.generated.h" /** * */ UCLASS() -class AVALON_API UAvalonPublishInstanceFactory : public UFactory +class OPENPYPE_API UOpenPypePublishInstanceFactory : public UFactory { GENERATED_BODY() public: - UAvalonPublishInstanceFactory(const FObjectInitializer& ObjectInitializer); + UOpenPypePublishInstanceFactory(const FObjectInitializer& ObjectInitializer); virtual UObject* FactoryCreateNew(UClass* Class, UObject* InParent, FName Name, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; virtual bool ShouldShowInNewMenu() const override; }; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h similarity index 71% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h index db4b16d53f..692aab2e5e 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonPythonBridge.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypePythonBridge.h @@ -1,15 +1,15 @@ #pragma once #include "Engine.h" -#include "AvalonPythonBridge.generated.h" +#include "OpenPypePythonBridge.generated.h" UCLASS(Blueprintable) -class UAvalonPythonBridge : public UObject +class UOpenPypePythonBridge : public UObject { GENERATED_BODY() public: UFUNCTION(BlueprintCallable, Category = Python) - static UAvalonPythonBridge* Get(); + static UOpenPypePythonBridge* Get(); UFUNCTION(BlueprintImplementableEvent, Category = Python) void RunInPython_Popup() const; diff --git a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h similarity index 95% rename from openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h rename to openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h index ffb2bc7aa4..0e9400406a 100644 --- a/openpype/hosts/unreal/integration/Source/Avalon/Public/AvalonStyle.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h @@ -5,7 +5,7 @@ class FSlateStyleSet; class ISlateStyle; -class FAvalonStyle +class FOpenPypeStyle { public: static void Initialize(); diff --git a/openpype/hosts/unreal/plugins/create/create_camera.py b/openpype/hosts/unreal/plugins/create/create_camera.py index eda2b52be3..c2905fb6dd 100644 --- a/openpype/hosts/unreal/plugins/create/create_camera.py +++ b/openpype/hosts/unreal/plugins/create/create_camera.py @@ -16,7 +16,7 @@ class CreateCamera(Creator): family = "camera" icon = "cubes" - root = "/Game/Avalon/Instances" + root = "/Game/OpenPype/Instances" suffix = "_INS" def __init__(self, *args, **kwargs): diff --git a/openpype/hosts/unreal/plugins/create/create_layout.py b/openpype/hosts/unreal/plugins/create/create_layout.py index 239b72787b..00e83cf433 100644 --- a/openpype/hosts/unreal/plugins/create/create_layout.py +++ b/openpype/hosts/unreal/plugins/create/create_layout.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- from unreal import EditorLevelLibrary as ell from openpype.hosts.unreal.api.plugin import Creator from avalon.unreal import ( @@ -6,7 +7,7 @@ from avalon.unreal import ( class CreateLayout(Creator): - """Layout output for character rigs""" + """Layout output for character rigs.""" name = "layoutMain" label = "Layout" diff --git a/openpype/hosts/unreal/plugins/create/create_look.py b/openpype/hosts/unreal/plugins/create/create_look.py index 7d3913b883..59c40d3e74 100644 --- a/openpype/hosts/unreal/plugins/create/create_look.py +++ b/openpype/hosts/unreal/plugins/create/create_look.py @@ -1,10 +1,12 @@ -import unreal +# -*- coding: utf-8 -*- +"""Create look in Unreal.""" +import unreal # noqa from openpype.hosts.unreal.api.plugin import Creator -from avalon.unreal import pipeline +from openpype.hosts.unreal.api import pipeline class CreateLook(Creator): - """Shader connections defining shape look""" + """Shader connections defining shape look.""" name = "unrealLook" label = "Unreal - Look" @@ -49,14 +51,14 @@ class CreateLook(Creator): for material in materials: name = material.get_editor_property('material_slot_name') object_path = f"{full_path}/{name}.{name}" - object = unreal.EditorAssetLibrary.duplicate_loaded_asset( + unreal_object = unreal.EditorAssetLibrary.duplicate_loaded_asset( cube.get_asset(), object_path ) # Remove the default material of the cube object - object.get_editor_property('static_materials').pop() + unreal_object.get_editor_property('static_materials').pop() - object.add_material( + unreal_object.add_material( material.get_editor_property('material_interface')) self.data["members"].append(object_path) diff --git a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py index 4cc67e0f1f..700eac7366 100644 --- a/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/create/create_staticmeshfbx.py @@ -1,12 +1,14 @@ -import unreal +# -*- coding: utf-8 -*- +"""Create Static Meshes as FBX geometry.""" +import unreal # noqa from openpype.hosts.unreal.api.plugin import Creator -from avalon.unreal import ( +from openpype.hosts.unreal.api.pipeline import ( instantiate, ) class CreateStaticMeshFBX(Creator): - """Static FBX geometry""" + """Static FBX geometry.""" name = "unrealStaticMeshMain" label = "Unreal - Static Mesh" diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index e2023e8b47..a0cd69326f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -1,12 +1,15 @@ +# -*- coding: utf-8 -*- +"""Loader for published alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline + +import unreal # noqa -class PointCacheAlembicLoader(api.Loader): +class PointCacheAlembicLoader(plugin.Loader): """Load Point Cache from Alembic""" families = ["model", "pointcache"] @@ -56,8 +59,7 @@ class PointCacheAlembicLoader(api.Loader): return task def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -76,10 +78,10 @@ class PointCacheAlembicLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -109,7 +111,7 @@ class PointCacheAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py index b652af0b89..0236bab138 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Load Skeletal Mesh alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class SkeletalMeshAlembicLoader(api.Loader): +class SkeletalMeshAlembicLoader(plugin.Loader): """Load Unreal SkeletalMesh from Alembic""" families = ["pointcache"] @@ -16,8 +18,7 @@ class SkeletalMeshAlembicLoader(api.Loader): color = "orange" def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -38,8 +39,8 @@ class SkeletalMeshAlembicLoader(api.Loader): list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and openpype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -74,7 +75,7 @@ class SkeletalMeshAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index ccec31b832..aec8b45041 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Loader for Static Mesh alembics.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class StaticMeshAlembicLoader(api.Loader): +class StaticMeshAlembicLoader(plugin.Loader): """Load Unreal StaticMesh from Alembic""" families = ["model"] @@ -49,8 +51,7 @@ class StaticMeshAlembicLoader(api.Loader): return task def load(self, context, name, namespace, data): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -69,10 +70,10 @@ class StaticMeshAlembicLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -93,7 +94,7 @@ class StaticMeshAlembicLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 20baa30847..63c734b969 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -1,14 +1,16 @@ +# -*- coding: utf-8 -*- +"""Load FBX with animations.""" import os import json from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class AnimationFBXLoader(api.Loader): - """Load Unreal SkeletalMesh from FBX""" +class AnimationFBXLoader(plugin.Loader): + """Load Unreal SkeletalMesh from FBX.""" families = ["animation"] label = "Import FBX Animation" @@ -37,10 +39,10 @@ class AnimationFBXLoader(api.Loader): Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -62,9 +64,9 @@ class AnimationFBXLoader(api.Loader): task = unreal.AssetImportTask() task.options = unreal.FbxImportUI() - libpath = self.fname.replace("fbx", "json") + lib_path = self.fname.replace("fbx", "json") - with open(libpath, "r") as fp: + with open(lib_path, "r") as fp: data = json.load(fp) instance_name = data.get("instance_name") @@ -127,7 +129,7 @@ class AnimationFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index b2b25eec73..c6bcfa08a9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Load camera from FBX.""" import os from avalon import api, io, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class CameraLoader(api.Loader): +class CameraLoader(plugin.Loader): """Load Unreal StaticMesh from FBX""" families = ["camera"] @@ -38,8 +40,8 @@ class CameraLoader(api.Loader): list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -109,7 +111,7 @@ class CameraLoader(api.Loader): ) # Create Asset Container - lib.create_avalon_container(container=container_name, path=asset_dir) + unreal_pipeline.create_container(container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 19d0b74e3e..a5e93a009f 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Loader for layouts.""" import os import json from pathlib import Path @@ -10,11 +12,11 @@ from unreal import FBXImportType from unreal import MathLibrary as umath from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline -class LayoutLoader(api.Loader): +class LayoutLoader(plugin.Loader): """Load Layout from a JSON file""" families = ["layout"] @@ -23,6 +25,7 @@ class LayoutLoader(api.Loader): label = "Load Layout" icon = "code-fork" color = "orange" + ASSET_ROOT = "/Game/OpenPype/Assets" def _get_asset_containers(self, path): ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -40,7 +43,8 @@ class LayoutLoader(api.Loader): return asset_containers - def _get_fbx_loader(self, loaders, family): + @staticmethod + def _get_fbx_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshFBXLoader" @@ -58,7 +62,8 @@ class LayoutLoader(api.Loader): return None - def _get_abc_loader(self, loaders, family): + @staticmethod + def _get_abc_loader(loaders, family): name = "" if family == 'rig': name = "SkeletalMeshAlembicLoader" @@ -74,14 +79,15 @@ class LayoutLoader(api.Loader): return None - def _process_family(self, assets, classname, transform, inst_name=None): + @staticmethod + def _process_family(assets, class_name, transform, inst_name=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() actors = [] for asset in assets: obj = ar.get_asset_by_object_path(asset).get_asset() - if obj.get_class().get_name() == classname: + if obj.get_class().get_name() == class_name: actor = EditorLevelLibrary.spawn_actor_from_object( obj, transform.get('translation') @@ -111,8 +117,9 @@ class LayoutLoader(api.Loader): return actors + @staticmethod def _import_animation( - self, asset_dir, path, instance_name, skeleton, actors_dict, + asset_dir, path, instance_name, skeleton, actors_dict, animation_file): anim_file = Path(animation_file) anim_file_name = anim_file.with_suffix('') @@ -192,10 +199,10 @@ class LayoutLoader(api.Loader): actor.skeletal_mesh_component.animation_data.set_editor_property( 'anim_to_play', animation) - def _process(self, libpath, asset_dir, loaded=None): + def _process(self, lib_path, asset_dir, loaded=None): ar = unreal.AssetRegistryHelpers.get_asset_registry() - with open(libpath, "r") as fp: + with open(lib_path, "r") as fp: data = json.load(fp) all_loaders = api.discover(api.Loader) @@ -203,7 +210,7 @@ class LayoutLoader(api.Loader): if not loaded: loaded = [] - path = Path(libpath) + path = Path(lib_path) skeleton_dict = {} actors_dict = {} @@ -292,17 +299,18 @@ class LayoutLoader(api.Loader): asset_dir, path, instance_name, skeleton, actors_dict, animation_file) - def _remove_family(self, assets, components, classname, propname): + @staticmethod + def _remove_family(assets, components, class_name, prop_name): ar = unreal.AssetRegistryHelpers.get_asset_registry() objects = [] for a in assets: obj = ar.get_asset_by_object_path(a) - if obj.get_asset().get_class().get_name() == classname: + if obj.get_asset().get_class().get_name() == class_name: objects.append(obj) for obj in objects: for comp in components: - if comp.get_editor_property(propname) == obj.get_asset(): + if comp.get_editor_property(prop_name) == obj.get_asset(): comp.get_owner().destroy_actor() def _remove_actors(self, path): @@ -334,8 +342,7 @@ class LayoutLoader(api.Loader): assets, skel_meshes_comp, 'SkeletalMesh', 'skeletal_mesh') def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -349,14 +356,14 @@ class LayoutLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + root = self.ASSET_ROOT asset = context.get('asset').get('name') suffix = "_CON" if asset: @@ -375,7 +382,7 @@ class LayoutLoader(api.Loader): self._process(self.fname, asset_dir) # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { @@ -406,7 +413,7 @@ class LayoutLoader(api.Loader): source_path = api.get_representation_path(representation) destination_path = container["namespace"] - libpath = Path(api.get_representation_path(representation)) + lib_path = Path(api.get_representation_path(representation)) self._remove_actors(destination_path) @@ -502,7 +509,7 @@ class LayoutLoader(api.Loader): if animation_file and skeleton: self._import_animation( - destination_path, libpath, + destination_path, lib_path, instance_name, skeleton, actors_dict, animation_file) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index c7d095aa21..1503477ec7 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -1,13 +1,15 @@ +# -*- coding: utf-8 -*- +"""Load Skeletal Meshes form FBX.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class SkeletalMeshFBXLoader(api.Loader): - """Load Unreal SkeletalMesh from FBX""" +class SkeletalMeshFBXLoader(plugin.Loader): + """Load Unreal SkeletalMesh from FBX.""" families = ["rig"] label = "Import FBX Skeletal Mesh" @@ -16,8 +18,7 @@ class SkeletalMeshFBXLoader(api.Loader): color = "orange" def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -31,15 +32,15 @@ class SkeletalMeshFBXLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content - """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + """ + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" if options and options.get("asset_dir"): root = options["asset_dir"] asset = context.get('asset').get('name') @@ -94,7 +95,7 @@ class SkeletalMeshFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 510c4331ad..14ca39c728 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -1,13 +1,15 @@ +# -*- coding: utf-8 -*- +"""Load Static meshes form FBX.""" import os from avalon import api, pipeline -from avalon.unreal import lib -from avalon.unreal import pipeline as unreal_pipeline -import unreal +from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import pipeline as unreal_pipeline +import unreal # noqa -class StaticMeshFBXLoader(api.Loader): - """Load Unreal StaticMesh from FBX""" +class StaticMeshFBXLoader(plugin.Loader): + """Load Unreal StaticMesh from FBX.""" families = ["model", "unrealStaticMesh"] label = "Import FBX Static Mesh" @@ -15,7 +17,8 @@ class StaticMeshFBXLoader(api.Loader): icon = "cube" color = "orange" - def get_task(self, filename, asset_dir, asset_name, replace): + @staticmethod + def get_task(filename, asset_dir, asset_name, replace): task = unreal.AssetImportTask() options = unreal.FbxImportUI() import_data = unreal.FbxStaticMeshImportData() @@ -41,8 +44,7 @@ class StaticMeshFBXLoader(api.Loader): return task def load(self, context, name, namespace, options): - """ - Load and containerise representation into Content Browser. + """Load and containerise representation into Content Browser. This is two step process. First, import FBX to temporary path and then call `containerise()` on it - this moves all content to new @@ -56,15 +58,15 @@ class StaticMeshFBXLoader(api.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - data (dict): Those would be data to be imprinted. This is not used + options (dict): Those would be data to be imprinted. This is not used now, data are imprinted by `containerise()`. Returns: list(str): list of container content """ - # Create directory for asset and avalon container - root = "/Game/Avalon/Assets" + # Create directory for asset and OpenPype container + root = "/Game/OpenPype/Assets" if options and options.get("asset_dir"): root = options["asset_dir"] asset = context.get('asset').get('name') @@ -87,7 +89,7 @@ class StaticMeshFBXLoader(api.Loader): unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501 # Create Asset Container - lib.create_avalon_container( + unreal_pipeline.create_container( container=container_name, path=asset_dir) data = { diff --git a/openpype/hosts/unreal/plugins/publish/collect_current_file.py b/openpype/hosts/unreal/plugins/publish/collect_current_file.py index 4e828933bb..acd4c5c8d2 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_current_file.py +++ b/openpype/hosts/unreal/plugins/publish/collect_current_file.py @@ -1,17 +1,18 @@ -import unreal - +# -*- coding: utf-8 -*- +"""Collect current project path.""" +import unreal # noqa import pyblish.api class CollectUnrealCurrentFile(pyblish.api.ContextPlugin): - """Inject the current working file into context""" + """Inject the current working file into context.""" order = pyblish.api.CollectorOrder - 0.5 label = "Unreal Current File" hosts = ['unreal'] def process(self, context): - """Inject the current working file""" + """Inject the current working file.""" current_file = unreal.Paths.get_project_file_path() context.data['currentFile'] = current_file diff --git a/openpype/hosts/unreal/plugins/publish/collect_instances.py b/openpype/hosts/unreal/plugins/publish/collect_instances.py index 62676f9938..94e732d728 100644 --- a/openpype/hosts/unreal/plugins/publish/collect_instances.py +++ b/openpype/hosts/unreal/plugins/publish/collect_instances.py @@ -1,12 +1,14 @@ +# -*- coding: utf-8 -*- +"""Collect publishable instances in Unreal.""" import ast -import unreal +import unreal # noqa import pyblish.api class CollectInstances(pyblish.api.ContextPlugin): - """Gather instances by AvalonPublishInstance class + """Gather instances by OpenPypePublishInstance class - This collector finds all paths containing `AvalonPublishInstance` class + This collector finds all paths containing `OpenPypePublishInstance` class asset Identifier: @@ -22,7 +24,7 @@ class CollectInstances(pyblish.api.ContextPlugin): ar = unreal.AssetRegistryHelpers.get_asset_registry() instance_containers = ar.get_assets_by_class( - "AvalonPublishInstance", True) + "OpenPypePublishInstance", True) for container_data in instance_containers: asset = container_data.get_asset() diff --git a/openpype/hosts/unreal/plugins/publish/extract_camera.py b/openpype/hosts/unreal/plugins/publish/extract_camera.py index 10862fc0ef..ce53824563 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_camera.py +++ b/openpype/hosts/unreal/plugins/publish/extract_camera.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Extract camera from Unreal.""" import os import unreal @@ -17,7 +19,7 @@ class ExtractCamera(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) fbx_filename = "{}.fbx".format(instance.name) # Perform extraction @@ -38,7 +40,7 @@ class ExtractCamera(openpype.api.Extractor): sequence, sequence.get_bindings(), unreal.FbxExportOption(), - os.path.join(stagingdir, fbx_filename) + os.path.join(staging_dir, fbx_filename) ) break @@ -49,6 +51,6 @@ class ExtractCamera(openpype.api.Extractor): 'name': 'fbx', 'ext': 'fbx', 'files': fbx_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(fbx_representation) diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index a47187cf47..2d09b0e7bd 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import os import json import math @@ -20,7 +21,7 @@ class ExtractLayout(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) # Perform extraction self.log.info("Performing extraction..") @@ -96,7 +97,7 @@ class ExtractLayout(openpype.api.Extractor): json_data.append(json_element) json_filename = "{}.json".format(instance.name) - json_path = os.path.join(stagingdir, json_filename) + json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) @@ -108,6 +109,6 @@ class ExtractLayout(openpype.api.Extractor): 'name': 'json', 'ext': 'json', 'files': json_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) diff --git a/openpype/hosts/unreal/plugins/publish/extract_look.py b/openpype/hosts/unreal/plugins/publish/extract_look.py index 0f1539a7d5..ea39949417 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_look.py +++ b/openpype/hosts/unreal/plugins/publish/extract_look.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import json import os @@ -17,7 +18,7 @@ class ExtractLook(openpype.api.Extractor): def process(self, instance): # Define extract output file path - stagingdir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) resources_dir = instance.data["resourcesDir"] ar = unreal.AssetRegistryHelpers.get_asset_registry() @@ -57,7 +58,7 @@ class ExtractLook(openpype.api.Extractor): tga_export_task.set_editor_property('automated', True) tga_export_task.set_editor_property('object', texture) tga_export_task.set_editor_property( - 'filename', f"{stagingdir}/{tga_filename}") + 'filename', f"{staging_dir}/{tga_filename}") tga_export_task.set_editor_property('prompt', False) tga_export_task.set_editor_property('selected', False) @@ -66,7 +67,7 @@ class ExtractLook(openpype.api.Extractor): json_element['tga_filename'] = tga_filename transfers.append(( - f"{stagingdir}/{tga_filename}", + f"{staging_dir}/{tga_filename}", f"{resources_dir}/{tga_filename}")) fbx_filename = f"{instance.name}_{name}.fbx" @@ -84,7 +85,7 @@ class ExtractLook(openpype.api.Extractor): task.set_editor_property('automated', True) task.set_editor_property('object', object) task.set_editor_property( - 'filename', f"{stagingdir}/{fbx_filename}") + 'filename', f"{staging_dir}/{fbx_filename}") task.set_editor_property('prompt', False) task.set_editor_property('selected', False) @@ -93,13 +94,13 @@ class ExtractLook(openpype.api.Extractor): json_element['fbx_filename'] = fbx_filename transfers.append(( - f"{stagingdir}/{fbx_filename}", + f"{staging_dir}/{fbx_filename}", f"{resources_dir}/{fbx_filename}")) json_data.append(json_element) json_filename = f"{instance.name}.json" - json_path = os.path.join(stagingdir, json_filename) + json_path = os.path.join(staging_dir, json_filename) with open(json_path, "w+") as file: json.dump(json_data, fp=file, indent=2) @@ -113,7 +114,7 @@ class ExtractLook(openpype.api.Extractor): 'name': 'json', 'ext': 'json', 'files': json_filename, - "stagingDir": stagingdir, + "stagingDir": staging_dir, } instance.data["representations"].append(json_representation) From de8e1f821859def926995381403504368f6b3ba9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 1 Mar 2022 11:54:05 +0100 Subject: [PATCH 126/152] globa: fix host name retrieving from running session --- openpype/lib/avalon_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 3ce205c499..1e8d21852b 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -952,7 +952,7 @@ class BuildWorkfile: Returns: (dict): preset per entered task name """ - host_name = avalon.api.registered_host().__name__.rsplit(".", 1)[-1] + host_name = os.environ["AVALON_APP"] project_settings = get_project_settings( avalon.io.Session["AVALON_PROJECT"] ) From 3c9501ac3ceb471f98f602b52baf2aa73d2b2434 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:32:21 +0100 Subject: [PATCH 127/152] remove submodule, minor fixes --- openpype/hosts/unreal/api/__init__.py | 40 +++++++++++++++++++ .../integration/Content/Python/init_unreal.py | 4 +- .../Source/OpenPype/OpenPype.Build.cs | 2 +- .../Source/OpenPype/Private/OpenPype.cpp | 4 +- .../OpenPype/Private/OpenPypePythonBridge.cpp | 4 +- .../Source/OpenPype/Public/AssetContainer.h | 2 +- .../Source/OpenPype/Public/OpenPypeLib.h | 2 +- .../Source/OpenPype/Public/OpenPypeStyle.h | 2 +- repos/avalon-unreal-integration | 1 - 9 files changed, 51 insertions(+), 10 deletions(-) delete mode 160000 repos/avalon-unreal-integration diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index df86c09073..e70749004b 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -4,6 +4,26 @@ import logging from avalon import api as avalon from pyblish import api as pyblish import openpype.hosts.unreal +from .plugin import( + Loader, + Creator +) +from .pipeline import ( + install, + uninstall, + ls, + publish, + containerise, + show_creator, + show_loader, + show_publisher, + show_manager, + show_experimental_tools, + show_tools_dialog, + show_tools_popup, + instantiate, +) + logger = logging.getLogger("openpype.hosts.unreal") @@ -15,6 +35,26 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") +__all__ = [ + "install", + "uninstall", + "Creator", + "Loader", + "ls", + "publish", + "containerise", + "show_creator", + "show_loader", + "show_publisher", + "show_manager", + "show_experimental_tools", + "show_tools_dialog", + "show_tools_popup", + "instantiate" +] + + + def install(): """Install Unreal configuration for OpenPype.""" print("-=" * 40) diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py index 4445abb1b0..2ecd301c25 100644 --- a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -4,12 +4,14 @@ openpype_detected = True try: from avalon import api except ImportError as exc: + api = None openpype_detected = False unreal.log_error("Avalon: cannot load Avalon [ {} ]".format(exc)) try: - from openpype.host.unreal import api as openpype_host + from openpype.hosts.unreal import api as openpype_host except ImportError as exc: + openpype_host = None openpype_detected = False unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs index cf50041aed..c30835b63d 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs +++ b/openpype/hosts/unreal/integration/Source/OpenPype/OpenPype.Build.cs @@ -4,7 +4,7 @@ using UnrealBuildTool; public class OpenPype : ModuleRules { - public Avalon(ReadOnlyTargetRules Target) : base(Target) + public OpenPype(ReadOnlyTargetRules Target) : base(Target) { PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp index 65da780ad6..15c46b3862 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPype.cpp @@ -1,4 +1,4 @@ -#include "Avalon.h" +#include "OpenPype.h" #include "LevelEditor.h" #include "OpenPypePythonBridge.h" #include "OpenPypeStyle.h" @@ -75,7 +75,7 @@ void FOpenPypeModule::AddToobarEntry(FToolBarBuilder& ToolbarBuilder) { ToolbarBuilder.AddToolBarButton( FUIAction( - FExecuteAction::CreateRaw(this, &FAvalonModule::MenuPopup), + FExecuteAction::CreateRaw(this, &FOpenPypeModule::MenuPopup), NULL, FIsActionChecked() diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp index 767f089374..8113231503 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Private/OpenPypePythonBridge.cpp @@ -3,11 +3,11 @@ UOpenPypePythonBridge* UOpenPypePythonBridge::Get() { TArray OpenPypePythonBridgeClasses; - GetDerivedClasses(UAvalonPythonBridge::StaticClass(), OpenPypePythonBridgeClasses); + GetDerivedClasses(UOpenPypePythonBridge::StaticClass(), OpenPypePythonBridgeClasses); int32 NumClasses = OpenPypePythonBridgeClasses.Num(); if (NumClasses > 0) { - return Cast(AvalonPythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); + return Cast(OpenPypePythonBridgeClasses[NumClasses - 1]->GetDefaultObject()); } return nullptr; }; \ No newline at end of file diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h index 1195f95cba..3c2a360c78 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/AssetContainer.h @@ -12,7 +12,7 @@ * */ UCLASS(Blueprintable) -class AVALON_API UAssetContainer : public UAssetUserData +class OPENPYPE_API UAssetContainer : public UAssetUserData { GENERATED_BODY() diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h index 3b4afe1408..59e9c8bd76 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeLib.h @@ -1,7 +1,7 @@ #pragma once #include "Engine.h" -#include "AvalonLib.generated.h" +#include "OpenPypeLib.generated.h" UCLASS(Blueprintable) diff --git a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h index 0e9400406a..fbc8bcdd5b 100644 --- a/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h +++ b/openpype/hosts/unreal/integration/Source/OpenPype/Public/OpenPypeStyle.h @@ -18,5 +18,5 @@ public: private: static TUniquePtr< FSlateStyleSet > Create(); - static TUniquePtr< FSlateStyleSet > AvalonStyleInstance; + static TUniquePtr< FSlateStyleSet > OpenPypeStyleInstance; }; \ No newline at end of file diff --git a/repos/avalon-unreal-integration b/repos/avalon-unreal-integration deleted file mode 160000 index 43f6ea9439..0000000000 --- a/repos/avalon-unreal-integration +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 43f6ea943980b29c02a170942b566ae11f2b7080 From 4fbf8f90319ce91b88025f39d1bee5125402139d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:42:28 +0100 Subject: [PATCH 128/152] =?UTF-8?q?fix=20=F0=9F=90=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/api/__init__.py | 2 +- openpype/hosts/unreal/api/helpers.py | 2 +- .../hosts/unreal/plugins/load/load_alembic_geometrycache.py | 2 +- openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py | 2 +- openpype/hosts/unreal/plugins/load/load_camera.py | 3 ++- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index e70749004b..1aad704c56 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -4,7 +4,7 @@ import logging from avalon import api as avalon from pyblish import api as pyblish import openpype.hosts.unreal -from .plugin import( +from .plugin import ( Loader, Creator ) diff --git a/openpype/hosts/unreal/api/helpers.py b/openpype/hosts/unreal/api/helpers.py index 555133eae0..0b6f07f52f 100644 --- a/openpype/hosts/unreal/api/helpers.py +++ b/openpype/hosts/unreal/api/helpers.py @@ -15,7 +15,7 @@ class OpenPypeHelpers(unreal.OpenPypeLib): """ @unreal.ufunction(params=[str, unreal.LinearColor, bool]) - def set_folder_color(self, path: str, color: unreal.LinearColor) -> Bool: + def set_folder_color(self, path: str, color: unreal.LinearColor) -> None: """Set color on folder in Content Browser. This method sets color on folder in Content Browser. Unfortunately diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index a0cd69326f..027e9f4cd3 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index aec8b45041..3bcc8b476f 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index c6bcfa08a9..34999faa23 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -111,7 +111,8 @@ class CameraLoader(plugin.Loader): ) # Create Asset Container - unreal_pipeline.create_container(container=container_name, path=asset_dir) + unreal_pipeline.create_container( + container=container_name, path=asset_dir) data = { "schema": "openpype:container-2.0", From c654353c73b26c8a18273f9f98cbcc7f7b186fda Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:46:10 +0100 Subject: [PATCH 129/152] =?UTF-8?q?fix=20=F0=9F=90=95=E2=80=8D=F0=9F=A6=BA?= =?UTF-8?q?=20round=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/plugins/load/load_camera.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_layout.py | 6 +++--- openpype/hosts/unreal/plugins/load/load_rig.py | 2 +- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 34999faa23..0de9470ef9 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -2,8 +2,8 @@ """Load camera from FBX.""" import os -from avalon import api, io, pipeline -from openpype.hosts.unreal.api import lib, plugin +from avalon import io, pipeline +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index a5e93a009f..b802f5940a 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -12,7 +12,7 @@ from unreal import FBXImportType from unreal import MathLibrary as umath from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -356,8 +356,8 @@ class LayoutLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 1503477ec7..009d6bc656 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 14ca39c728..573e5bd7e6 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -3,7 +3,7 @@ import os from avalon import api, pipeline -from openpype.hosts.unreal.api import lib, plugin +from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa From 5479a26b216507230694405156a6466e53bf5209 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 13:47:51 +0100 Subject: [PATCH 130/152] =?UTF-8?q?fix=20=F0=9F=90=A9=20round=203?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/unreal/plugins/load/load_rig.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 009d6bc656..a7ecb0ef7d 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -32,8 +32,8 @@ class SkeletalMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 573e5bd7e6..c8a6964ffb 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -58,8 +58,8 @@ class StaticMeshFBXLoader(plugin.Loader): This is not passed here, so namespace is set by `containerise()` because only then we know real path. - options (dict): Those would be data to be imprinted. This is not used - now, data are imprinted by `containerise()`. + options (dict): Those would be data to be imprinted. This is not + used now, data are imprinted by `containerise()`. Returns: list(str): list of container content From 6a463bfbb455321b90413b5795263f18e9d7c9b0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 1 Mar 2022 14:35:35 +0100 Subject: [PATCH 131/152] OL-2799 - more detailed temp file name for environment json for Deadline Previous implementation probably wasn't detailed enough. --- .../repository/custom/plugins/GlobalJobPreLoad.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index ee137a2ee3..82c2494e7a 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- import os import tempfile -import time +from datetime import datetime import subprocess import json import platform +import uuid from Deadline.Scripting import RepositoryUtils, FileUtils @@ -36,9 +37,11 @@ def inject_openpype_environment(deadlinePlugin): print("--- OpenPype executable: {}".format(openpype_app)) # tempfile.TemporaryFile cannot be used because of locking - export_url = os.path.join(tempfile.gettempdir(), - time.strftime('%Y%m%d%H%M%S'), - 'env.json') # add HHMMSS + delete later + temp_file_name = "{}_{}.json".format( + datetime.utcnow().strftime('%Y%m%d%H%M%S%f'), + str(uuid.uuid1()) + ) + export_url = os.path.join(tempfile.gettempdir(), temp_file_name) print(">>> Temporary path: {}".format(export_url)) args = [ From 5d896b97de964aab19b279f579222f0483192a2e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 15:00:13 +0100 Subject: [PATCH 132/152] make sure that result of '_get_versions_order_doc' is dictionary --- openpype/settings/handlers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index 9f2b46d758..2109b53b09 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -694,7 +694,7 @@ class MongoSettingsHandler(SettingsHandler): return self.collection.find_one( {"type": self._version_order_key}, projection - ) + ) or {} def _check_version_order(self): """This method will work only in OpenPype process. From be1ae4a99c5fa95b78714e33c5aba0e3ea58fd02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 15:01:25 +0100 Subject: [PATCH 133/152] replace _Callback with functools.partial --- openpype/tools/settings/settings/base.py | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index d4ad84996c..706e2fdcf0 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -1,6 +1,7 @@ import sys import json import traceback +import functools from Qt import QtWidgets, QtGui, QtCore @@ -12,22 +13,6 @@ from .lib import create_deffered_value_change_timer from .constants import DEFAULT_PROJECT_LABEL -class _Callback: - """Callback wrapper which stores it's args and kwargs. - - Using lambda has few issues if local variables are passed to called - functions in loop it may change the value of the variable in already - stored callback. - """ - def __init__(self, func, *args, **kwargs): - self._func = func - self._args = args - self._kwargs = kwargs - - def __call__(self): - self._func(*self._args, **self._kwargs) - - class BaseWidget(QtWidgets.QWidget): allow_actions = True @@ -341,10 +326,7 @@ class BaseWidget(QtWidgets.QWidget): action = QtWidgets.QAction(project_name) submenu.addAction(action) - # Use custom callback object instead of lambda - # - project_name value is changed each value so all actions will - # use the same source project - actions_mapping[action] = _Callback( + actions_mapping[action] = functools.partial( self._apply_values_from_project, project_name ) From ed526947ebb717d830da9f876e57b78ec6c6bed3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 1 Mar 2022 15:32:46 +0100 Subject: [PATCH 134/152] fix adding 'root' key to format data --- openpype/lib/anatomy.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index 3d56c1f1ba..3fbc05ee88 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -726,10 +726,11 @@ class AnatomyTemplates(TemplatesDict): return output def format(self, data, strict=True): + copy_data = copy.deepcopy(data) roots = self.roots if roots: - data["root"] = roots - result = super(AnatomyTemplates, self).format(data) + copy_data["root"] = roots + result = super(AnatomyTemplates, self).format(copy_data) result.strict = strict return result From 32963fb56d9b0d4234f8fcc20488cbe2a0ece391 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:43:09 +0100 Subject: [PATCH 135/152] move lib out of host implementation --- openpype/hosts/unreal/hooks/pre_workfile_preparation.py | 2 +- openpype/hosts/unreal/{api => }/lib.py | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename openpype/hosts/unreal/{api => }/lib.py (100%) diff --git a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py index 6b787f4da7..f07e96551c 100644 --- a/openpype/hosts/unreal/hooks/pre_workfile_preparation.py +++ b/openpype/hosts/unreal/hooks/pre_workfile_preparation.py @@ -10,7 +10,7 @@ from openpype.lib import ( get_workdir_data, get_workfile_template_key ) -from openpype.hosts.unreal.api import lib as unreal_lib +import openpype.hosts.unreal.lib as unreal_lib class UnrealPrelaunchHook(PreLaunchHook): diff --git a/openpype/hosts/unreal/api/lib.py b/openpype/hosts/unreal/lib.py similarity index 100% rename from openpype/hosts/unreal/api/lib.py rename to openpype/hosts/unreal/lib.py From 306eddd5493986d702ee6423b0b14e1b41629223 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:43:55 +0100 Subject: [PATCH 136/152] move install/uninstall to pipeline --- openpype/hosts/unreal/api/__init__.py | 49 +---------------------- openpype/hosts/unreal/api/pipeline.py | 56 ++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 57 deletions(-) diff --git a/openpype/hosts/unreal/api/__init__.py b/openpype/hosts/unreal/api/__init__.py index 1aad704c56..ede71aa218 100644 --- a/openpype/hosts/unreal/api/__init__.py +++ b/openpype/hosts/unreal/api/__init__.py @@ -1,9 +1,6 @@ -import os -import logging +# -*- coding: utf-8 -*- +"""Unreal Editor OpenPype host API.""" -from avalon import api as avalon -from pyblish import api as pyblish -import openpype.hosts.unreal from .plugin import ( Loader, Creator @@ -24,17 +21,6 @@ from .pipeline import ( instantiate, ) - -logger = logging.getLogger("openpype.hosts.unreal") - -HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__)) -PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -CREATE_PATH = os.path.join(PLUGINS_DIR, "create") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") - - __all__ = [ "install", "uninstall", @@ -52,34 +38,3 @@ __all__ = [ "show_tools_popup", "instantiate" ] - - - -def install(): - """Install Unreal configuration for OpenPype.""" - print("-=" * 40) - logo = '''. -. - ____________ - / \\ __ \\ - \\ \\ \\/_\\ \\ - \\ \\ _____/ ______ - \\ \\ \\___// \\ \\ - \\ \\____\\ \\ \\_____\\ - \\/_____/ \\/______/ PYPE Club . -. -''' - print(logo) - print("installing OpenPype for Unreal ...") - print("-=" * 40) - logger.info("installing OpenPype for Unreal") - pyblish.register_plugin_path(str(PUBLISH_PATH)) - avalon.register_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.register_plugin_path(avalon.Creator, str(CREATE_PATH)) - - -def uninstall(): - """Uninstall Unreal configuration for Avalon.""" - pyblish.deregister_plugin_path(str(PUBLISH_PATH)) - avalon.deregister_plugin_path(avalon.Loader, str(LOAD_PATH)) - avalon.deregister_plugin_path(avalon.Creator, str(CREATE_PATH)) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 02c89abadd..5a93709ada 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -1,22 +1,62 @@ # -*- coding: utf-8 -*- -import pyblish.api -from avalon.pipeline import AVALON_CONTAINER_ID - -import unreal # noqa +import os +import logging from typing import List -from openpype.tools.utils import host_tools + +import pyblish.api +import avalon +from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api +from openpype.tools.utils import host_tools +import openpype.hosts.unreal +import unreal # noqa + + +logger = logging.getLogger("openpype.hosts.unreal") OPENPYPE_CONTAINERS = "OpenPypeContainers" +HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.unreal.__file__)) +PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") +CREATE_PATH = os.path.join(PLUGINS_DIR, "create") +INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") + def install(): - pyblish.api.register_host("unreal") + """Install Unreal configuration for OpenPype.""" + print("-=" * 40) + logo = '''. +. + ____________ + / \\ __ \\ + \\ \\ \\/_\\ \\ + \\ \\ _____/ ______ + \\ \\ \\___// \\ \\ + \\ \\____\\ \\ \\_____\\ + \\/_____/ \\/______/ PYPE Club . +. +''' + print(logo) + print("installing OpenPype for Unreal ...") + print("-=" * 40) + logger.info("installing OpenPype for Unreal") + pyblish.api.register_plugin_path(str(PUBLISH_PATH)) + api.register_plugin_path(api.Loader, str(LOAD_PATH)) + api.register_plugin_path(api.Creator, str(CREATE_PATH)) _register_callbacks() _register_events() +def uninstall(): + """Uninstall Unreal configuration for Avalon.""" + pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) + api.deregister_plugin_path(api.Loader, str(LOAD_PATH)) + api.deregister_plugin_path(api.Creator, str(CREATE_PATH)) + + def _register_callbacks(): """ TODO: Implement callbacks if supported by UE4 @@ -31,10 +71,6 @@ def _register_events(): pass -def uninstall(): - pyblish.api.deregister_host("unreal") - - class Creator(api.Creator): hosts = ["unreal"] asset_types = [] From 11b3d2cbaf0cb1a3201dae7e30a3958f51b848e8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:44:08 +0100 Subject: [PATCH 137/152] module relative path --- openpype/hosts/unreal/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index e6ca1e833d..533f315df3 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -1,11 +1,12 @@ import os +import openpype.hosts def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" # Set OPENPYPE_UNREAL_PLUGIN required for Unreal implementation unreal_plugin_path = os.path.join( - os.environ["OPENPYPE_ROOT"], "openpype", "hosts", + os.path.dirname(os.path.abspath(openpype.hosts.__file__)), "unreal", "integration" ) env["OPENPYPE_UNREAL_PLUGIN"] = unreal_plugin_path From 765ba59358e595aca45128914d8c40223fd5060c Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:46:28 +0100 Subject: [PATCH 138/152] remove unused import --- openpype/hosts/unreal/api/pipeline.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 5a93709ada..ad64d56e9e 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -4,7 +4,6 @@ import logging from typing import List import pyblish.api -import avalon from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api From 0f8c297f85604de31d6b7c9dfddee47da1577548 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 2 Mar 2022 03:36:52 +0000 Subject: [PATCH 139/152] [Automated] Bump version --- CHANGELOG.md | 14 +++++++++----- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c945569545..348f7dc1b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.9.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.8.2...HEAD) @@ -14,21 +14,23 @@ - Documentation: broken link fix [\#2785](https://github.com/pypeclub/OpenPype/pull/2785) - Documentation: link fixes [\#2772](https://github.com/pypeclub/OpenPype/pull/2772) - Update docusaurus to latest version [\#2760](https://github.com/pypeclub/OpenPype/pull/2760) -- Various testing updates [\#2726](https://github.com/pypeclub/OpenPype/pull/2726) **🚀 Enhancements** +- General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) - General: Set context environments for non host applications [\#2803](https://github.com/pypeclub/OpenPype/pull/2803) - Tray publisher: New Tray Publisher host \(beta\) [\#2778](https://github.com/pypeclub/OpenPype/pull/2778) - Houdini: Implement Reset Frame Range [\#2770](https://github.com/pypeclub/OpenPype/pull/2770) - Pyblish Pype: Remove redundant new line in installed fonts printing [\#2758](https://github.com/pypeclub/OpenPype/pull/2758) - Flame: use Shot Name on segment for asset name [\#2751](https://github.com/pypeclub/OpenPype/pull/2751) - Flame: adding validator source clip [\#2746](https://github.com/pypeclub/OpenPype/pull/2746) -- Work Files: Preserve subversion comment of current filename by default [\#2734](https://github.com/pypeclub/OpenPype/pull/2734) +- Ftrack: Disable ftrack module by default [\#2732](https://github.com/pypeclub/OpenPype/pull/2732) - RoyalRender: Minor enhancements [\#2700](https://github.com/pypeclub/OpenPype/pull/2700) **🐛 Bug fixes** +- Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) +- Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) - Settings UI: Search case sensitivity [\#2810](https://github.com/pypeclub/OpenPype/pull/2810) - Flame Babypublisher optimalization [\#2806](https://github.com/pypeclub/OpenPype/pull/2806) - resolve: fixing fusion module loading [\#2802](https://github.com/pypeclub/OpenPype/pull/2802) @@ -38,13 +40,15 @@ - Maya: Fix `unique\_namespace` when in an namespace that is empty [\#2759](https://github.com/pypeclub/OpenPype/pull/2759) - Loader UI: Fix right click in representation widget [\#2757](https://github.com/pypeclub/OpenPype/pull/2757) - Aftereffects 2022 and Deadline [\#2748](https://github.com/pypeclub/OpenPype/pull/2748) +- Flame: bunch of bugs [\#2745](https://github.com/pypeclub/OpenPype/pull/2745) - Maya: Save current scene on workfile publish [\#2744](https://github.com/pypeclub/OpenPype/pull/2744) - Version Up: Preserve parts of filename after version number \(like subversion\) on version\_up [\#2741](https://github.com/pypeclub/OpenPype/pull/2741) -- Loader UI: Multiple asset selection and underline colors fixed [\#2731](https://github.com/pypeclub/OpenPype/pull/2731) - Maya: Remove some unused code [\#2709](https://github.com/pypeclub/OpenPype/pull/2709) **Merged pull requests:** +- Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) +- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Ftrack: Unset task ids from asset versions before tasks are removed [\#2800](https://github.com/pypeclub/OpenPype/pull/2800) - Slack: fail gracefully if slack exception [\#2798](https://github.com/pypeclub/OpenPype/pull/2798) - Ftrack: Moved module one hierarchy level higher [\#2792](https://github.com/pypeclub/OpenPype/pull/2792) @@ -54,10 +58,10 @@ - Houdini: Remove duplicate ValidateOutputNode plug-in [\#2780](https://github.com/pypeclub/OpenPype/pull/2780) - Slack: Added regex for filtering on subset names [\#2775](https://github.com/pypeclub/OpenPype/pull/2775) - Houdini: Fix open last workfile [\#2767](https://github.com/pypeclub/OpenPype/pull/2767) +- General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) - Harmony: Rendering in Deadline didn't work in other machines than submitter [\#2754](https://github.com/pypeclub/OpenPype/pull/2754) - Houdini: Move Houdini Save Current File to beginning of ExtractorOrder [\#2747](https://github.com/pypeclub/OpenPype/pull/2747) - Maya: set Deadline job/batch name to original source workfile name instead of published workfile [\#2733](https://github.com/pypeclub/OpenPype/pull/2733) -- Fusion: Moved implementation into OpenPype [\#2713](https://github.com/pypeclub/OpenPype/pull/2713) ## [3.8.2](https://github.com/pypeclub/OpenPype/tree/3.8.2) (2022-02-07) diff --git a/openpype/version.py b/openpype/version.py index 0a799462ed..b41951a34c 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.0-nightly.4" +__version__ = "3.9.0-nightly.5" diff --git a/pyproject.toml b/pyproject.toml index 44bc0acbcc..851bf3f735 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.0-nightly.4" # OpenPype +version = "3.9.0-nightly.5" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From a252865b9ee3ecebf57806178e592b06eb748077 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 11:25:10 +0100 Subject: [PATCH 140/152] added missing deadline events folder --- .../custom/events/OpenPype/OpenPype.param | 37 ++++ .../custom/events/OpenPype/OpenPype.py | 191 ++++++++++++++++++ 2 files changed, 228 insertions(+) create mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param create mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param new file mode 100644 index 0000000000..871ce47467 --- /dev/null +++ b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param @@ -0,0 +1,37 @@ +[State] +Type=Enum +Items=Global Enabled;Opt-In;Disabled +Category=Options +CategoryOrder=0 +CategoryIndex=0 +Label=State +Default=Global Enabled +Description=How this event plug-in should respond to events. If Global, all jobs and slaves will trigger the events for this plugin. If Opt-In, jobs and slaves can choose to trigger the events for this plugin. If Disabled, no events are triggered for this plugin. + +[PythonSearchPaths] +Type=MultiLineMultiFolder +Label=Additional Python Search Paths +Category=Options +CategoryOrder=0 +CategoryIndex=1 +Default= +Description=The list of paths to append to the PYTHONPATH environment variable. This allows the Python job to find custom modules in non-standard locations. + +[LoggingLevel] +Type=Enum +Label=Logging Level +Category=Options +CategoryOrder=0 +CategoryIndex=2 +Items=DEBUG;INFO;WARNING;ERROR +Default=DEBUG +Description=Logging level where printing will start. + +[OpenPypeExecutable] +Type=multilinemultifilename +Label=Path to OpenPype executable +Category=Job Plugins +CategoryOrder=1 +CategoryIndex=1 +Default= +Description= \ No newline at end of file diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py new file mode 100644 index 0000000000..e5e2cf52a8 --- /dev/null +++ b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py @@ -0,0 +1,191 @@ +import Deadline.Events +import Deadline.Scripting + + +def GetDeadlineEventListener(): + return OpenPypeEventListener() + + +def CleanupDeadlineEventListener(eventListener): + eventListener.Cleanup() + + +class OpenPypeEventListener(Deadline.Events.DeadlineEventListener): + """ + Called on every Deadline plugin event, used for injecting OpenPype + environment variables into rendering process. + + Expects that job already contains env vars: + AVALON_PROJECT + AVALON_ASSET + AVALON_TASK + AVALON_APP_NAME + Without these only global environment would be pulled from OpenPype + + Configure 'Path to OpenPype executable dir' in Deadlines + 'Tools > Configure Events > openpype ' + Only directory path is needed. + + """ + def __init__(self): + self.OnJobSubmittedCallback += self.OnJobSubmitted + self.OnJobStartedCallback += self.OnJobStarted + self.OnJobFinishedCallback += self.OnJobFinished + self.OnJobRequeuedCallback += self.OnJobRequeued + self.OnJobFailedCallback += self.OnJobFailed + self.OnJobSuspendedCallback += self.OnJobSuspended + self.OnJobResumedCallback += self.OnJobResumed + self.OnJobPendedCallback += self.OnJobPended + self.OnJobReleasedCallback += self.OnJobReleased + self.OnJobDeletedCallback += self.OnJobDeleted + self.OnJobErrorCallback += self.OnJobError + self.OnJobPurgedCallback += self.OnJobPurged + + self.OnHouseCleaningCallback += self.OnHouseCleaning + self.OnRepositoryRepairCallback += self.OnRepositoryRepair + + self.OnSlaveStartedCallback += self.OnSlaveStarted + self.OnSlaveStoppedCallback += self.OnSlaveStopped + self.OnSlaveIdleCallback += self.OnSlaveIdle + self.OnSlaveRenderingCallback += self.OnSlaveRendering + self.OnSlaveStartingJobCallback += self.OnSlaveStartingJob + self.OnSlaveStalledCallback += self.OnSlaveStalled + + self.OnIdleShutdownCallback += self.OnIdleShutdown + self.OnMachineStartupCallback += self.OnMachineStartup + self.OnThermalShutdownCallback += self.OnThermalShutdown + self.OnMachineRestartCallback += self.OnMachineRestart + + def Cleanup(self): + del self.OnJobSubmittedCallback + del self.OnJobStartedCallback + del self.OnJobFinishedCallback + del self.OnJobRequeuedCallback + del self.OnJobFailedCallback + del self.OnJobSuspendedCallback + del self.OnJobResumedCallback + del self.OnJobPendedCallback + del self.OnJobReleasedCallback + del self.OnJobDeletedCallback + del self.OnJobErrorCallback + del self.OnJobPurgedCallback + + del self.OnHouseCleaningCallback + del self.OnRepositoryRepairCallback + + del self.OnSlaveStartedCallback + del self.OnSlaveStoppedCallback + del self.OnSlaveIdleCallback + del self.OnSlaveRenderingCallback + del self.OnSlaveStartingJobCallback + del self.OnSlaveStalledCallback + + del self.OnIdleShutdownCallback + del self.OnMachineStartupCallback + del self.OnThermalShutdownCallback + del self.OnMachineRestartCallback + + def set_openpype_executable_path(self, job): + """ + Sets configurable OpenPypeExecutable value to job extra infos. + + GlobalJobPreLoad takes this value, pulls env vars for each task + from specific worker itself. GlobalJobPreLoad is not easily + configured, so we are configuring Event itself. + """ + openpype_execs = self.GetConfigEntryWithDefault("OpenPypeExecutable", + "") + job.SetJobExtraInfoKeyValue("openpype_executables", openpype_execs) + + Deadline.Scripting.RepositoryUtils.SaveJob(job) + + def updateFtrackStatus(self, job, statusName, createIfMissing=False): + """Updates version status on ftrack""" + pass + + def OnJobSubmitted(self, job): + # self.LogInfo("OnJobSubmitted LOGGING") + # for 1st time submit + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Render Queued") + + def OnJobStarted(self, job): + # self.LogInfo("OnJobStarted") + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Rendering") + + def OnJobFinished(self, job): + # self.LogInfo("OnJobFinished") + self.updateFtrackStatus(job, "Artist Review") + + def OnJobRequeued(self, job): + # self.LogInfo("OnJobRequeued LOGGING") + self.set_openpype_executable_path(job) + + def OnJobFailed(self, job): + pass + + def OnJobSuspended(self, job): + # self.LogInfo("OnJobSuspended LOGGING") + self.updateFtrackStatus(job, "Render Queued") + + def OnJobResumed(self, job): + # self.LogInfo("OnJobResumed LOGGING") + self.set_openpype_executable_path(job) + self.updateFtrackStatus(job, "Rendering") + + def OnJobPended(self, job): + # self.LogInfo("OnJobPended LOGGING") + pass + + def OnJobReleased(self, job): + pass + + def OnJobDeleted(self, job): + pass + + def OnJobError(self, job, task, report): + # self.LogInfo("OnJobError LOGGING") + pass + + def OnJobPurged(self, job): + pass + + def OnHouseCleaning(self): + pass + + def OnRepositoryRepair(self, job, *args): + pass + + def OnSlaveStarted(self, job): + # self.LogInfo("OnSlaveStarted LOGGING") + pass + + def OnSlaveStopped(self, job): + pass + + def OnSlaveIdle(self, job): + pass + + def OnSlaveRendering(self, host_name, job): + # self.LogInfo("OnSlaveRendering LOGGING") + pass + + def OnSlaveStartingJob(self, host_name, job): + # self.LogInfo("OnSlaveStartingJob LOGGING") + self.set_openpype_executable_path(job) + + def OnSlaveStalled(self, job): + pass + + def OnIdleShutdown(self, job): + pass + + def OnMachineStartup(self, job): + pass + + def OnThermalShutdown(self, job): + pass + + def OnMachineRestart(self, job): + pass From 4b1bbe668f6143bb822d14ef8869c277274903a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 11:59:08 +0100 Subject: [PATCH 141/152] removed event that should be removed --- .../custom/events/OpenPype/OpenPype.param | 37 ---- .../custom/events/OpenPype/OpenPype.py | 191 ------------------ 2 files changed, 228 deletions(-) delete mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param delete mode 100644 openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param deleted file mode 100644 index 871ce47467..0000000000 --- a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.param +++ /dev/null @@ -1,37 +0,0 @@ -[State] -Type=Enum -Items=Global Enabled;Opt-In;Disabled -Category=Options -CategoryOrder=0 -CategoryIndex=0 -Label=State -Default=Global Enabled -Description=How this event plug-in should respond to events. If Global, all jobs and slaves will trigger the events for this plugin. If Opt-In, jobs and slaves can choose to trigger the events for this plugin. If Disabled, no events are triggered for this plugin. - -[PythonSearchPaths] -Type=MultiLineMultiFolder -Label=Additional Python Search Paths -Category=Options -CategoryOrder=0 -CategoryIndex=1 -Default= -Description=The list of paths to append to the PYTHONPATH environment variable. This allows the Python job to find custom modules in non-standard locations. - -[LoggingLevel] -Type=Enum -Label=Logging Level -Category=Options -CategoryOrder=0 -CategoryIndex=2 -Items=DEBUG;INFO;WARNING;ERROR -Default=DEBUG -Description=Logging level where printing will start. - -[OpenPypeExecutable] -Type=multilinemultifilename -Label=Path to OpenPype executable -Category=Job Plugins -CategoryOrder=1 -CategoryIndex=1 -Default= -Description= \ No newline at end of file diff --git a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py b/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py deleted file mode 100644 index e5e2cf52a8..0000000000 --- a/openpype/modules/deadline/repository/custom/events/OpenPype/OpenPype.py +++ /dev/null @@ -1,191 +0,0 @@ -import Deadline.Events -import Deadline.Scripting - - -def GetDeadlineEventListener(): - return OpenPypeEventListener() - - -def CleanupDeadlineEventListener(eventListener): - eventListener.Cleanup() - - -class OpenPypeEventListener(Deadline.Events.DeadlineEventListener): - """ - Called on every Deadline plugin event, used for injecting OpenPype - environment variables into rendering process. - - Expects that job already contains env vars: - AVALON_PROJECT - AVALON_ASSET - AVALON_TASK - AVALON_APP_NAME - Without these only global environment would be pulled from OpenPype - - Configure 'Path to OpenPype executable dir' in Deadlines - 'Tools > Configure Events > openpype ' - Only directory path is needed. - - """ - def __init__(self): - self.OnJobSubmittedCallback += self.OnJobSubmitted - self.OnJobStartedCallback += self.OnJobStarted - self.OnJobFinishedCallback += self.OnJobFinished - self.OnJobRequeuedCallback += self.OnJobRequeued - self.OnJobFailedCallback += self.OnJobFailed - self.OnJobSuspendedCallback += self.OnJobSuspended - self.OnJobResumedCallback += self.OnJobResumed - self.OnJobPendedCallback += self.OnJobPended - self.OnJobReleasedCallback += self.OnJobReleased - self.OnJobDeletedCallback += self.OnJobDeleted - self.OnJobErrorCallback += self.OnJobError - self.OnJobPurgedCallback += self.OnJobPurged - - self.OnHouseCleaningCallback += self.OnHouseCleaning - self.OnRepositoryRepairCallback += self.OnRepositoryRepair - - self.OnSlaveStartedCallback += self.OnSlaveStarted - self.OnSlaveStoppedCallback += self.OnSlaveStopped - self.OnSlaveIdleCallback += self.OnSlaveIdle - self.OnSlaveRenderingCallback += self.OnSlaveRendering - self.OnSlaveStartingJobCallback += self.OnSlaveStartingJob - self.OnSlaveStalledCallback += self.OnSlaveStalled - - self.OnIdleShutdownCallback += self.OnIdleShutdown - self.OnMachineStartupCallback += self.OnMachineStartup - self.OnThermalShutdownCallback += self.OnThermalShutdown - self.OnMachineRestartCallback += self.OnMachineRestart - - def Cleanup(self): - del self.OnJobSubmittedCallback - del self.OnJobStartedCallback - del self.OnJobFinishedCallback - del self.OnJobRequeuedCallback - del self.OnJobFailedCallback - del self.OnJobSuspendedCallback - del self.OnJobResumedCallback - del self.OnJobPendedCallback - del self.OnJobReleasedCallback - del self.OnJobDeletedCallback - del self.OnJobErrorCallback - del self.OnJobPurgedCallback - - del self.OnHouseCleaningCallback - del self.OnRepositoryRepairCallback - - del self.OnSlaveStartedCallback - del self.OnSlaveStoppedCallback - del self.OnSlaveIdleCallback - del self.OnSlaveRenderingCallback - del self.OnSlaveStartingJobCallback - del self.OnSlaveStalledCallback - - del self.OnIdleShutdownCallback - del self.OnMachineStartupCallback - del self.OnThermalShutdownCallback - del self.OnMachineRestartCallback - - def set_openpype_executable_path(self, job): - """ - Sets configurable OpenPypeExecutable value to job extra infos. - - GlobalJobPreLoad takes this value, pulls env vars for each task - from specific worker itself. GlobalJobPreLoad is not easily - configured, so we are configuring Event itself. - """ - openpype_execs = self.GetConfigEntryWithDefault("OpenPypeExecutable", - "") - job.SetJobExtraInfoKeyValue("openpype_executables", openpype_execs) - - Deadline.Scripting.RepositoryUtils.SaveJob(job) - - def updateFtrackStatus(self, job, statusName, createIfMissing=False): - """Updates version status on ftrack""" - pass - - def OnJobSubmitted(self, job): - # self.LogInfo("OnJobSubmitted LOGGING") - # for 1st time submit - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Render Queued") - - def OnJobStarted(self, job): - # self.LogInfo("OnJobStarted") - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Rendering") - - def OnJobFinished(self, job): - # self.LogInfo("OnJobFinished") - self.updateFtrackStatus(job, "Artist Review") - - def OnJobRequeued(self, job): - # self.LogInfo("OnJobRequeued LOGGING") - self.set_openpype_executable_path(job) - - def OnJobFailed(self, job): - pass - - def OnJobSuspended(self, job): - # self.LogInfo("OnJobSuspended LOGGING") - self.updateFtrackStatus(job, "Render Queued") - - def OnJobResumed(self, job): - # self.LogInfo("OnJobResumed LOGGING") - self.set_openpype_executable_path(job) - self.updateFtrackStatus(job, "Rendering") - - def OnJobPended(self, job): - # self.LogInfo("OnJobPended LOGGING") - pass - - def OnJobReleased(self, job): - pass - - def OnJobDeleted(self, job): - pass - - def OnJobError(self, job, task, report): - # self.LogInfo("OnJobError LOGGING") - pass - - def OnJobPurged(self, job): - pass - - def OnHouseCleaning(self): - pass - - def OnRepositoryRepair(self, job, *args): - pass - - def OnSlaveStarted(self, job): - # self.LogInfo("OnSlaveStarted LOGGING") - pass - - def OnSlaveStopped(self, job): - pass - - def OnSlaveIdle(self, job): - pass - - def OnSlaveRendering(self, host_name, job): - # self.LogInfo("OnSlaveRendering LOGGING") - pass - - def OnSlaveStartingJob(self, host_name, job): - # self.LogInfo("OnSlaveStartingJob LOGGING") - self.set_openpype_executable_path(job) - - def OnSlaveStalled(self, job): - pass - - def OnIdleShutdown(self, job): - pass - - def OnMachineStartup(self, job): - pass - - def OnThermalShutdown(self, job): - pass - - def OnMachineRestart(self, job): - pass From d90c83a6b8b5cd7a4765a91ff47d773ffda7384f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 12:26:33 +0100 Subject: [PATCH 142/152] move pyblish ui logic into host_tools --- openpype/tools/utils/host_tools.py | 36 ++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index a7ad8fef3b..f9e38c0dee 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -3,8 +3,9 @@ It is possible to create `HostToolsHelper` in host implementation or use singleton approach with global functions (using helper anyway). """ - +import os import avalon.api +import pyblish.api from .lib import qt_app_context @@ -196,10 +197,29 @@ class HostToolsHelper: library_loader_tool.refresh() def show_publish(self, parent=None): - """Publish UI.""" - from avalon.tools import publish + """Try showing the most desirable publish GUI - publish.show(parent) + This function cycles through the currently registered + graphical user interfaces, if any, and presents it to + the user. + """ + + pyblish_show = self._discover_pyblish_gui() + return pyblish_show(parent) + + def _discover_pyblish_gui(): + """Return the most desirable of the currently registered GUIs""" + # Prefer last registered + guis = list(reversed(pyblish.api.registered_guis())) + for gui in guis: + try: + gui = __import__(gui).show + except (ImportError, AttributeError): + continue + else: + return gui + + raise ImportError("No Pyblish GUI found") def get_look_assigner_tool(self, parent): """Create, cache and return look assigner tool window.""" @@ -394,3 +414,11 @@ def show_publish(parent=None): def show_experimental_tools_dialog(parent=None): _SingletonPoint.show_tool_by_name("experimental_tools", parent) + + +def get_pyblish_icon(): + pyblish_dir = os.path.abspath(os.path.dirname(pyblish.api.__file__)) + icon_path = os.path.join(pyblish_dir, "icons", "logo-32x32.svg") + if os.path.exists(icon_path): + return icon_path + return None From b22a3c9217230aff377fd083517647f326fd35da Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 12:26:55 +0100 Subject: [PATCH 143/152] import qt_app_context in utils init file --- openpype/tools/utils/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index b4b0af106e..c15e9f8139 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -15,6 +15,7 @@ from .lib import ( get_warning_pixmap, set_style_property, DynamicQThread, + qt_app_context, ) from .models import ( @@ -39,6 +40,7 @@ __all__ = ( "get_warning_pixmap", "set_style_property", "DynamicQThread", + "qt_app_context", "RecursiveSortFilterProxyModel", ) From 4f0001c4f3ea709c02b15cc6a62ad0f4e5df4f7e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 13:48:47 +0100 Subject: [PATCH 144/152] replace usages of avalon.tools with use classes from openpype.tools --- openpype/hosts/blender/api/pipeline.py | 11 ++++------- openpype/hosts/maya/api/commands.py | 4 ++-- openpype/hosts/maya/api/menu.py | 4 ++-- openpype/tools/mayalookassigner/widgets.py | 15 +++++++++------ openpype/tools/sceneinventory/model.py | 2 +- openpype/tools/sceneinventory/view.py | 12 ++++++++---- openpype/tools/standalonepublish/publish.py | 4 ++-- openpype/tools/workfiles/model.py | 4 ++-- 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 0e5104fea9..6da0ba3dcb 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -202,13 +202,10 @@ def reload_pipeline(*args): avalon.api.uninstall() for module in ( - "avalon.io", - "avalon.lib", - "avalon.pipeline", - "avalon.tools.creator.app", - "avalon.tools.manager.app", - "avalon.api", - "avalon.tools", + "avalon.io", + "avalon.lib", + "avalon.pipeline", + "avalon.api", ): module = importlib.import_module(module) importlib.reload(module) diff --git a/openpype/hosts/maya/api/commands.py b/openpype/hosts/maya/api/commands.py index c774afcc12..a1e0be2cfe 100644 --- a/openpype/hosts/maya/api/commands.py +++ b/openpype/hosts/maya/api/commands.py @@ -37,17 +37,17 @@ class ToolWindows: def edit_shader_definitions(): - from avalon.tools import lib from Qt import QtWidgets from openpype.hosts.maya.api.shader_definition_editor import ( ShaderDefinitionsEditor ) + from openpype.tools.utils import qt_app_context top_level_widgets = QtWidgets.QApplication.topLevelWidgets() main_window = next(widget for widget in top_level_widgets if widget.objectName() == "MayaWindow") - with lib.application(): + with qt_app_context(): window = ToolWindows.get_window("shader_definition_editor") if not window: window = ShaderDefinitionsEditor(parent=main_window) diff --git a/openpype/hosts/maya/api/menu.py b/openpype/hosts/maya/api/menu.py index b1934c757d..5f0fc39bf3 100644 --- a/openpype/hosts/maya/api/menu.py +++ b/openpype/hosts/maya/api/menu.py @@ -36,7 +36,7 @@ def install(): return def deferred(): - from avalon.tools import publish + pyblish_icon = host_tools.get_pyblish_icon() parent_widget = get_main_window() cmds.menu( MENU_NAME, @@ -80,7 +80,7 @@ def install(): command=lambda *args: host_tools.show_publish( parent=parent_widget ), - image=publish.ICON + image=pyblish_icon ) cmds.menuItem( diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index d575e647ce..e5a9968b01 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -4,8 +4,11 @@ from collections import defaultdict from Qt import QtWidgets, QtCore # TODO: expose this better in avalon core -from avalon.tools import lib -from avalon.tools.models import TreeModel +from openpype.tools.utils.models import TreeModel +from openpype.tools.utils.lib import ( + preserve_expanded_rows, + preserve_selection, +) from .models import ( AssetModel, @@ -88,8 +91,8 @@ class AssetOutliner(QtWidgets.QWidget): """Add all items from the current scene""" items = [] - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_all_asset_nodes() items = commands.create_items_from_nodes(nodes) @@ -100,8 +103,8 @@ class AssetOutliner(QtWidgets.QWidget): def get_selected_assets(self): """Add all selected items from the current scene""" - with lib.preserve_expanded_rows(self.view): - with lib.preserve_selection(self.view): + with preserve_expanded_rows(self.view): + with preserve_selection(self.view): self.clear() nodes = commands.get_selected_nodes() items = commands.create_items_from_nodes(nodes) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index d2b7f8b70f..6435e5c488 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -8,7 +8,7 @@ from avalon import api, io, style, schema from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item from .lib import ( get_site_icons, diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index 80f26a881d..f55a68df95 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -7,9 +7,13 @@ from Qt import QtWidgets, QtCore from avalon import io, api, style from avalon.vendor import qtawesome from avalon.lib import HeroVersionType -from avalon.tools import lib as tools_lib from openpype.modules import ModulesManager +from openpype.tools.utils.lib import ( + get_progress_for_repre, + iter_model_rows, + format_version +) from .switch_dialog import SwitchAssetDialog from .model import InventoryModel @@ -373,7 +377,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): if not repre_doc: continue - progress = tools_lib.get_progress_for_repre( + progress = get_progress_for_repre( repre_doc, active_site, remote_site @@ -544,7 +548,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): "toggle": selection_model.Toggle, }[options.get("mode", "select")] - for item in tools_lib.iter_model_rows(model, 0): + for item in iter_model_rows(model, 0): item = item.data(InventoryModel.ItemRole) if item.get("isGroupNode"): continue @@ -704,7 +708,7 @@ class SceneInvetoryView(QtWidgets.QTreeView): labels = [] for version in all_versions: is_hero = version["type"] == "hero_version" - label = tools_lib.format_version(version["name"], is_hero) + label = format_version(version["name"], is_hero) labels.append(label) versions_by_label[label] = version["name"] diff --git a/openpype/tools/standalonepublish/publish.py b/openpype/tools/standalonepublish/publish.py index af269c4381..582e7eccf8 100644 --- a/openpype/tools/standalonepublish/publish.py +++ b/openpype/tools/standalonepublish/publish.py @@ -3,10 +3,10 @@ import sys import openpype import pyblish.api +from openpype.tools.utils.host_tools import show_publish def main(env): - from avalon.tools import publish # Registers pype's Global pyblish plugins openpype.install() @@ -19,7 +19,7 @@ def main(env): continue pyblish.api.register_plugin_path(path) - return publish.show() + return show_publish() if __name__ == "__main__": diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 583f495606..3425cc3df0 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -1,11 +1,11 @@ import os import logging -from Qt import QtCore, QtGui +from Qt import QtCore from avalon import style from avalon.vendor import qtawesome -from avalon.tools.models import TreeModel, Item +from openpype.tools.utils.models import TreeModel, Item log = logging.getLogger(__name__) From 9bd774593e870e842e4889d0d198dcacdb1c4326 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 2 Mar 2022 14:07:42 +0100 Subject: [PATCH 145/152] fix method arguments --- openpype/tools/utils/host_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index f9e38c0dee..6ce9e818d9 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -207,7 +207,7 @@ class HostToolsHelper: pyblish_show = self._discover_pyblish_gui() return pyblish_show(parent) - def _discover_pyblish_gui(): + def _discover_pyblish_gui(self): """Return the most desirable of the currently registered GUIs""" # Prefer last registered guis = list(reversed(pyblish.api.registered_guis())) From 171ddd66766f4e81165e605101ef160434c35909 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 2 Mar 2022 15:22:28 +0100 Subject: [PATCH 146/152] Update openpype/tools/mayalookassigner/widgets.py --- openpype/tools/mayalookassigner/widgets.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/mayalookassigner/widgets.py b/openpype/tools/mayalookassigner/widgets.py index e5a9968b01..e546ee705d 100644 --- a/openpype/tools/mayalookassigner/widgets.py +++ b/openpype/tools/mayalookassigner/widgets.py @@ -3,7 +3,6 @@ from collections import defaultdict from Qt import QtWidgets, QtCore -# TODO: expose this better in avalon core from openpype.tools.utils.models import TreeModel from openpype.tools.utils.lib import ( preserve_expanded_rows, From d7b704d6e5a3eebaa5153beca41c8427be231ca2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 11:47:01 +0100 Subject: [PATCH 147/152] removed module_name logic from harmony --- openpype/hosts/harmony/api/lib.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 134f670dc4..66eeac1e3a 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -361,7 +361,7 @@ def zip_and_move(source, destination): log.debug(f"Saved '{source}' to '{destination}'") -def show(module_name): +def show(tool_name): """Call show on "module_name". This allows to make a QApplication ahead of time and always "exec_" to @@ -375,13 +375,6 @@ def show(module_name): # requests to be received properly. time.sleep(1) - # Get tool name from module name - # TODO this is for backwards compatibility not sure if `TB_sceneOpened.js` - # is automatically updated. - # Previous javascript sent 'module_name' which contained whole tool import - # string e.g. "avalon.tools.workfiles" now it should be only "workfiles" - tool_name = module_name.split(".")[-1] - kwargs = {} if tool_name == "loader": kwargs["use_context"] = True From 6a6ce4d5c5976038bf4f296183603883d38d9f92 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 14:52:24 +0100 Subject: [PATCH 148/152] added funciton to convert string fpx into float --- openpype/modules/ftrack/lib/avalon_sync.py | 106 +++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index db7c592c9b..11478925d6 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -2,6 +2,9 @@ import re import json import collections import copy +import numbers + +import six from avalon.api import AvalonMongoDB @@ -32,6 +35,109 @@ CURRENT_DOC_SCHEMAS = { "config": "openpype:config-2.0" } +FPS_KEYS = { + "fps", + # For development purposes + "fps_string" +} + + +class InvalidFpsValue(Exception): + pass + + +def is_string_number(value): + """Can string value be converted to number (float).""" + if not isinstance(value, six.string_types): + raise TypeError("Expected {} got {}".format( + ", ".join(str(t) for t in six.string_types), str(type(value)) + )) + if value == ".": + return False + + if value.startswith("."): + value = "0" + value + elif value.endswith("."): + value = value + "0" + + if re.match(r"^\d+(\.\d+)?$", value) is None: + return False + return True + + +def convert_to_fps(source_value): + """Convert value into fps value. + + Non string values are kept untouched. String is tried to convert. + Valid values: + "1000" + "1000.05" + "1000,05" + ",05" + ".05" + "1000," + "1000." + "1000/1000" + "1000.05/1000" + "1000/1000.05" + "1000.05/1000.05" + "1000,05/1000" + "1000/1000,05" + "1000,05/1000,05" + + Invalid values: + "/" + "/1000" + "1000/" + "," + "." + ...any other string + + Returns: + float: Converted value. + + Raises: + InvalidFpsValue: When value can't be converted to float. + """ + if not isinstance(source_value, six.string_types): + if isinstance(source_value, numbers.Number): + return float(source_value) + return source_value + + value = source_value.strip().replace(",", ".") + if not value: + raise InvalidFpsValue("Got empty value") + + subs = value.split("/") + if len(subs) == 1: + str_value = subs[0] + if not is_string_number(str_value): + raise InvalidFpsValue( + "Value \"{}\" can't be converted to number.".format(value) + ) + return float(str_value) + + elif len(subs) == 2: + divident, divisor = subs + if not divident or not is_string_number(divident): + raise InvalidFpsValue( + "Divident value \"{}\" can't be converted to number".format( + divident + ) + ) + + if not divisor or not is_string_number(divisor): + raise InvalidFpsValue( + "Divisor value \"{}\" can't be converted to number".format( + divident + ) + ) + return float(divident) / float(divisor) + + raise InvalidFpsValue( + "Value can't be converted to number \"{}\"".format(source_value) + ) + def create_chunks(iterable, chunk_size=None): """Separate iterable into multiple chunks by size. From f88bf7b5be19280ba9ea2088a46ffe579644d564 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 14:52:41 +0100 Subject: [PATCH 149/152] use fps conversion function during synchronization --- .../event_sync_to_avalon.py | 49 +++++++++++++++++++ openpype/modules/ftrack/lib/avalon_sync.py | 44 ++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 9f85000dbb..76f4be1419 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -25,6 +25,11 @@ from openpype_modules.ftrack.lib import ( BaseEvent ) +from openpype_modules.ftrack.lib.avalon_sync import ( + convert_to_fps, + InvalidFpsValue, + FPS_KEYS +) from openpype.lib import CURRENT_DOC_SCHEMAS @@ -1149,12 +1154,31 @@ class SyncToAvalonEvent(BaseEvent): "description": ftrack_ent["description"] } } + invalid_fps_items = [] cust_attrs = self.get_cust_attr_values(ftrack_ent) for key, val in cust_attrs.items(): if key.startswith("avalon_"): continue + + if key in FPS_KEYS: + try: + val = convert_to_fps(val) + except InvalidFpsValue: + invalid_fps_items.append((ftrack_ent["id"], val)) + continue + final_entity["data"][key] = val + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + _mongo_id_str = cust_attrs.get(CUST_ATTR_ID_KEY) if _mongo_id_str: try: @@ -2155,11 +2179,19 @@ class SyncToAvalonEvent(BaseEvent): ) convert_types_by_id[attr_id] = convert_type + default_value = attr["default"] + if key in FPS_KEYS: + try: + default_value = convert_to_fps(default_value) + except InvalidFpsValue: + pass + entities_dict[ftrack_project_id]["hier_attrs"][key] = ( attr["default"] ) # PREPARE DATA BEFORE THIS + invalid_fps_items = [] avalon_hier = [] for item in values: value = item["value"] @@ -2173,8 +2205,25 @@ class SyncToAvalonEvent(BaseEvent): if convert_type: value = convert_type(value) + + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) + continue entities_dict[entity_id]["hier_attrs"][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # Get dictionary with not None hierarchical values to pull to childs project_values = {} for key, value in ( diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 11478925d6..07b974d84f 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -1086,6 +1086,7 @@ class SyncEntitiesFactory: sync_ids ) + invalid_fps_items = [] for item in items: entity_id = item["entity_id"] attr_id = item["configuration_id"] @@ -1098,8 +1099,24 @@ class SyncEntitiesFactory: value = item["value"] if convert_type: value = convert_type(value) + + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) self.entities_dict[entity_id][store_key][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # process hierarchical attributes self.set_hierarchical_attribute( hier_attrs, sync_ids, cust_attr_type_name_by_id @@ -1132,8 +1149,15 @@ class SyncEntitiesFactory: if key.startswith("avalon_"): store_key = "avalon_attrs" + default_value = attr["default"] + if key in FPS_KEYS: + try: + default_value = convert_to_fps(default_value) + except InvalidFpsValue: + pass + self.entities_dict[self.ft_project_id][store_key][key] = ( - attr["default"] + default_value ) # Add attribute ids to entities dictionary @@ -1175,6 +1199,7 @@ class SyncEntitiesFactory: True ) + invalid_fps_items = [] avalon_hier = [] for item in items: value = item["value"] @@ -1194,6 +1219,13 @@ class SyncEntitiesFactory: entity_id = item["entity_id"] key = attribute_key_by_id[attr_id] + if key in FPS_KEYS: + try: + value = convert_to_fps(value) + except InvalidFpsValue: + invalid_fps_items.append((entity_id, value)) + continue + if key.startswith("avalon_"): store_key = "avalon_attrs" avalon_hier.append(key) @@ -1201,6 +1233,16 @@ class SyncEntitiesFactory: store_key = "hier_attrs" self.entities_dict[entity_id][store_key][key] = value + if invalid_fps_items: + fps_msg = ( + "These entities have invalid fps value in custom attributes" + ) + items = [] + for entity_id, value in invalid_fps_items: + ent_path = self.get_ent_path(entity_id) + items.append("{} - \"{}\"".format(ent_path, value)) + self.report_items["error"][fps_msg] = items + # Get dictionary with not None hierarchical values to pull to childs top_id = self.ft_project_id project_values = {} From 630c8193366edaceacaadd59bb47c5f0fe47ee70 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 15:29:30 +0100 Subject: [PATCH 150/152] moved FPS_KEYS to constants --- .../ftrack/event_handlers_server/event_sync_to_avalon.py | 4 ++-- openpype/modules/ftrack/lib/__init__.py | 5 ++++- openpype/modules/ftrack/lib/avalon_sync.py | 8 +------- openpype/modules/ftrack/lib/constants.py | 6 ++++++ 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 76f4be1419..eea6436b53 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -20,6 +20,7 @@ from openpype_modules.ftrack.lib import ( query_custom_attributes, CUST_ATTR_ID_KEY, CUST_ATTR_AUTO_SYNC, + FPS_KEYS, avalon_sync, @@ -27,8 +28,7 @@ from openpype_modules.ftrack.lib import ( ) from openpype_modules.ftrack.lib.avalon_sync import ( convert_to_fps, - InvalidFpsValue, - FPS_KEYS + InvalidFpsValue ) from openpype.lib import CURRENT_DOC_SCHEMAS diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index 80b4db9dd6..7fc2bc99eb 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -4,7 +4,8 @@ from .constants import ( CUST_ATTR_GROUP, CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, - CUST_ATTR_INTENT + CUST_ATTR_INTENT, + FPS_KEYS ) from .settings import ( get_ftrack_event_mongo_info @@ -30,6 +31,8 @@ __all__ = ( "CUST_ATTR_GROUP", "CUST_ATTR_TOOLS", "CUST_ATTR_APPLICATIONS", + "CUST_ATTR_INTENT", + "FPS_KEYS", "get_ftrack_event_mongo_info", diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 07b974d84f..5a0c3c1574 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -17,7 +17,7 @@ from openpype.api import ( ) from openpype.lib import ApplicationManager -from .constants import CUST_ATTR_ID_KEY +from .constants import CUST_ATTR_ID_KEY, FPS_KEYS from .custom_attributes import get_openpype_attr, query_custom_attributes from bson.objectid import ObjectId @@ -35,12 +35,6 @@ CURRENT_DOC_SCHEMAS = { "config": "openpype:config-2.0" } -FPS_KEYS = { - "fps", - # For development purposes - "fps_string" -} - class InvalidFpsValue(Exception): pass diff --git a/openpype/modules/ftrack/lib/constants.py b/openpype/modules/ftrack/lib/constants.py index e6e2013d2b..636dcfbc3d 100644 --- a/openpype/modules/ftrack/lib/constants.py +++ b/openpype/modules/ftrack/lib/constants.py @@ -12,3 +12,9 @@ CUST_ATTR_APPLICATIONS = "applications" CUST_ATTR_TOOLS = "tools_env" # Intent custom attribute name CUST_ATTR_INTENT = "intent" + +FPS_KEYS = { + "fps", + # For development purposes + "fps_string" +} From c237434ad682f4477791df3751c05835b9a99551 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 15:30:15 +0100 Subject: [PATCH 151/152] create custom attributes action does not replace text fps custom attribute --- .../action_create_cust_attrs.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py index cb5b88ad50..88dc8213bd 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py @@ -11,6 +11,7 @@ from openpype_modules.ftrack.lib import ( CUST_ATTR_TOOLS, CUST_ATTR_APPLICATIONS, CUST_ATTR_INTENT, + FPS_KEYS, default_custom_attributes_definition, app_definitions_from_app_manager, @@ -519,20 +520,28 @@ class CustomAttributes(BaseAction): self.show_message(event, msg) def process_attribute(self, data): - existing_attrs = self.session.query( - "CustomAttributeConfiguration" - ).all() + existing_attrs = self.session.query(( + "select is_hierarchical, key, type, entity_type, object_type_id" + " from CustomAttributeConfiguration" + )).all() matching = [] + is_hierarchical = data.get("is_hierarchical", False) for attr in existing_attrs: if ( - attr["key"] != data["key"] or - attr["type"]["name"] != data["type"]["name"] + is_hierarchical != attr["is_hierarchical"] + or attr["key"] != data["key"] ): continue - if data.get("is_hierarchical") is True: - if attr["is_hierarchical"] is True: - matching.append(attr) + if attr["type"]["name"] != data["type"]["name"]: + if data["key"] in FPS_KEYS and attr["type"]["name"] == "text": + self.log.info("Kept 'fps' as text custom attribute.") + return + continue + + if is_hierarchical: + matching.append(attr) + elif "object_type_id" in data: if ( attr["entity_type"] == data["entity_type"] and From cec7adab1c1163f1ae15fbd4deeb57ea4b4a2924 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 3 Mar 2022 17:53:14 +0100 Subject: [PATCH 152/152] fix zero division error --- openpype/modules/ftrack/lib/avalon_sync.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 5a0c3c1574..5301ec568e 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -126,7 +126,10 @@ def convert_to_fps(source_value): divident ) ) - return float(divident) / float(divisor) + divisor_float = float(divisor) + if divisor_float == 0.0: + raise InvalidFpsValue("Can't divide by zero") + return float(divident) / divisor_float raise InvalidFpsValue( "Value can't be converted to number \"{}\"".format(source_value)