From 12766377b43e9391509e52af68a95d1121411a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 14 Feb 2022 18:34:36 +0100 Subject: [PATCH 001/854] fix case with single mesh and prefixes --- .../create/create_unreal_staticmesh.py | 1 + .../hosts/maya/plugins/load/load_reference.py | 3 ++- .../publish/collect_unreal_staticmesh.py | 17 ++++++++++-- .../publish/extract_unreal_staticmesh.py | 27 ++++++++++++++----- .../validate_unreal_staticmesh_naming.py | 9 ++++++- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index 9ad560ab7c..1fe7e57abc 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -33,6 +33,7 @@ class CreateUnrealStaticMesh(plugin.Creator): def process(self): with lib.undo_chunk(): + self.name = "{}_{}".format(self.family, self.name) instance = super(CreateUnrealStaticMesh, self).process() content = cmds.sets(instance, query=True) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 0565b0b95c..7cdd91a7ea 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -20,7 +20,8 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "camera", "rig", "camerarig", - "xgen"] + "xgen", + "unrealStaticMesh"] representations = ["ma", "abc", "fbx", "mb"] label = "Reference" diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index b1fb0542f2..8d9b88ed32 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -1,6 +1,8 @@ # -*- coding: utf-8 -*- from maya import cmds import pyblish.api +from avalon.api import Session +from openpype.api import get_project_settings class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): @@ -16,10 +18,21 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): families = ["unrealStaticMesh"] def process(self, instance): + project_settings = get_project_settings(Session["AVALON_PROJECT"]) + sm_prefix = ( + project_settings + ["maya"] + ["create"] + ["CreateUnrealStaticMesh"] + ["static_mesh_prefix"] + ) # add fbx family to trigger fbx extractor instance.data["families"].append("fbx") - # take the name from instance (without the `S_` prefix) - instance.data["staticMeshCombinedName"] = instance.name[2:] + # take the name from instance (without the `unrealStaticMesh_` prefix) + instance.data["staticMeshCombinedName"] = "{}_{}".format( + sm_prefix, + instance.name[len(instance.data.get("family"))+3:] + ) geometry_set = [i for i in instance if i == "geometry_SET"] instance.data["membersToCombine"] = cmds.sets( diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 32dc9d1d1c..f46360e34a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -15,13 +15,24 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): def process(self, instance): to_combine = instance.data.get("membersToCombine") static_mesh_name = instance.data.get("staticMeshCombinedName") - self.log.info( - "merging {} into {}".format( - " + ".join(to_combine), static_mesh_name)) - duplicates = cmds.duplicate(to_combine, ic=True) - cmds.polyUnite( - *duplicates, - n=static_mesh_name, ch=False) + duplicates = [] + + # if we have more objects, combine them into one + # or just duplicate the single one + if len(to_combine) > 1: + self.log.info( + "merging {} into {}".format( + " + ".join(to_combine), static_mesh_name)) + duplicates = cmds.duplicate(to_combine, ic=True) + cmds.polyUnite( + *duplicates, + n=static_mesh_name, ch=False) + else: + self.log.info( + "duplicating {} to {} for export".format( + to_combine[0], static_mesh_name) + ) + cmds.duplicate(to_combine[0], name=static_mesh_name, ic=True) if not instance.data.get("cleanNodes"): instance.data["cleanNodes"] = [] @@ -31,3 +42,5 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): instance.data["setMembers"] = [static_mesh_name] instance.data["setMembers"] += instance.data["collisionMembers"] + + self.log.debug(instance.data["setMembers"]) diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 901a2ec75e..b886e7da75 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -71,6 +71,13 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): ["CreateUnrealStaticMesh"] ["collision_prefixes"] ) + static_mesh_prefix = ( + project_settings + ["maya"] + ["create"] + ["CreateUnrealStaticMesh"] + ["static_mesh_prefix"] + ) combined_geometry_name = instance.data.get( "staticMeshCombinedName", None) @@ -107,7 +114,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): else: expected_collision = "{}_{}".format( cl_m.group("prefix"), - combined_geometry_name + combined_geometry_name[len(static_mesh_prefix)+1:] ) if not obj.startswith(expected_collision): From 1c0601518a3488b450b9c97d30e7c9d3011b1fb4 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 15 Feb 2022 15:58:47 +0100 Subject: [PATCH 002/854] remove debug print --- .../hosts/maya/plugins/publish/extract_unreal_staticmesh.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index f46360e34a..0799d574a2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -42,5 +42,3 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): instance.data["setMembers"] = [static_mesh_name] instance.data["setMembers"] += instance.data["collisionMembers"] - - self.log.debug(instance.data["setMembers"]) From 3f7602ce8c7a463ff0472c33ac0e7a3df177cf80 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 15 Feb 2022 16:01:18 +0100 Subject: [PATCH 003/854] fix prefix --- openpype/settings/defaults/project_settings/maya.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 24e8e4a29b..4aaf6c705a 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -52,7 +52,7 @@ "", "_Main" ], - "static_mesh_prefix": "S_", + "static_mesh_prefix": "S", "collision_prefixes": [ "UBX", "UCP", From 3cf48636b1a885704e8e30ddf7104595d1d23df9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 17 Feb 2022 13:18:44 +0100 Subject: [PATCH 004/854] rename family to staticMesh --- .../maya/plugins/create/create_unreal_staticmesh.py | 5 ++--- openpype/hosts/maya/plugins/load/load_reference.py | 2 +- .../maya/plugins/publish/collect_unreal_staticmesh.py | 4 ++-- .../maya/plugins/publish/extract_unreal_staticmesh.py | 2 +- .../publish/validate_unreal_mesh_triangulated.py | 2 +- .../publish/validate_unreal_staticmesh_naming.py | 2 +- .../maya/plugins/publish/validate_unreal_up_axis.py | 2 +- openpype/plugins/publish/integrate_new.py | 3 ++- .../settings/defaults/project_anatomy/templates.json | 2 +- .../settings/defaults/project_settings/global.json | 11 +++++++++++ 10 files changed, 23 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py index 1fe7e57abc..f62d15fe62 100644 --- a/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/create/create_unreal_staticmesh.py @@ -10,7 +10,7 @@ class CreateUnrealStaticMesh(plugin.Creator): """Unreal Static Meshes with collisions.""" name = "staticMeshMain" label = "Unreal - Static Mesh" - family = "unrealStaticMesh" + family = "staticMesh" icon = "cube" dynamic_subset_keys = ["asset"] @@ -28,12 +28,11 @@ class CreateUnrealStaticMesh(plugin.Creator): variant, task_name, asset_id, project_name, host_name ) dynamic_data["asset"] = Session.get("AVALON_ASSET") - return dynamic_data def process(self): + self.name = "{}_{}".format(self.family, self.name) with lib.undo_chunk(): - self.name = "{}_{}".format(self.family, self.name) instance = super(CreateUnrealStaticMesh, self).process() content = cmds.sets(instance, query=True) diff --git a/openpype/hosts/maya/plugins/load/load_reference.py b/openpype/hosts/maya/plugins/load/load_reference.py index 7cdd91a7ea..8713182d3f 100644 --- a/openpype/hosts/maya/plugins/load/load_reference.py +++ b/openpype/hosts/maya/plugins/load/load_reference.py @@ -21,7 +21,7 @@ class ReferenceLoader(openpype.hosts.maya.api.plugin.ReferenceLoader): "rig", "camerarig", "xgen", - "unrealStaticMesh"] + "staticMesh"] representations = ["ma", "abc", "fbx", "mb"] label = "Reference" diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 8d9b88ed32..604aa58b50 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -15,7 +15,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): order = pyblish.api.CollectorOrder + 0.2 label = "Collect Unreal Static Meshes" - families = ["unrealStaticMesh"] + families = ["staticMesh"] def process(self, instance): project_settings = get_project_settings(Session["AVALON_PROJECT"]) @@ -28,7 +28,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): ) # add fbx family to trigger fbx extractor instance.data["families"].append("fbx") - # take the name from instance (without the `unrealStaticMesh_` prefix) + # take the name from instance (without the `staticMesh_` prefix) instance.data["staticMeshCombinedName"] = "{}_{}".format( sm_prefix, instance.name[len(instance.data.get("family"))+3:] diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 0799d574a2..6153417de4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -10,7 +10,7 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): order = pyblish.api.ExtractorOrder - 0.1 label = "Extract Unreal Static Mesh" - families = ["unrealStaticMesh"] + families = ["staticMesh"] def process(self, instance): to_combine = instance.data.get("membersToCombine") diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py index b2ef174374..737664ffd3 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py @@ -10,7 +10,7 @@ class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin): order = openpype.api.ValidateMeshOrder hosts = ["maya"] - families = ["unrealStaticMesh"] + families = ["staticMesh"] category = "geometry" label = "Mesh is Triangulated" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index b886e7da75..89769a3421 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -52,7 +52,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): optional = True order = openpype.api.ValidateContentsOrder hosts = ["maya"] - families = ["unrealStaticMesh"] + families = ["staticMesh"] label = "Unreal StaticMesh Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] regex_mesh = r"(?P.*))" diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py b/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py index 5a8c29c22d..b3af643048 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py @@ -11,7 +11,7 @@ class ValidateUnrealUpAxis(pyblish.api.ContextPlugin): optional = True order = openpype.api.ValidateContentsOrder hosts = ["maya"] - families = ["unrealStaticMesh"] + families = ["staticMesh"] label = "Unreal Up-Axis check" actions = [openpype.api.RepairAction] diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index bf214d9139..9ced6a1d7d 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -100,7 +100,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "redshiftproxy", "effect", "xgen", - "hda" + "hda", + "staticMesh" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index d46d449c77..2ab3ff5c54 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -28,7 +28,7 @@ }, "delivery": {}, "unreal": { - "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{subset}/{@version}", + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}", "file": "{subset}_{@version}<_{output}><.{@frame}>.{ext}", "path": "{@folder}/{@file}" }, diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index f08bee8b2d..93aba808db 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -192,6 +192,17 @@ "task_types": [], "tasks": [], "template_name": "render" + }, + { + "families": [ + "staticMesh" + ], + "hosts": [ + "maya" + ], + "task_types": [], + "tasks": [], + "template_name": "unreal" } ], "subset_grouping_profiles": [ From 487b273a09e3cc67bc0386d047bfb4309db66439 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 18 Feb 2022 18:05:07 +0100 Subject: [PATCH 005/854] refactor fbx extractor --- openpype/hosts/maya/api/fbx.py | 208 ++++++++++++++++++ .../hosts/maya/plugins/publish/clean_nodes.py | 31 --- .../publish/collect_unreal_staticmesh.py | 2 - .../hosts/maya/plugins/publish/extract_fbx.py | 196 ++--------------- .../publish/extract_unreal_staticmesh.py | 89 ++++++-- 5 files changed, 287 insertions(+), 239 deletions(-) create mode 100644 openpype/hosts/maya/api/fbx.py delete mode 100644 openpype/hosts/maya/plugins/publish/clean_nodes.py diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py new file mode 100644 index 0000000000..3a8ae19ff7 --- /dev/null +++ b/openpype/hosts/maya/api/fbx.py @@ -0,0 +1,208 @@ +# -*- coding: utf-8 -*- +"""Tools to work with FBX.""" +import os +import logging + +from pyblish.api import Instance + +from maya import cmds # noqa +import maya.mel as mel # noqa + + +class FBXExtractor: + """Extract FBX from Maya. + + This extracts reproducible FBX exports ignoring any of the settings set + on the local machine in the FBX export options window. + + All export settings are applied with the `FBXExport*` commands prior + to the `FBXExport` call itself. The options can be overridden with + their + nice names as seen in the "options" property on this class. + + For more information on FBX exports see: + - https://knowledge.autodesk.com/support/maya/learn-explore/caas + /CloudHelp/cloudhelp/2016/ENU/Maya/files/GUID-6CCE943A-2ED4-4CEE-96D4 + -9CB19C28F4E0-htm.html + - http://forums.cgsociety.org/archive/index.php?t-1032853.html + - https://groups.google.com/forum/#!msg/python_inside_maya/cLkaSo361oE + /LKs9hakE28kJ + + """ + @property + def options(self): + """Overridable options for FBX Export + + Given in the following format + - {NAME: EXPECTED TYPE} + + If the overridden option's type does not match, + the option is not included and a warning is logged. + + """ + + return { + "cameras": bool, + "smoothingGroups": bool, + "hardEdges": bool, + "tangents": bool, + "smoothMesh": bool, + "instances": bool, + # "referencedContainersContent": bool, # deprecated in Maya 2016+ + "bakeComplexAnimation": int, + "bakeComplexStart": int, + "bakeComplexEnd": int, + "bakeComplexStep": int, + "bakeResampleAnimation": bool, + "animationOnly": bool, + "useSceneName": bool, + "quaternion": str, # "euler" + "shapes": bool, + "skins": bool, + "constraints": bool, + "lights": bool, + "embeddedTextures": bool, + "inputConnections": bool, + "upAxis": str, # x, y or z, + "triangulate": bool + } + + @property + def default_options(self): + """The default options for FBX extraction. + + This includes shapes, skins, constraints, lights and incoming + connections and exports with the Y-axis as up-axis. + + By default this uses the time sliders start and end time. + + """ + + start_frame = int(cmds.playbackOptions(query=True, + animationStartTime=True)) + end_frame = int(cmds.playbackOptions(query=True, + animationEndTime=True)) + + return { + "cameras": False, + "smoothingGroups": False, + "hardEdges": False, + "tangents": False, + "smoothMesh": False, + "instances": False, + "bakeComplexAnimation": True, + "bakeComplexStart": start_frame, + "bakeComplexEnd": end_frame, + "bakeComplexStep": 1, + "bakeResampleAnimation": True, + "animationOnly": False, + "useSceneName": False, + "quaternion": "euler", + "shapes": True, + "skins": True, + "constraints": False, + "lights": True, + "embeddedTextures": True, + "inputConnections": True, + "upAxis": "y", + "triangulate": False + } + + def __init__(self, log=None): + # Ensure FBX plug-in is loaded + self.log = log or logging.getLogger(__class__.__name__) + cmds.loadPlugin("fbxmaya", quiet=True) + + def parse_overrides(self, instance, options): + """Inspect data of instance to determine overridden options + + An instance may supply any of the overridable options + as data, the option is then added to the extraction. + + """ + + for key in instance.data: + if key not in self.options: + continue + + # Ensure the data is of correct type + value = instance.data[key] + if not isinstance(value, self.options[key]): + self.log.warning( + "Overridden attribute {key} was of " + "the wrong type: {invalid_type} " + "- should have been {valid_type}".format( + key=key, + invalid_type=type(value).__name__, + valid_type=self.options[key].__name__)) + continue + + options[key] = value + + return options + + def set_options_from_instance(self, instance): + # type: (Instance) -> None + """Sets FBX export options from data in the instance. + + Args: + instance (Instance): Instance data. + + """ + # Parse export options + options = self.default_options + options = self.parse_overrides(instance, options) + self.log.info("Export options: {0}".format(options)) + + # Collect the start and end including handles + # TODO: Move this to library function (pypeclub/OpenPype#2648) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] + handle_start = instance.data.get("handleStart", 0) + handle_end = instance.data.get("handleEnd", 0) + if handle_start: + start -= handle_start + if handle_end: + end += handle_end + + options['bakeComplexStart'] = start + options['bakeComplexEnd'] = end + + # First apply the default export settings to be fully consistent + # each time for successive publishes + mel.eval("FBXResetExport") + + # Apply the FBX overrides through MEL since the commands + # only work correctly in MEL according to online + # available discussions on the topic + _iteritems = getattr(options, "iteritems", options.items) + for option, value in _iteritems(): + key = option[0].upper() + option[1:] # uppercase first letter + + # Boolean must be passed as lower-case strings + # as to MEL standards + if isinstance(value, bool): + value = str(value).lower() + + template = "FBXExport{0} {1}" if key == "UpAxis" else \ + "FBXExport{0} -v {1}" # noqa + cmd = template.format(key, value) + self.log.info(cmd) + mel.eval(cmd) + + # Never show the UI or generate a log + mel.eval("FBXExportShowUI -v false") + mel.eval("FBXExportGenerateLog -v false") + + @staticmethod + def export(members, path): + # type: (list, str) -> None + """Export members as FBX with given path. + + Args: + members (list): List of members to export. + path (str): Path to use for export. + + """ + cmds.select(members, r=1, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/plugins/publish/clean_nodes.py b/openpype/hosts/maya/plugins/publish/clean_nodes.py deleted file mode 100644 index 03995cdabe..0000000000 --- a/openpype/hosts/maya/plugins/publish/clean_nodes.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -"""Cleanup leftover nodes.""" -from maya import cmds # noqa -import pyblish.api - - -class CleanNodesUp(pyblish.api.InstancePlugin): - """Cleans up the staging directory after a successful publish. - - This will also clean published renders and delete their parent directories. - - """ - - order = pyblish.api.IntegratorOrder + 10 - label = "Clean Nodes" - optional = True - active = True - - def process(self, instance): - if not instance.data.get("cleanNodes"): - self.log.info("Nothing to clean.") - return - - nodes_to_clean = instance.data.pop("cleanNodes", []) - self.log.info("Removing {} nodes".format(len(nodes_to_clean))) - for node in nodes_to_clean: - try: - cmds.delete(node) - except ValueError: - # object might be already deleted, don't complain about it - pass diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 604aa58b50..1a0a561efd 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -26,8 +26,6 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): ["CreateUnrealStaticMesh"] ["static_mesh_prefix"] ) - # add fbx family to trigger fbx extractor - instance.data["families"].append("fbx") # take the name from instance (without the `staticMesh_` prefix) instance.data["staticMeshCombinedName"] = "{}_{}".format( sm_prefix, diff --git a/openpype/hosts/maya/plugins/publish/extract_fbx.py b/openpype/hosts/maya/plugins/publish/extract_fbx.py index 844084b9ab..fbbe8e06b0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_fbx.py +++ b/openpype/hosts/maya/plugins/publish/extract_fbx.py @@ -5,152 +5,29 @@ from maya import cmds # noqa import maya.mel as mel # noqa import pyblish.api import openpype.api -from openpype.hosts.maya.api.lib import ( - root_parent, - maintained_selection -) +from openpype.hosts.maya.api.lib import maintained_selection + +from openpype.hosts.maya.api import fbx class ExtractFBX(openpype.api.Extractor): """Extract FBX from Maya. - This extracts reproducible FBX exports ignoring any of the settings set - on the local machine in the FBX export options window. - - All export settings are applied with the `FBXExport*` commands prior - to the `FBXExport` call itself. The options can be overridden with their - nice names as seen in the "options" property on this class. - - For more information on FBX exports see: - - https://knowledge.autodesk.com/support/maya/learn-explore/caas - /CloudHelp/cloudhelp/2016/ENU/Maya/files/GUID-6CCE943A-2ED4-4CEE-96D4 - -9CB19C28F4E0-htm.html - - http://forums.cgsociety.org/archive/index.php?t-1032853.html - - https://groups.google.com/forum/#!msg/python_inside_maya/cLkaSo361oE - /LKs9hakE28kJ + This extracts reproducible FBX exports ignoring any of the + settings set on the local machine in the FBX export options window. """ - order = pyblish.api.ExtractorOrder label = "Extract FBX" families = ["fbx"] - @property - def options(self): - """Overridable options for FBX Export - - Given in the following format - - {NAME: EXPECTED TYPE} - - If the overridden option's type does not match, - the option is not included and a warning is logged. - - """ - - return { - "cameras": bool, - "smoothingGroups": bool, - "hardEdges": bool, - "tangents": bool, - "smoothMesh": bool, - "instances": bool, - # "referencedContainersContent": bool, # deprecated in Maya 2016+ - "bakeComplexAnimation": int, - "bakeComplexStart": int, - "bakeComplexEnd": int, - "bakeComplexStep": int, - "bakeResampleAnimation": bool, - "animationOnly": bool, - "useSceneName": bool, - "quaternion": str, # "euler" - "shapes": bool, - "skins": bool, - "constraints": bool, - "lights": bool, - "embeddedTextures": bool, - "inputConnections": bool, - "upAxis": str, # x, y or z, - "triangulate": bool - } - - @property - def default_options(self): - """The default options for FBX extraction. - - This includes shapes, skins, constraints, lights and incoming - connections and exports with the Y-axis as up-axis. - - By default this uses the time sliders start and end time. - - """ - - start_frame = int(cmds.playbackOptions(query=True, - animationStartTime=True)) - end_frame = int(cmds.playbackOptions(query=True, - animationEndTime=True)) - - return { - "cameras": False, - "smoothingGroups": False, - "hardEdges": False, - "tangents": False, - "smoothMesh": False, - "instances": False, - "bakeComplexAnimation": True, - "bakeComplexStart": start_frame, - "bakeComplexEnd": end_frame, - "bakeComplexStep": 1, - "bakeResampleAnimation": True, - "animationOnly": False, - "useSceneName": False, - "quaternion": "euler", - "shapes": True, - "skins": True, - "constraints": False, - "lights": True, - "embeddedTextures": True, - "inputConnections": True, - "upAxis": "y", - "triangulate": False - } - - def parse_overrides(self, instance, options): - """Inspect data of instance to determine overridden options - - An instance may supply any of the overridable options - as data, the option is then added to the extraction. - - """ - - for key in instance.data: - if key not in self.options: - continue - - # Ensure the data is of correct type - value = instance.data[key] - if not isinstance(value, self.options[key]): - self.log.warning( - "Overridden attribute {key} was of " - "the wrong type: {invalid_type} " - "- should have been {valid_type}".format( - key=key, - invalid_type=type(value).__name__, - valid_type=self.options[key].__name__)) - continue - - options[key] = value - - return options - def process(self, instance): - - # Ensure FBX plug-in is loaded - cmds.loadPlugin("fbxmaya", quiet=True) + fbx_exporter = fbx.FBXExtractor(log=self.log) # Define output path - stagingDir = self.staging_dir(instance) + staging_dir = self.staging_dir(instance) filename = "{0}.fbx".format(instance.name) - path = os.path.join(stagingDir, filename) + path = os.path.join(staging_dir, filename) # The export requires forward slashes because we need # to format it into a string in a mel expression @@ -162,58 +39,13 @@ class ExtractFBX(openpype.api.Extractor): self.log.info("Members: {0}".format(members)) self.log.info("Instance: {0}".format(instance[:])) - # Parse export options - options = self.default_options - options = self.parse_overrides(instance, options) - 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 - - options['bakeComplexStart'] = start - options['bakeComplexEnd'] = end - - # First apply the default export settings to be fully consistent - # each time for successive publishes - mel.eval("FBXResetExport") - - # Apply the FBX overrides through MEL since the commands - # only work correctly in MEL according to online - # available discussions on the topic - _iteritems = getattr(options, "iteritems", options.items) - for option, value in _iteritems(): - key = option[0].upper() + option[1:] # uppercase first letter - - # Boolean must be passed as lower-case strings - # as to MEL standards - if isinstance(value, bool): - value = str(value).lower() - - template = "FBXExport{0} {1}" if key == "UpAxis" else "FBXExport{0} -v {1}" # noqa - cmd = template.format(key, value) - self.log.info(cmd) - mel.eval(cmd) - - # Never show the UI or generate a log - mel.eval("FBXExportShowUI -v false") - mel.eval("FBXExportGenerateLog -v false") + fbx_exporter.set_options_from_instance(instance) # Export - if "unrealStaticMesh" in instance.data["families"]: - with maintained_selection(): - with root_parent(members): - self.log.info("Un-parenting: {}".format(members)) - cmds.select(members, r=1, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) - else: - with maintained_selection(): - cmds.select(members, r=1, noExpand=True) - mel.eval('FBXExport -f "{}" -s'.format(path)) + with maintained_selection(): + fbx_exporter.export(members, path) + cmds.select(members, r=1, noExpand=True) + mel.eval('FBXExport -f "{}" -s'.format(path)) if "representations" not in instance.data: instance.data["representations"] = [] @@ -222,7 +54,7 @@ class ExtractFBX(openpype.api.Extractor): 'name': 'fbx', 'ext': 'fbx', 'files': filename, - "stagingDir": stagingDir, + "stagingDir": staging_dir, } instance.data["representations"].append(representation) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 6153417de4..d3d491594a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -1,9 +1,18 @@ # -*- coding: utf-8 -*- """Create Unreal Static Mesh data to be extracted as FBX.""" -import openpype.api -import pyblish.api +import os + from maya import cmds # noqa +import pyblish.api +import openpype.api +from openpype.hosts.maya.api.lib import ( + root_parent, + maintained_selection, + delete_after +) +from openpype.hosts.maya.api import fbx + class ExtractUnrealStaticMesh(openpype.api.Extractor): """Extract FBX from Maya. """ @@ -13,32 +22,64 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): families = ["staticMesh"] def process(self, instance): + fbx_exporter = fbx.FBXExtractor(log=self.log) to_combine = instance.data.get("membersToCombine") static_mesh_name = instance.data.get("staticMeshCombinedName") duplicates = [] - # if we have more objects, combine them into one - # or just duplicate the single one - if len(to_combine) > 1: - self.log.info( - "merging {} into {}".format( - " + ".join(to_combine), static_mesh_name)) - duplicates = cmds.duplicate(to_combine, ic=True) - cmds.polyUnite( - *duplicates, - n=static_mesh_name, ch=False) - else: - self.log.info( - "duplicating {} to {} for export".format( - to_combine[0], static_mesh_name) - ) - cmds.duplicate(to_combine[0], name=static_mesh_name, ic=True) + # delete created temporary nodes after extraction + with delete_after() as delete_bin: + # if we have more objects, combine them into one + # or just duplicate the single one + if len(to_combine) > 1: + self.log.info( + "merging {} into {}".format( + " + ".join(to_combine), static_mesh_name)) + duplicates = cmds.duplicate(to_combine, ic=True) + cmds.polyUnite( + *duplicates, + n=static_mesh_name, ch=False) + else: + self.log.info( + "duplicating {} to {} for export".format( + to_combine[0], static_mesh_name) + ) + cmds.duplicate(to_combine[0], name=static_mesh_name, ic=True) - if not instance.data.get("cleanNodes"): - instance.data["cleanNodes"] = [] + delete_bin.extend(static_mesh_name) + delete_bin.extend(duplicates) - instance.data["cleanNodes"].append(static_mesh_name) - instance.data["cleanNodes"] += duplicates + members = [static_mesh_name] + members += instance.data["collisionMembers"] - instance.data["setMembers"] = [static_mesh_name] - instance.data["setMembers"] += instance.data["collisionMembers"] + fbx_exporter = fbx.FBXExtractor() + + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace('\\', '/') + + self.log.info("Extracting FBX to: {0}".format(path)) + self.log.info("Members: {0}".format(members)) + self.log.info("Instance: {0}".format(instance[:])) + + fbx_exporter.set_options_from_instance(instance) + + with maintained_selection(): + with root_parent(members): + self.log.info("Un-parenting: {}".format(members)) + fbx_exporter.export(members, path) + + representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) + + self.log.info("Extract FBX successful to: {0}".format(path)) \ No newline at end of file From 9604dca2593745380fc24741b7b7f5cf45f76ba8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 18 Feb 2022 18:40:21 +0100 Subject: [PATCH 006/854] fix logging --- openpype/hosts/maya/api/fbx.py | 2 +- .../maya/plugins/publish/extract_unreal_staticmesh.py | 8 +++++--- .../plugins/publish/validate_unreal_staticmesh_naming.py | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 3a8ae19ff7..659f456e1a 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -110,7 +110,7 @@ class FBXExtractor: def __init__(self, log=None): # Ensure FBX plug-in is loaded - self.log = log or logging.getLogger(__class__.__name__) + self.log = log or logging.getLogger(self.__class__.__name__) cmds.loadPlugin("fbxmaya", quiet=True) def parse_overrides(self, instance, options): diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index d3d491594a..c5d2710dc2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -22,7 +22,6 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): families = ["staticMesh"] def process(self, instance): - fbx_exporter = fbx.FBXExtractor(log=self.log) to_combine = instance.data.get("membersToCombine") static_mesh_name = instance.data.get("staticMeshCombinedName") duplicates = [] @@ -46,13 +45,13 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): ) cmds.duplicate(to_combine[0], name=static_mesh_name, ic=True) - delete_bin.extend(static_mesh_name) + delete_bin.extend([static_mesh_name]) delete_bin.extend(duplicates) members = [static_mesh_name] members += instance.data["collisionMembers"] - fbx_exporter = fbx.FBXExtractor() + fbx_exporter = fbx.FBXExtractor(log=self.log) # Define output path staging_dir = self.staging_dir(instance) @@ -74,6 +73,9 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): self.log.info("Un-parenting: {}".format(members)) fbx_exporter.export(members, path) + if "representations" not in instance.data: + instance.data["representations"] = [] + representation = { 'name': 'fbx', 'ext': 'fbx', diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 89769a3421..e233fd190c 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -79,6 +79,9 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): ["static_mesh_prefix"] ) + to_combine = instance.data.get("membersToCombine") + if not to_combine: + raise ValueError("Missing geometry to export.") combined_geometry_name = instance.data.get( "staticMeshCombinedName", None) if cls.validate_mesh: From 3a42aa5c943e3a1a9a03a9f5c34cde0dbe8263d7 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 18 Feb 2022 18:42:19 +0100 Subject: [PATCH 007/854] fix family name in defaults --- openpype/settings/defaults/project_settings/global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 93aba808db..efed25287a 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -298,7 +298,7 @@ }, { "families": [ - "unrealStaticMesh" + "staticMesh" ], "hosts": [ "maya" From f5087f4e47588d31bd335e51c648c8eb6bb33425 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 18 Feb 2022 19:04:24 +0100 Subject: [PATCH 008/854] =?UTF-8?q?fix=20hound=20=F0=9F=90=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openpype/hosts/maya/api/fbx.py | 1 - .../hosts/maya/plugins/publish/collect_unreal_staticmesh.py | 2 +- .../hosts/maya/plugins/publish/extract_unreal_staticmesh.py | 2 +- .../maya/plugins/publish/validate_unreal_staticmesh_naming.py | 2 +- 4 files changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 659f456e1a..00c58153af 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- """Tools to work with FBX.""" -import os import logging from pyblish.api import Instance diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 1a0a561efd..2c0bec2c1a 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -29,7 +29,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): # take the name from instance (without the `staticMesh_` prefix) instance.data["staticMeshCombinedName"] = "{}_{}".format( sm_prefix, - instance.name[len(instance.data.get("family"))+3:] + instance.name[len(instance.data.get("family")) + 3:] ) geometry_set = [i for i in instance if i == "geometry_SET"] diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index c5d2710dc2..0c7d61f8f5 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -84,4 +84,4 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extract FBX successful to: {0}".format(path)) \ No newline at end of file + self.log.info("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index e233fd190c..fd19e3d2af 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -117,7 +117,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): else: expected_collision = "{}_{}".format( cl_m.group("prefix"), - combined_geometry_name[len(static_mesh_prefix)+1:] + combined_geometry_name[len(static_mesh_prefix) + 1:] ) if not obj.startswith(expected_collision): From 8dcc2eabff6a39c47f4fae519cc9de658039312d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 18 Feb 2022 22:24:37 +0100 Subject: [PATCH 009/854] flame: adding batch utils for creating batch in desktop --- openpype/hosts/flame/api/batch_utils.py | 37 +++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 openpype/hosts/flame/api/batch_utils.py diff --git a/openpype/hosts/flame/api/batch_utils.py b/openpype/hosts/flame/api/batch_utils.py new file mode 100644 index 0000000000..3a155c4b8d --- /dev/null +++ b/openpype/hosts/flame/api/batch_utils.py @@ -0,0 +1,37 @@ +import flame + + +def create_batch(name, frame_start, frame_end, **kwargs): + schematicReels = ['LoadedReel1'] + shelfReels = ['ShelfReel1'] + + handle_start = kwargs.get("handleStart") + handle_end = kwargs.get("handleEnd") + + if handle_start: + frame_start -= handle_start + if handle_end: + frame_end += handle_end + + # Create batch group with name, start_frame value, duration value, + # set of schematic reel names, set of shelf reel names + flame.batch.create_batch_group( + name, + start_frame=frame_start, + duration=frame_end, + reels=schematicReels, + shelf_reels=shelfReels + ) + + if kwargs.get("switch_batch_tab"): + # use this command to switch to the batch tab + flame.batch.go_to() + + comp = flame.batch.create_node("Comp") + writeFile = flame.batch.create_node("Write File") + + # connect nodes + flame.batch.connect_nodes(comp, "Result", writeFile, "Front") + + # sort batch nodes + flame.batch.organize() From 5391f1fff3abe36dde6289778ab2823e79616ab7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 18 Feb 2022 22:46:27 +0100 Subject: [PATCH 010/854] flame: adding write node to batch utils --- openpype/hosts/flame/api/batch_utils.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/batch_utils.py b/openpype/hosts/flame/api/batch_utils.py index 3a155c4b8d..2c80834928 100644 --- a/openpype/hosts/flame/api/batch_utils.py +++ b/openpype/hosts/flame/api/batch_utils.py @@ -5,6 +5,7 @@ def create_batch(name, frame_start, frame_end, **kwargs): schematicReels = ['LoadedReel1'] shelfReels = ['ShelfReel1'] + write_pref = kwargs["write_pref"] handle_start = kwargs.get("handleStart") handle_end = kwargs.get("handleEnd") @@ -27,11 +28,23 @@ def create_batch(name, frame_start, frame_end, **kwargs): # use this command to switch to the batch tab flame.batch.go_to() - comp = flame.batch.create_node("Comp") - writeFile = flame.batch.create_node("Write File") + comp_node = flame.batch.create_node("Comp") + + # create write node + write_node = flame.batch.create_node('Write File') + write_node.media_path = write_pref["media_path"] + write_node.media_path_pattern = write_pref["media_path_pattern"] + write_node.create_clip = write_pref["create_clip"] + write_node.include_setup = write_pref["include_setup"] + write_node.create_clip_path = write_pref["create_clip_path"] + write_node.include_setup_path = write_pref["include_setup_path"] + write_node.file_type = write_pref["file_type"] + write_node.bit_depth = write_pref["bit_depth"] + write_node.frame_index_mode = write_pref["frame_index_mode"] + write_node.frame_padding = int(write_pref["frame_padding"]) # connect nodes - flame.batch.connect_nodes(comp, "Result", writeFile, "Front") + flame.batch.connect_nodes(comp_node, "Result", write_node, "Front") # sort batch nodes flame.batch.organize() From cdc3d0be792e718b17fb1290f3f05a5ea8c4380f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Feb 2022 12:49:11 +0100 Subject: [PATCH 011/854] flame: batch utils to api --- openpype/hosts/flame/api/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 56bbadd2fc..98a1a23e89 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -70,6 +70,9 @@ from .render_utils import ( export_clip, get_preset_path_by_xml_name ) +from .batch_utils import ( + create_batch +) __all__ = [ # constants @@ -140,5 +143,8 @@ __all__ = [ # render utils "export_clip", - "get_preset_path_by_xml_name" + "get_preset_path_by_xml_name", + + # batch utils + "create_batch" ] From 5d8e3e293f46860f9de52f93736f9646023de501 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Feb 2022 12:49:33 +0100 Subject: [PATCH 012/854] flame: adding docstrigs to create_batch --- openpype/hosts/flame/api/batch_utils.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/api/batch_utils.py b/openpype/hosts/flame/api/batch_utils.py index 2c80834928..a1fe7961c4 100644 --- a/openpype/hosts/flame/api/batch_utils.py +++ b/openpype/hosts/flame/api/batch_utils.py @@ -2,8 +2,15 @@ import flame def create_batch(name, frame_start, frame_end, **kwargs): - schematicReels = ['LoadedReel1'] - shelfReels = ['ShelfReel1'] + """Create Batch Group in active project's Desktop + + Args: + name (str): name of batch group to be created + frame_start (int): start frame of batch + frame_end (int): end frame of batch + """ + schematic_reels = kwargs.get("shematic_reels") or ['LoadedReel1'] + shelf_reels = kwargs.get("shelf_reels") or ['ShelfReel1'] write_pref = kwargs["write_pref"] handle_start = kwargs.get("handleStart") @@ -20,8 +27,8 @@ def create_batch(name, frame_start, frame_end, **kwargs): name, start_frame=frame_start, duration=frame_end, - reels=schematicReels, - shelf_reels=shelfReels + reels=schematic_reels, + shelf_reels=shelf_reels ) if kwargs.get("switch_batch_tab"): From 162df8c0aca975da19778cd05e22771f651458a9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Feb 2022 13:00:14 +0100 Subject: [PATCH 013/854] flame: itegrator wip --- .../flame/plugins/publish/integrate_batch_group.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 openpype/hosts/flame/plugins/publish/integrate_batch_group.py diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py new file mode 100644 index 0000000000..fd88ed318e --- /dev/null +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -0,0 +1,14 @@ +import pyblish +import openpype.hosts.flame.api as opfapi + +@pyblish.api.log +class IntegrateBatchGroup(pyblish.api.InstancePlugin): + """Integrate published shot to batch group""" + + order = pyblish.api.IntegratorOrder + 0.45 + label = "Integrate Batch Groups" + hosts = ["flame"] + families = ["clip"] + + def process(self, instance): + opfapi.create_batch \ No newline at end of file From 1121bc8eaa42a0f8fcdda1001500908e42c7308e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 14:36:43 +0100 Subject: [PATCH 014/854] disable unnecessary plugins --- .../maya/plugins/publish/validate_unreal_mesh_triangulated.py | 1 + .../maya/plugins/publish/validate_unreal_staticmesh_naming.py | 4 ++-- .../hosts/maya/plugins/publish/validate_unreal_up_axis.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py index 737664ffd3..c05121a1b0 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_mesh_triangulated.py @@ -14,6 +14,7 @@ class ValidateUnrealMeshTriangulated(pyblish.api.InstancePlugin): category = "geometry" label = "Mesh is Triangulated" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] + active = False @classmethod def get_invalid(cls, instance): diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index fd19e3d2af..d15d52f3bd 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -53,7 +53,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): order = openpype.api.ValidateContentsOrder hosts = ["maya"] families = ["staticMesh"] - label = "Unreal StaticMesh Name" + label = "Unreal Static Mesh Name" actions = [openpype.hosts.maya.api.action.SelectInvalidAction] regex_mesh = r"(?P.*))" regex_collision = r"(?P.*)" @@ -101,7 +101,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): cls.log.warning("No collision objects to validate.") return False - regex_collision = "{}{}".format( + regex_collision = "{}{}_(\\d+)".format( "(?P({}))_".format( "|".join("{0}".format(p) for p in collision_prefixes) ) or "", cls.regex_collision diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py b/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py index b3af643048..5e1b04889f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_up_axis.py @@ -9,6 +9,7 @@ class ValidateUnrealUpAxis(pyblish.api.ContextPlugin): """Validate if Z is set as up axis in Maya""" optional = True + active = False order = openpype.api.ValidateContentsOrder hosts = ["maya"] families = ["staticMesh"] From 691f23d72eab7ab0114efc6a9890ddcdce89f0da Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 14:38:48 +0100 Subject: [PATCH 015/854] unify handles --- openpype/hosts/maya/api/fbx.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 00c58153af..7980cd029c 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -154,15 +154,8 @@ class FBXExtractor: self.log.info("Export options: {0}".format(options)) # Collect the start and end including handles - # TODO: Move this to library function (pypeclub/OpenPype#2648) - start = instance.data["frameStart"] - end = instance.data["frameEnd"] - handle_start = instance.data.get("handleStart", 0) - handle_end = instance.data.get("handleEnd", 0) - if handle_start: - start -= handle_start - if handle_end: - end += handle_end + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] options['bakeComplexStart'] = start options['bakeComplexEnd'] = end From 901df528d4107e9423e7bc81aa80108024af143f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 14:42:36 +0100 Subject: [PATCH 016/854] remove ftrack submodules from old location --- .gitmodules | 8 +------- .../modules/default_modules/ftrack/python2_vendor/arrow | 1 - .../ftrack/python2_vendor/ftrack-python-api | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/arrow delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api diff --git a/.gitmodules b/.gitmodules index e1b0917e9d..67b820a247 100644 --- a/.gitmodules +++ b/.gitmodules @@ -3,10 +3,4 @@ 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 -[submodule "openpype/modules/default_modules/ftrack/python2_vendor/arrow"] - path = openpype/modules/default_modules/ftrack/python2_vendor/arrow - url = https://github.com/arrow-py/arrow.git -[submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"] - path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api - url = https://bitbucket.org/ftrack/ftrack-python-api.git \ No newline at end of file + url = https://github.com/pypeclub/avalon-unreal-integration.git \ No newline at end of file diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/arrow b/openpype/modules/default_modules/ftrack/python2_vendor/arrow deleted file mode 160000 index b746fedf72..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/arrow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api deleted file mode 160000 index d277f474ab..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e From 28e11a5b2d847d0db971fe8a7d5c707064ac17fd Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 15:18:38 +0100 Subject: [PATCH 017/854] fix frame handling and collision name determination --- openpype/hosts/maya/api/fbx.py | 6 ++++-- .../hosts/maya/plugins/publish/collect_unreal_staticmesh.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 7980cd029c..92683da51b 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -154,8 +154,10 @@ class FBXExtractor: self.log.info("Export options: {0}".format(options)) # Collect the start and end including handles - start = instance.data["frameStartHandle"] - end = instance.data["frameEndHandle"] + start = instance.data.get("frameStartHandle") or \ + instance.context.data.get("frameStartHandle") + end = instance.data.get("frameEndHandle") or \ + instance.context.data.get("frameEndHandle") options['bakeComplexStart'] = start options['bakeComplexEnd'] = end diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 2c0bec2c1a..ddcc3f691f 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -29,7 +29,7 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): # take the name from instance (without the `staticMesh_` prefix) instance.data["staticMeshCombinedName"] = "{}_{}".format( sm_prefix, - instance.name[len(instance.data.get("family")) + 3:] + instance.name[len(instance.data.get("family")) + 1:] ) geometry_set = [i for i in instance if i == "geometry_SET"] From bd5478731cc6f6839d7dcbbeb1fd6d9d37e7cff0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 1 Mar 2022 22:39:53 +0100 Subject: [PATCH 018/854] change default templates --- .../defaults/project_anatomy/templates.json | 15 ++++++++++++--- .../defaults/project_settings/global.json | 9 ++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index 2ab3ff5c54..7d01248653 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -28,9 +28,18 @@ }, "delivery": {}, "unreal": { - "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}", - "file": "{subset}_{@version}<_{output}><.{@frame}>.{ext}", + "folder": "{root[work]}/{project[name]}/unreal/{task[name]}", + "file": "{project[code]}_{asset}", "path": "{@folder}/{@file}" }, - "others": {} + "others": { + "maya2unreal": { + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}", + "file": "{subset}_{@version}<_{output}><.{@frame}>.{ext}", + "path": "{@folder}/{@file}" + }, + "__dynamic_keys_labels__": { + "maya2unreal": "Maya to Unreal" + } + } } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index efed25287a..86786cc9ed 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -202,7 +202,7 @@ ], "task_types": [], "tasks": [], - "template_name": "unreal" + "template_name": "maya2unreal" } ], "subset_grouping_profiles": [ @@ -315,6 +315,13 @@ "task_types": [], "hosts": [], "workfile_template": "work" + }, + { + "task_types": [], + "hosts": [ + "unreal" + ], + "workfile_template": "unreal" } ], "last_workfile_on_startup": [ From 7f83c8a2d028ddeeb772d1bcc7e4a0348568ee25 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 15:21:42 +0100 Subject: [PATCH 019/854] OP-2765 - added methods for New Publisher Removed uuid, replaced with instance_id or first members item --- openpype/hosts/aftereffects/api/__init__.py | 8 ++++- openpype/hosts/aftereffects/api/pipeline.py | 39 +++++++++++++++------ openpype/hosts/aftereffects/api/ws_stub.py | 20 +++++------ 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index cea1bdc023..2ad1255d27 100644 --- a/openpype/hosts/aftereffects/api/__init__.py +++ b/openpype/hosts/aftereffects/api/__init__.py @@ -16,7 +16,10 @@ from .pipeline import ( uninstall, list_instances, remove_instance, - containerise + containerise, + get_context_data, + update_context_data, + get_context_title ) from .workio import ( @@ -51,6 +54,9 @@ __all__ = [ "list_instances", "remove_instance", "containerise", + "get_context_data", + "update_context_data", + "get_context_title", "file_extensions", "has_unsaved_changes", diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 94f1e3d105..ea03542765 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -10,6 +10,7 @@ from avalon import io, pipeline from openpype import lib from openpype.api import Logger import openpype.hosts.aftereffects +from openpype.pipeline import BaseCreator from .launch_logic import get_stub @@ -67,6 +68,7 @@ def install(): avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) log.info(PUBLISH_PATH) pyblish.api.register_callback( @@ -238,12 +240,6 @@ def list_instances(): if instance.get("schema") and \ "container" in instance.get("schema"): continue - - uuid_val = instance.get("uuid") - if uuid_val: - instance['uuid'] = uuid_val - else: - instance['uuid'] = instance.get("members")[0] # legacy instances.append(instance) return instances @@ -265,8 +261,29 @@ def remove_instance(instance): if not stub: return - stub.remove_instance(instance.get("uuid")) - item = stub.get_item(instance.get("uuid")) - if item: - stub.rename_item(item.id, - item.name.replace(stub.PUBLISH_ICON, '')) + inst_id = instance.get("instance_id") + if not inst_id: + log.warning("No instance identifier for {}".format(instance)) + return + + stub.remove_instance(inst_id) + + if instance.members: + item = stub.get_item(instance.members[0]) + if item: + stub.rename_item(item.id, + item.name.replace(stub.PUBLISH_ICON, '')) + + +def get_context_data(): + print("get_context_data") + return {} + + +def update_context_data(data, changes): + print("update_context_data") + + +def get_context_title(): + """Returns title for Creator window""" + return "AfterEffects" diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index 5a0600e92e..d098419e81 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -28,6 +28,7 @@ class AEItem(object): workAreaDuration = attr.ib(default=None) frameRate = attr.ib(default=None) file_name = attr.ib(default=None) + instance_id = attr.ib(default=None) # New Publisher class AfterEffectsServerStub(): @@ -132,8 +133,9 @@ class AfterEffectsServerStub(): is_new = True for item_meta in items_meta: - if item_meta.get('members') \ - and str(item.id) == str(item_meta.get('members')[0]): + if ((item_meta.get('members') and + str(item.id) == str(item_meta.get('members')[0])) or + item_meta.get("instance_id") == item.id): is_new = False if data: item_meta.update(data) @@ -314,15 +316,12 @@ class AfterEffectsServerStub(): Keep matching item in file though. Args: - instance_id(string): instance uuid + instance_id(string): instance id """ cleaned_data = [] for instance in self.get_metadata(): - uuid_val = instance.get("uuid") - if not uuid_val: - uuid_val = instance.get("members")[0] # legacy - if uuid_val != instance_id: + if instance.get("instance_id") != instance_id: cleaned_data.append(instance) payload = json.dumps(cleaned_data, indent=4) @@ -357,7 +356,7 @@ class AfterEffectsServerStub(): item_id (int): Returns: - (namedtuple) + (AEItem) """ res = self.websocketserver.call(self.client.call @@ -418,7 +417,7 @@ class AfterEffectsServerStub(): """ Get render queue info for render purposes Returns: - (namedtuple): with 'file_name' field + (AEItem): with 'file_name' field """ res = self.websocketserver.call(self.client.call ('AfterEffects.get_render_info')) @@ -606,7 +605,8 @@ class AfterEffectsServerStub(): d.get('workAreaStart'), d.get('workAreaDuration'), d.get('frameRate'), - d.get('file_name')) + d.get('file_name'), + d.get("instance_id")) ret.append(item) return ret From 2af112571dd0435b639c78c4ccac9f185e1338e6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 15:26:56 +0100 Subject: [PATCH 020/854] OP-2765 - refactor - order of methods changed --- openpype/hosts/aftereffects/api/pipeline.py | 187 ++++++++++---------- 1 file changed, 96 insertions(+), 91 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index ea03542765..1ec76fd9dd 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -27,39 +27,6 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create") INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory") -def check_inventory(): - if not lib.any_outdated(): - return - - host = pyblish.api.registered_host() - outdated_containers = [] - for container in host.ls(): - representation = container['representation'] - representation_doc = io.find_one( - { - "_id": io.ObjectId(representation), - "type": "representation" - }, - projection={"parent": True} - ) - if representation_doc and not lib.is_latest(representation_doc): - outdated_containers.append(container) - - # Warn about outdated containers. - print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) - - message_box = QtWidgets.QMessageBox() - message_box.setIcon(QtWidgets.QMessageBox.Warning) - msg = "There are outdated containers in the scene." - message_box.setText(msg) - message_box.exec_() - - -def application_launch(): - check_inventory() - - def install(): print("Installing Pype config...") @@ -84,6 +51,11 @@ def uninstall(): avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) +def application_launch(): + """Triggered after start of app""" + check_inventory() + + def on_pyblish_instance_toggled(instance, old_value, new_value): """Toggle layer visibility on instance toggles.""" instance[0].Visible = new_value @@ -118,6 +90,77 @@ def get_asset_settings(): } +# loaded containers section +def ls(): + """Yields containers from active AfterEffects document. + + This is the host-equivalent of api.ls(), but instead of listing + assets on disk, it lists assets already loaded in AE; once loaded + they are called 'containers'. Used in Manage tool. + + Containers could be on multiple levels, single images/videos/was as a + FootageItem, or multiple items - backgrounds (folder with automatically + created composition and all imported layers). + + Yields: + dict: container + + """ + try: + stub = get_stub() # only after AfterEffects is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + layers_meta = stub.get_metadata() + for item in stub.get_items(comps=True, + folders=True, + footages=True): + data = stub.read(item, layers_meta) + # Skip non-tagged layers. + if not data: + continue + + # Filter to only containers. + if "container" not in data["id"]: + continue + + # Append transient data + data["objectName"] = item.name.replace(stub.LOADED_ICON, '') + data["layer"] = item + yield data + + +def check_inventory(): + """Checks loaded containers if they are of highest version""" + if not lib.any_outdated(): + return + + host = pyblish.api.registered_host() + outdated_containers = [] + for container in host.ls(): + representation = container['representation'] + representation_doc = io.find_one( + { + "_id": io.ObjectId(representation), + "type": "representation" + }, + projection={"parent": True} + ) + if representation_doc and not lib.is_latest(representation_doc): + outdated_containers.append(container) + + # Warn about outdated containers. + print("Starting new QApplication..") + app = QtWidgets.QApplication(sys.argv) + + message_box = QtWidgets.QMessageBox() + message_box.setIcon(QtWidgets.QMessageBox.Warning) + msg = "There are outdated containers in the scene." + message_box.setText(msg) + message_box.exec_() + + def containerise(name, namespace, comp, @@ -159,64 +202,7 @@ def containerise(name, return comp -def _get_stub(): - """ - Handle pulling stub from PS to run operations on host - Returns: - (AEServerStub) or None - """ - try: - stub = get_stub() # only after Photoshop is up - except lib.ConnectionNotEstablishedYet: - print("Not connected yet, ignoring") - return - - if not stub.get_active_document_name(): - return - - return stub - - -def ls(): - """Yields containers from active AfterEffects document. - - This is the host-equivalent of api.ls(), but instead of listing - assets on disk, it lists assets already loaded in AE; once loaded - they are called 'containers'. Used in Manage tool. - - Containers could be on multiple levels, single images/videos/was as a - FootageItem, or multiple items - backgrounds (folder with automatically - created composition and all imported layers). - - Yields: - dict: container - - """ - try: - stub = get_stub() # only after AfterEffects is up - except lib.ConnectionNotEstablishedYet: - print("Not connected yet, ignoring") - return - - layers_meta = stub.get_metadata() - for item in stub.get_items(comps=True, - folders=True, - footages=True): - data = stub.read(item, layers_meta) - # Skip non-tagged layers. - if not data: - continue - - # Filter to only containers. - if "container" not in data["id"]: - continue - - # Append transient data - data["objectName"] = item.name.replace(stub.LOADED_ICON, '') - data["layer"] = item - yield data - - +# created instances section def list_instances(): """ List all created instances from current workfile which @@ -275,6 +261,7 @@ def remove_instance(instance): item.name.replace(stub.PUBLISH_ICON, '')) +# new publisher section def get_context_data(): print("get_context_data") return {} @@ -287,3 +274,21 @@ def update_context_data(data, changes): def get_context_title(): """Returns title for Creator window""" return "AfterEffects" + + +def _get_stub(): + """ + Handle pulling stub from PS to run operations on host + Returns: + (AEServerStub) or None + """ + try: + stub = get_stub() # only after Photoshop is up + except lib.ConnectionNotEstablishedYet: + print("Not connected yet, ignoring") + return + + if not stub.get_active_document_name(): + return + + return stub From a27119bee40d29725eea5493e1b2004d1813669d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:26:04 +0100 Subject: [PATCH 021/854] OP-2765 - renamed old creators --- ...ender.py => create_legacy_local_render.py} | 6 +- .../plugins/create/create_legacy_render.py | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) rename openpype/hosts/aftereffects/plugins/create/{create_local_render.py => create_legacy_local_render.py} (57%) create mode 100644 openpype/hosts/aftereffects/plugins/create/create_legacy_render.py diff --git a/openpype/hosts/aftereffects/plugins/create/create_local_render.py b/openpype/hosts/aftereffects/plugins/create/create_legacy_local_render.py similarity index 57% rename from openpype/hosts/aftereffects/plugins/create/create_local_render.py rename to openpype/hosts/aftereffects/plugins/create/create_legacy_local_render.py index 9d2cdcd7be..4fb07f31f8 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_local_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_legacy_local_render.py @@ -1,7 +1,7 @@ -from openpype.hosts.aftereffects.plugins.create import create_render +from openpype.hosts.aftereffects.plugins.create import create_legacy_render -class CreateLocalRender(create_render.CreateRender): +class CreateLocalRender(create_legacy_render.CreateRender): """ Creator to render locally. Created only after default render on farm. So family 'render.local' is @@ -10,4 +10,4 @@ class CreateLocalRender(create_render.CreateRender): name = "renderDefault" label = "Render Locally" - family = "renderLocal" + family = "renderLocal" \ No newline at end of file diff --git a/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py b/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py new file mode 100644 index 0000000000..7da489a731 --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py @@ -0,0 +1,62 @@ +from avalon.api import CreatorError + +import openpype.api +from openpype.hosts.aftereffects.api import ( + get_stub, + list_instances +) + + +class CreateRender(openpype.api.Creator): + """Render folder for publish. + + Creates subsets in format 'familyTaskSubsetname', + eg 'renderCompositingMain'. + + Create only single instance from composition at a time. + """ + + name = "renderDefault" + label = "Render on Farm" + family = "render" + defaults = ["Main"] + + def process(self): + stub = get_stub() # only after After Effects is up + if (self.options or {}).get("useSelection"): + items = stub.get_selected_items( + comps=True, folders=False, footages=False + ) + if len(items) > 1: + raise CreatorError( + "Please select only single composition at time." + ) + + if not items: + raise CreatorError(( + "Nothing to create. Select composition " + "if 'useSelection' or create at least " + "one composition." + )) + + existing_subsets = [ + instance['subset'].lower() + for instance in list_instances() + ] + + item = items.pop() + if self.name.lower() in existing_subsets: + txt = "Instance with name \"{}\" already exists.".format(self.name) + raise CreatorError(txt) + + self.data["members"] = [item.id] + self.data["uuid"] = item.id # for SubsetManager + self.data["subset"] = ( + self.data["subset"] + .replace(stub.PUBLISH_ICON, '') + .replace(stub.LOADED_ICON, '') + ) + + stub.imprint(item, self.data) + stub.set_label_color(item.id, 14) # Cyan options 0 - 16 + stub.rename_item(item.id, stub.PUBLISH_ICON + self.data["subset"]) \ No newline at end of file From ebc05e82c8001878667aa31d1cba014d9c06f231 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:31:18 +0100 Subject: [PATCH 022/854] OP-2765 - refactored imprint method Uses id instead of full AEItem --- openpype/hosts/aftereffects/api/pipeline.py | 8 ++++---- openpype/hosts/aftereffects/api/ws_stub.py | 8 ++++---- .../hosts/aftereffects/plugins/load/load_background.py | 5 ++--- openpype/hosts/aftereffects/plugins/load/load_file.py | 8 ++++---- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 1ec76fd9dd..550ff25886 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -178,7 +178,7 @@ def containerise(name, Arguments: name (str): Name of resulting assembly namespace (str): Namespace under which to host container - comp (Comp): Composition to containerise + comp (AEItem): Composition to containerise context (dict): Asset information loader (str, optional): Name of loader used to produce this container. suffix (str, optional): Suffix of container, defaults to `_CON`. @@ -197,7 +197,7 @@ def containerise(name, } stub = get_stub() - stub.imprint(comp, data) + stub.imprint(comp.id, data) return comp @@ -254,8 +254,8 @@ def remove_instance(instance): stub.remove_instance(inst_id) - if instance.members: - item = stub.get_item(instance.members[0]) + if instance.get("members"): + item = stub.get_item(instance["members"][0]) if item: stub.rename_item(item.id, item.name.replace(stub.PUBLISH_ICON, '')) diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index d098419e81..18852d3d6c 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -111,11 +111,11 @@ class AfterEffectsServerStub(): self.log.debug("Couldn't find layer metadata") - def imprint(self, item, data, all_items=None, items_meta=None): + def imprint(self, item_id, data, all_items=None, items_meta=None): """ Save item metadata to Label field of metadata of active document Args: - item (AEItem): + item_id (int|str): id of FootageItem or instance_id for workfiles data(string): json representation for single layer all_items (list of item): for performance, could be injected for usage in loop, if not, single call will be @@ -134,8 +134,8 @@ class AfterEffectsServerStub(): for item_meta in items_meta: if ((item_meta.get('members') and - str(item.id) == str(item_meta.get('members')[0])) or - item_meta.get("instance_id") == item.id): + str(item_id) == str(item_meta.get('members')[0])) or + item_meta.get("instance_id") == item_id): is_new = False if data: item_meta.update(data) diff --git a/openpype/hosts/aftereffects/plugins/load/load_background.py b/openpype/hosts/aftereffects/plugins/load/load_background.py index 1a2d6fc432..9b39556040 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_background.py +++ b/openpype/hosts/aftereffects/plugins/load/load_background.py @@ -91,7 +91,7 @@ class BackgroundLoader(AfterEffectsLoader): container["namespace"] = comp_name container["members"] = comp.members - stub.imprint(comp, container) + stub.imprint(comp.id, container) def remove(self, container): """ @@ -100,10 +100,9 @@ class BackgroundLoader(AfterEffectsLoader): Args: container (dict): container to be removed - used to get layer_id """ - print("!!!! container:: {}".format(container)) stub = self.get_stub() layer = container.pop("layer") - stub.imprint(layer, {}) + stub.imprint(layer.id, {}) stub.delete_item(layer.id) def switch(self, container, representation): diff --git a/openpype/hosts/aftereffects/plugins/load/load_file.py b/openpype/hosts/aftereffects/plugins/load/load_file.py index 9dbbf7aae1..ba5bb5f69a 100644 --- a/openpype/hosts/aftereffects/plugins/load/load_file.py +++ b/openpype/hosts/aftereffects/plugins/load/load_file.py @@ -96,9 +96,9 @@ class FileLoader(AfterEffectsLoader): # with aftereffects.maintained_selection(): # TODO stub.replace_item(layer.id, path, stub.LOADED_ICON + layer_name) stub.imprint( - layer, {"representation": str(representation["_id"]), - "name": context["subset"], - "namespace": layer_name} + layer.id, {"representation": str(representation["_id"]), + "name": context["subset"], + "namespace": layer_name} ) def remove(self, container): @@ -109,7 +109,7 @@ class FileLoader(AfterEffectsLoader): """ stub = self.get_stub() layer = container.pop("layer") - stub.imprint(layer, {}) + stub.imprint(layer.id, {}) stub.delete_item(layer.id) def switch(self, container, representation): From 3c11f46b110d3e74f96b7990845bec375ee46d05 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:32:04 +0100 Subject: [PATCH 023/854] OP-2765 - working version of new creator --- .../plugins/create/create_render.py | 126 ++++++++++++------ 1 file changed, 87 insertions(+), 39 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 8dfc85cdc8..c290bd46c3 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,37 +1,65 @@ -from avalon.api import CreatorError - -import openpype.api -from openpype.hosts.aftereffects.api import ( - get_stub, - list_instances +import json +from openpype import resources +import openpype.hosts.aftereffects.api as api +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib, + CreatorError ) -class CreateRender(openpype.api.Creator): - """Render folder for publish. - - Creates subsets in format 'familyTaskSubsetname', - eg 'renderCompositingMain'. - - Create only single instance from composition at a time. - """ - - name = "renderDefault" - label = "Render on Farm" +class RenderCreator(Creator): + identifier = "render" + label = "Render" family = "render" - defaults = ["Main"] + description = "Render creator" - def process(self): - stub = get_stub() # only after After Effects is up - if (self.options or {}).get("useSelection"): + create_allow_context_change = False + + def get_icon(self): + return resources.get_openpype_splash_filepath() + + def collect_instances(self): + for instance_data in api.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance_data = self._handle_legacy(instance_data) + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + created_inst, changes = update_list[0] + print("RenderCreator update_list:: {}-{}".format(created_inst, changes)) + api.get_stub().imprint(created_inst.get("instance_id"), + created_inst.data_to_store()) + + def remove_instances(self, instances): + for instance in instances: + print("instance:: {}".format(instance)) + api.remove_instance(instance) + self._remove_instance_from_context(instance) + + def create(self, subset_name, data, pre_create_data): + print("Data that can be used in create:\n{}".format( + json.dumps(pre_create_data, indent=4) + )) + stub = api.get_stub() # only after After Effects is up + print("pre_create_data:: {}".format(pre_create_data)) + if pre_create_data.get("use_selection"): items = stub.get_selected_items( comps=True, folders=False, footages=False ) + else: + items = stub.get_items(comps=True, folders=False, footages=False) + if len(items) > 1: raise CreatorError( "Please select only single composition at time." ) - + print("items:: {}".format(items)) if not items: raise CreatorError(( "Nothing to create. Select composition " @@ -39,24 +67,44 @@ class CreateRender(openpype.api.Creator): "one composition." )) - existing_subsets = [ - instance['subset'].lower() - for instance in list_instances() + data["members"] = [items[0].id] + new_instance = CreatedInstance(self.family, subset_name, data, self) + new_instance.creator_attributes["farm"] = pre_create_data["farm"] + + api.get_stub().imprint(new_instance.get("instance_id"), + new_instance.data_to_store()) + self.log.info(new_instance.data) + self._add_instance_to_context(new_instance) + + def get_default_variants(self): + return [ + "myVariant", + "variantTwo", + "different_variant" ] - item = items.pop() - if self.name.lower() in existing_subsets: - txt = "Instance with name \"{}\" already exists.".format(self.name) - raise CreatorError(txt) + def get_instance_attr_defs(self): + return [lib.BoolDef("farm", label="Render on farm")] - self.data["members"] = [item.id] - self.data["uuid"] = item.id # for SubsetManager - self.data["subset"] = ( - self.data["subset"] - .replace(stub.PUBLISH_ICON, '') - .replace(stub.LOADED_ICON, '') - ) + def get_pre_create_attr_defs(self): + output = [ + lib.BoolDef("use_selection", default=True, label="Use selection"), + lib.UISeparatorDef(), + lib.BoolDef("farm", label="Render on farm") + ] + return output + + def get_detail_description(self): + return """Creator for Render instances""" + + def _handle_legacy(self, instance_data): + """Converts old instances to new format.""" + if instance_data.get("uuid"): + instance_data["item_id"] = instance_data.get("uuid") + instance_data.pop("uuid") + + if not instance_data.get("members"): + instance_data["members"] = [instance_data["item_id"]] + + return instance_data - stub.imprint(item, self.data) - stub.set_label_color(item.id, 14) # Cyan options 0 - 16 - stub.rename_item(item.id, stub.PUBLISH_ICON + self.data["subset"]) From 082b2306ee08a4f286804d1afe0f8139006e5fe8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:32:50 +0100 Subject: [PATCH 024/854] OP-2765 - changed collector to work with new creator --- .../hosts/aftereffects/plugins/publish/collect_workfile.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index c1c2be4855..61c4897cae 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -10,6 +10,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.1 def process(self, context): + for instance in context: + if instance.data["family"] == "workfile": + self.log.debug("Workfile instance found, skipping") + return + task = api.Session["AVALON_TASK"] current_file = context.data["currentFile"] staging_dir = os.path.dirname(current_file) From 64b63369d6b1a8bbf702a3fe34a3ea05e4021d79 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:33:21 +0100 Subject: [PATCH 025/854] OP-2765 - added 'newPublishing' flag to differentiate --- openpype/plugins/publish/collect_from_create_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 16e3f669c3..09584ab37c 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -25,7 +25,7 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): # Update global data to context context.data.update(create_context.context_data_to_store()) - + context.data["newPublishing"] = True # Update context data for key in ("AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK"): value = create_context.dbcon.Session.get(key) From be05fe990580aff0bc98ffee8243bc4e7536083e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:34:00 +0100 Subject: [PATCH 026/854] OP-2765 - updated collecting of render family Added pre collect for backward compatibility --- .../plugins/publish/collect_render.py | 197 ++++++++++-------- .../plugins/publish/pre_collect_render.py | 47 +++++ 2 files changed, 154 insertions(+), 90 deletions(-) create mode 100644 openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index 2a4b773681..1ad3d3dd18 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -2,6 +2,7 @@ import os import re import tempfile import attr +from copy import deepcopy import pyblish.api @@ -29,20 +30,22 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): label = "Collect After Effects Render Layers" hosts = ["aftereffects"] - # internal - family_remapping = { - "render": ("render.farm", "farm"), # (family, label) - "renderLocal": ("render", "local") - } padding_width = 6 rendered_extension = 'png' - stub = get_stub() + _stub = None + + @classmethod + def get_stub(cls): + if not cls._stub: + cls._stub = get_stub() + return cls._stub def get_instances(self, context): instances = [] + instances_to_remove = [] - app_version = self.stub.get_app_version() + app_version = CollectAERender.get_stub().get_app_version() app_version = app_version[0:4] current_file = context.data["currentFile"] @@ -50,105 +53,91 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] - compositions = self.stub.get_items(True) + compositions = CollectAERender.get_stub().get_items(True) compositions_by_id = {item.id: item for item in compositions} - for inst in self.stub.get_metadata(): - schema = inst.get('schema') - # loaded asset container skip it - if schema and 'container' in schema: + for inst in context: + family = inst.data["family"] + if family != "render": continue + self._debug_log(inst) - if not inst["members"]: - raise ValueError("Couldn't find id, unable to publish. " + - "Please recreate instance.") - item_id = inst["members"][0] + item_id = inst.data["members"][0] - work_area_info = self.stub.get_work_area(int(item_id)) + work_area_info = CollectAERender.get_stub().get_work_area( + int(item_id)) if not work_area_info: self.log.warning("Orphaned instance, deleting metadata") - self.stub.remove_instance(int(item_id)) + inst_id = inst.get("instance_id") or item_id + CollectAERender.get_stub().remove_instance(inst_id) continue - frameStart = work_area_info.workAreaStart - - frameEnd = round(work_area_info.workAreaStart + - float(work_area_info.workAreaDuration) * - float(work_area_info.frameRate)) - 1 + frame_start = work_area_info.workAreaStart + frame_end = round(work_area_info.workAreaStart + + float(work_area_info.workAreaDuration) * + float(work_area_info.frameRate)) - 1 fps = work_area_info.frameRate # TODO add resolution when supported by extension - if inst["family"] in self.family_remapping.keys() \ - and inst["active"]: - remapped_family = self.family_remapping[inst["family"]] - instance = AERenderInstance( - family=remapped_family[0], - families=[remapped_family[0]], - version=version, - time="", - source=current_file, - label="{} - {}".format(inst["subset"], remapped_family[1]), - subset=inst["subset"], - asset=context.data["assetEntity"]["name"], - attachTo=False, - setMembers='', - publish=True, - renderer='aerender', - name=inst["subset"], - resolutionWidth=asset_entity["data"].get( - "resolutionWidth", - project_entity["data"]["resolutionWidth"]), - resolutionHeight=asset_entity["data"].get( - "resolutionHeight", - project_entity["data"]["resolutionHeight"]), - pixelAspect=1, - tileRendering=False, - tilesX=0, - tilesY=0, - frameStart=frameStart, - frameEnd=frameEnd, - frameStep=1, - toBeRenderedOn='deadline', - fps=fps, - app_version=app_version - ) + if not inst.data["active"]: + continue - comp = compositions_by_id.get(int(item_id)) - if not comp: - raise ValueError("There is no composition for item {}". - format(item_id)) - instance.comp_name = comp.name - instance.comp_id = item_id - instance._anatomy = context.data["anatomy"] - instance.anatomyData = context.data["anatomyData"] + subset_name = inst.data["subset"] + instance = AERenderInstance( + family=family, + families=[family], + version=version, + time="", + source=current_file, + label="{} - {}".format(subset_name, family), + subset=subset_name, + asset=context.data["assetEntity"]["name"], + attachTo=False, + setMembers='', + publish=True, + renderer='aerender', + name=subset_name, + resolutionWidth=asset_entity["data"].get( + "resolutionWidth", + project_entity["data"]["resolutionWidth"]), + resolutionHeight=asset_entity["data"].get( + "resolutionHeight", + project_entity["data"]["resolutionHeight"]), + pixelAspect=1, + tileRendering=False, + tilesX=0, + tilesY=0, + frameStart=frame_start, + frameEnd=frame_end, + frameStep=1, + toBeRenderedOn='deadline', + fps=fps, + app_version=app_version, + anatomyData=deepcopy(context.data["anatomyData"]), + context=context + ) - instance.outputDir = self._get_output_dir(instance) - instance.context = context + comp = compositions_by_id.get(int(item_id)) + if not comp: + raise ValueError("There is no composition for item {}". + format(item_id)) + instance.outputDir = self._get_output_dir(instance) + instance.comp_name = comp.name + instance.comp_id = item_id - settings = get_project_settings(os.getenv("AVALON_PROJECT")) - reviewable_subset_filter = \ - (settings["deadline"] - ["publish"] - ["ProcessSubmittedJobOnFarm"] - ["aov_filter"]) + is_local = "renderLocal" in inst.data["families"] + if inst.data.get("creator_attributes"): + is_local = inst.data["creator_attributes"].get("farm") + if is_local: + # for local renders + instance = self._update_for_local(instance, project_entity) - if inst["family"] == "renderLocal": - # for local renders - instance.anatomyData["version"] = instance.version - instance.anatomyData["subset"] = instance.subset - instance.stagingDir = tempfile.mkdtemp() - instance.projectEntity = project_entity + self.log.info("New instance:: {}".format(instance)) + instances.append(instance) + instances_to_remove.append(inst) - if self.hosts[0] in reviewable_subset_filter.keys(): - for aov_pattern in \ - reviewable_subset_filter[self.hosts[0]]: - if re.match(aov_pattern, instance.subset): - instance.families.append("review") - instance.review = True - break - - self.log.info("New instance:: {}".format(instance)) - instances.append(instance) + for instance in instances_to_remove: + context.remove(instance) return instances @@ -169,7 +158,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): end = render_instance.frameEnd # pull file name from Render Queue Output module - render_q = self.stub.get_render_info() + render_q = CollectAERender.get_stub().get_render_info() if not render_q: raise ValueError("No file extension set in Render Queue") _, ext = os.path.splitext(os.path.basename(render_q.file_name)) @@ -216,3 +205,31 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): # for submit_publish_job return base_dir + + def _update_for_local(self, instance, project_entity): + instance.anatomyData["version"] = instance.version + instance.anatomyData["subset"] = instance.subset + instance.stagingDir = tempfile.mkdtemp() + instance.projectEntity = project_entity + + settings = get_project_settings(os.getenv("AVALON_PROJECT")) + reviewable_subset_filter = (settings["deadline"] + ["publish"] + ["ProcessSubmittedJobOnFarm"] + ["aov_filter"].get(self.hosts[0])) + for aov_pattern in reviewable_subset_filter: + if re.match(aov_pattern, instance.subset): + instance.families.append("review") + instance.review = True + break + + return instance + + def _debug_log(self, instance): + def _default_json(value): + return str(value) + + import json + self.log.info( + json.dumps(instance.data, indent=4, default=_default_json) + ) diff --git a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py new file mode 100644 index 0000000000..56dc884634 --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py @@ -0,0 +1,47 @@ +import json +import pyblish.api +from openpype.hosts.aftereffects.api import get_stub, list_instances + + +class PreCollectRender(pyblish.api.ContextPlugin): + """ + Checks if render instance is of new type, adds to families to both + existing collectors work same way. + """ + + label = "PreCollect Render" + order = pyblish.api.CollectorOrder + 0.400 + hosts = ["aftereffects"] + + family_remapping = { + "render": ("render.farm", "farm"), # (family, label) + "renderLocal": ("render", "local") + } + + def process(self, context): + if context.data.get("newPublishing"): + self.log.debug("Not applicable for New Publisher, skip") + return + + stub = get_stub() + for inst in list_instances(): + if inst["family"] not in self.family_remapping.keys(): + continue + + if not inst["members"]: + raise ValueError("Couldn't find id, unable to publish. " + + "Please recreate instance.") + + instance = context.create_instance(inst["subset"]) + inst["families"] = [self.family_remapping[inst["family"]]] + instance.data.update(inst) + + self._debug_log(instance) + + def _debug_log(self, instance): + def _default_json(value): + return str(value) + + self.log.info( + json.dumps(instance.data, indent=4, default=_default_json) + ) From c189725f3fdd7babae5709b70fd61708ae67bd91 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:34:27 +0100 Subject: [PATCH 027/854] OP-2765 - missed update for imprint --- .../aftereffects/plugins/publish/validate_instance_asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py index 71c1750457..3019719947 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_instance_asset.py @@ -27,7 +27,7 @@ class ValidateInstanceAssetRepair(pyblish.api.Action): data = stub.read(instance[0]) data["asset"] = api.Session["AVALON_ASSET"] - stub.imprint(instance[0], data) + stub.imprint(instance[0].instance_id, data) class ValidateInstanceAsset(pyblish.api.InstancePlugin): From 7967496b5c64c3e1a5c126de7c0a3f90dd3e81f5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 3 Mar 2022 19:34:52 +0100 Subject: [PATCH 028/854] OP-2765 - added CreatorError to pipeline api --- openpype/pipeline/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index e968df4011..2b7a39d444 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -4,7 +4,8 @@ from .create import ( BaseCreator, Creator, AutoCreator, - CreatedInstance + CreatedInstance, + CreatorError ) from .publish import ( @@ -21,6 +22,7 @@ __all__ = ( "Creator", "AutoCreator", "CreatedInstance", + "CreatorError", "PublishValidationError", "KnownPublishError", From b86201546fbd498c966ccda59d659b9f61ca8da6 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 4 Mar 2022 18:16:19 +0900 Subject: [PATCH 029/854] create dummy creator plugin for multiverse usd --- .../hosts/maya/plugins/create/create_multiverse_usd.py | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 openpype/hosts/maya/plugins/create/create_multiverse_usd.py diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py new file mode 100644 index 0000000000..2dc57823f1 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -0,0 +1,10 @@ +from openpype.hosts.maya.api import plugin + + +class CreateMultiverseUsd(plugin.Creator): + """Multiverse USD data""" + + name = "usd" + label = "Multiverse USD" + family = "usd" + icon = "cubes" From 2cb1925790555c7bea45e343ebce7ac25cd7d664 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 4 Mar 2022 18:16:48 +0900 Subject: [PATCH 030/854] create first version of multiverse usd extractor --- .../plugins/publish/extract_multiverse_usd.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py new file mode 100644 index 0000000000..72b1dcbbe5 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -0,0 +1,54 @@ +import os + +import avalon.maya +import openpype.api + +from maya import cmds + + +class ExtractMultiverseUsd(openpype.api.Extractor): + """Extractor for USD by Multiverse.""" + + label = "Extract Multiverse USD" + hosts = ["maya"] + families = ["usd"] + + def process(self, instance): + # Load plugin firstly + cmds.loadPlugin("MultiverseForMaya", quiet=True) + + # Define output file path + staging_dir = self.staging_dir(instance) + file_name = "{}.usd".format(instance.name) + file_path = os.path.join(staging_dir, file_name) + file_path = file_path.replace('\\', '/') + + # Perform extraction + self.log.info("Performing extraction ...") + + with avalon.maya.maintained_selection(): + members = instance.data("setMembers") + members = cmds.ls(members, + dag=True, + shapes=True, + type=("mesh"), + noIntermediate=True, + long=True) + + # TODO: Deal with asset, composition, overide with options. + import multiverse + options = multiverse.AssetWriteOptions() + multiverse.WriteAsset(file_path, members, options) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'usd', + 'ext': 'usd', + 'files': file_name, + "stagingDir": staging_dir + } + instance.data["representations"].append(representation) + + self.log.info("Extracted {} to {}".format(instance, file_path)) From 4434a4b1888f65a55aa86a365d186aabb6ec69cf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Mar 2022 15:44:19 +0100 Subject: [PATCH 031/854] OP-2765 - added default to Setting for subset name of workfile in AE --- openpype/settings/defaults/project_settings/global.json | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index f08bee8b2d..71c837659e 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -268,6 +268,7 @@ "workfile" ], "hosts": [ + "aftereffects", "tvpaint" ], "task_types": [], From e24ef3a9eba62a9dbcae252dcf70d9608145724b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Mar 2022 16:32:16 +0100 Subject: [PATCH 032/854] OP-2765 - added workfile creator and modified collector Workfile collector shouldn't create new isntance for NP, but should update version --- .../plugins/create/workfile_creator.py | 75 +++++++++++++++++++ .../plugins/publish/collect_workfile.py | 33 ++++---- 2 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 openpype/hosts/aftereffects/plugins/create/workfile_creator.py diff --git a/openpype/hosts/aftereffects/plugins/create/workfile_creator.py b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py new file mode 100644 index 0000000000..2d9d42ee8c --- /dev/null +++ b/openpype/hosts/aftereffects/plugins/create/workfile_creator.py @@ -0,0 +1,75 @@ +from avalon import io + +import openpype.hosts.aftereffects.api as api +from openpype.pipeline import ( + AutoCreator, + CreatedInstance +) + + +class AEWorkfileCreator(AutoCreator): + identifier = "workfile" + family = "workfile" + + def get_instance_attr_defs(self): + return [] + + def collect_instances(self): + for instance_data in api.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + subset_name = instance_data["subset"] + instance = CreatedInstance( + self.family, subset_name, instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + # nothing to change on workfiles + pass + + def create(self, options=None): + existing_instance = None + for instance in self.create_context.instances: + if instance.family == self.family: + existing_instance = instance + break + + variant = '' + project_name = io.Session["AVALON_PROJECT"] + asset_name = io.Session["AVALON_ASSET"] + task_name = io.Session["AVALON_TASK"] + host_name = io.Session["AVALON_APP"] + + if existing_instance is None: + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": variant + } + data.update(self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + )) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + + api.get_stub().imprint(new_instance.get("instance_id"), + new_instance.data_to_store()) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index 61c4897cae..29ec3a64e6 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -10,10 +10,11 @@ class CollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.1 def process(self, context): + create_instance = True for instance in context: if instance.data["family"] == "workfile": - self.log.debug("Workfile instance found, skipping") - return + self.log.debug("Workfile instance found, do not create new") + create_instance = False task = api.Session["AVALON_TASK"] current_file = context.data["currentFile"] @@ -44,20 +45,24 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # workfile instance family = "workfile" subset = family + task.capitalize() - # Create instance - instance = context.create_instance(subset) - # creating instance data - instance.data.update({ - "subset": subset, - "label": scene_file, - "family": family, - "families": [family], - "representations": list() - }) + if create_instance: # old publish + # Create instance + instance = context.create_instance(subset) - # adding basic script data - instance.data.update(shared_instance_data) + # creating instance data + instance.data.update({ + "subset": subset, + "label": scene_file, + "family": family, + "families": [family], + "representations": list() + }) + + # adding basic script data + instance.data.update(shared_instance_data) + else: + instance.data.update({"version": version}) # creating representation representation = { From 97b9b035db68132f22e4d48874a02ad5bf76c9af Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Mar 2022 17:54:21 +0100 Subject: [PATCH 033/854] OP-2765 - added helper logging function --- .../aftereffects/plugins/publish/collect_render.py | 13 +------------ .../plugins/publish/collect_workfile.py | 9 +++------ openpype/lib/__init__.py | 3 ++- openpype/lib/log.py | 12 ++++++++++++ 4 files changed, 18 insertions(+), 19 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index 1ad3d3dd18..b41fb5d5f5 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -59,7 +59,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): family = inst.data["family"] if family != "render": continue - self._debug_log(inst) item_id = inst.data["members"][0] @@ -127,12 +126,11 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): is_local = "renderLocal" in inst.data["families"] if inst.data.get("creator_attributes"): - is_local = inst.data["creator_attributes"].get("farm") + is_local = not inst.data["creator_attributes"].get("farm") if is_local: # for local renders instance = self._update_for_local(instance, project_entity) - self.log.info("New instance:: {}".format(instance)) instances.append(instance) instances_to_remove.append(inst) @@ -224,12 +222,3 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): break return instance - - def _debug_log(self, instance): - def _default_json(value): - return str(value) - - import json - self.log.info( - json.dumps(instance.data, indent=4, default=_default_json) - ) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index 29ec3a64e6..d8a324f828 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -2,6 +2,8 @@ import os from avalon import api import pyblish.api +from openpype.lib import debug_log_instance + class CollectWorkfile(pyblish.api.ContextPlugin): """ Adds the AE render instances """ @@ -61,8 +63,6 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # adding basic script data instance.data.update(shared_instance_data) - else: - instance.data.update({"version": version}) # creating representation representation = { @@ -74,7 +74,4 @@ class CollectWorkfile(pyblish.api.ContextPlugin): instance.data["representations"].append(representation) - self.log.info('Publishing After Effects workfile') - - for i in context: - self.log.debug(f"{i.data['families']}") + debug_log_instance(self.log, "Workfile instance", instance) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 6a24f30455..fb7afe7cb3 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -35,7 +35,7 @@ from .execute import ( path_to_subprocess_arg, CREATE_NO_WINDOW ) -from .log import PypeLogger, timeit +from .log import PypeLogger, timeit, debug_log_instance from .path_templates import ( merge_dict, @@ -313,6 +313,7 @@ __all__ = [ "OpenPypeMongoConnection", "timeit", + "debug_log_instance", "is_overlapping_otio_ranges", "otio_range_with_handles", diff --git a/openpype/lib/log.py b/openpype/lib/log.py index a42faef008..7824e96159 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -23,6 +23,7 @@ import time import traceback import threading import copy +import json from . import Terminal from .mongo import ( @@ -493,3 +494,14 @@ def timeit(method): print('%r %2.2f ms' % (method.__name__, (te - ts) * 1000)) return result return timed + + +def debug_log_instance(logger, msg, instance): + """Helper function to write instance.data as json""" + def _default_json(value): + return str(value) + + logger.debug(msg) + logger.debug( + json.dumps(instance.data, indent=4, default=_default_json) + ) From 9065530eefdc98daf604d282f9f49e16614bcd0d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Mar 2022 18:20:36 +0100 Subject: [PATCH 034/854] OP-2765 - fixed wrong assignment of representations to instances --- .../aftereffects/plugins/publish/collect_workfile.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index d8a324f828..1bb476d80b 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -12,11 +12,12 @@ class CollectWorkfile(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder + 0.1 def process(self, context): - create_instance = True + existing_instance = None for instance in context: if instance.data["family"] == "workfile": - self.log.debug("Workfile instance found, do not create new") - create_instance = False + self.log.debug("Workfile instance found, won't create new") + existing_instance = instance + break task = api.Session["AVALON_TASK"] current_file = context.data["currentFile"] @@ -47,8 +48,7 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # workfile instance family = "workfile" subset = family + task.capitalize() - - if create_instance: # old publish + if existing_instance is None: # old publish # Create instance instance = context.create_instance(subset) @@ -63,6 +63,8 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # adding basic script data instance.data.update(shared_instance_data) + else: + instance = existing_instance # creating representation representation = { From 8c0d7ed1ee09c84b233cb7ec4068312afa27507a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 4 Mar 2022 18:38:26 +0100 Subject: [PATCH 035/854] initial work on skeletal mesh support --- .../create/create_unreal_skeletalmesh.py | 50 ++++++++++++++++ .../publish/collect_unreal_skeletalmesh.py | 23 ++++++++ .../publish/collect_unreal_staticmesh.py | 7 +-- .../publish/extract_unreal_skeletalmesh.py | 57 +++++++++++++++++++ .../publish/extract_unreal_staticmesh.py | 2 +- openpype/plugins/publish/integrate_new.py | 3 +- .../defaults/project_settings/global.json | 14 ++++- .../defaults/project_settings/maya.json | 5 ++ .../schemas/schema_maya_create.json | 26 +++++++++ 9 files changed, 178 insertions(+), 9 deletions(-) create mode 100644 openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py create mode 100644 openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py create mode 100644 openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py diff --git a/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py new file mode 100644 index 0000000000..a6deeeee2e --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_unreal_skeletalmesh.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +"""Creator for Unreal Skeletal Meshes.""" +from openpype.hosts.maya.api import plugin, lib +from avalon.api import Session +from maya import cmds # noqa + + +class CreateUnrealSkeletalMesh(plugin.Creator): + """Unreal Static Meshes with collisions.""" + name = "staticMeshMain" + label = "Unreal - Skeletal Mesh" + family = "skeletalMesh" + icon = "thumbs-up" + dynamic_subset_keys = ["asset"] + + joint_hints = [] + + def __init__(self, *args, **kwargs): + """Constructor.""" + super(CreateUnrealSkeletalMesh, self).__init__(*args, **kwargs) + + @classmethod + def get_dynamic_data( + cls, variant, task_name, asset_id, project_name, host_name + ): + dynamic_data = super(CreateUnrealSkeletalMesh, cls).get_dynamic_data( + variant, task_name, asset_id, project_name, host_name + ) + dynamic_data["asset"] = Session.get("AVALON_ASSET") + return dynamic_data + + def process(self): + self.name = "{}_{}".format(self.family, self.name) + with lib.undo_chunk(): + instance = super(CreateUnrealSkeletalMesh, self).process() + content = cmds.sets(instance, query=True) + + # empty set and process its former content + cmds.sets(content, rm=instance) + geometry_set = cmds.sets(name="geometry_SET", empty=True) + joints_set = cmds.sets(name="joints_SET", empty=True) + + cmds.sets([geometry_set, joints_set], forceElement=instance) + members = cmds.ls(content) or [] + + for node in members: + if node in self.joint_hints: + cmds.sets(node, forceElement=joints_set) + else: + cmds.sets(node, forceElement=geometry_set) diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py new file mode 100644 index 0000000000..4b1de865c5 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from maya import cmds # noqa +import pyblish.api +from avalon.api import Session +from openpype.api import get_project_settings + + +class CollectUnrealSkeletalMesh(pyblish.api.InstancePlugin): + """Collect Unreal Skeletal Mesh.""" + + order = pyblish.api.CollectorOrder + 0.2 + label = "Collect Unreal Skeletal Meshes" + families = ["skeletalMesh"] + + def process(self, instance): + # set fbx overrides on instance + instance.data["smoothingGroups"] = True + instance.data["smoothMesh"] = True + instance.data["triangulate"] = True + + frame = cmds.currentTime(query=True) + instance.data["frameStart"] = frame + instance.data["frameEnd"] = frame diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index ddcc3f691f..59f8df1ef1 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -6,12 +6,7 @@ from openpype.api import get_project_settings class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): - """Collect Unreal Static Mesh - - Ensures always only a single frame is extracted (current frame). This - also sets correct FBX options for later extraction. - - """ + """Collect Unreal Static Mesh.""" order = pyblish.api.CollectorOrder + 0.2 label = "Collect Unreal Static Meshes" diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py new file mode 100644 index 0000000000..0ad1a92292 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +"""Create Unreal Skeletal Mesh data to be extracted as FBX.""" +import os + +from maya import cmds # noqa + +import pyblish.api +import openpype.api +from openpype.hosts.maya.api.lib import ( + root_parent, + maintained_selection, + delete_after +) +from openpype.hosts.maya.api import fbx + + +class ExtractUnrealSkeletalMesh(openpype.api.Extractor): + """Extract Unreal Skeletal Mesh as FBX from Maya. """ + + order = pyblish.api.ExtractorOrder - 0.1 + label = "Extract Unreal Skeletal Mesh" + families = ["skeletalMesh"] + + def process(self, instance): + fbx_exporter = fbx.FBXExtractor(log=self.log) + + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) + + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace('\\', '/') + + self.log.info("Extracting FBX to: {0}".format(path)) + self.log.info("Members: {0}".format(instance)) + self.log.info("Instance: {0}".format(instance[:])) + + fbx_exporter.set_options_from_instance(instance) + with maintained_selection(): + with root_parent(instance): + self.log.info("Un-parenting: {}".format(instance)) + fbx_exporter.export(instance, path) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'fbx', + 'ext': 'fbx', + 'files': filename, + "stagingDir": staging_dir, + } + instance.data["representations"].append(representation) + + self.log.info("Extract FBX successful to: {0}".format(path)) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 0c7d61f8f5..22a3af3059 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -15,7 +15,7 @@ from openpype.hosts.maya.api import fbx class ExtractUnrealStaticMesh(openpype.api.Extractor): - """Extract FBX from Maya. """ + """Extract Unreal Static Mesh as FBX from Maya. """ order = pyblish.api.ExtractorOrder - 0.1 label = "Extract Unreal Static Mesh" diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 48b87c697b..fce8c73d1d 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -102,7 +102,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "xgen", "hda", "usd", - "staticMesh" + "staticMesh", + "skeletalMesh" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 86786cc9ed..b7f6414cfb 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -195,7 +195,8 @@ }, { "families": [ - "staticMesh" + "staticMesh", + "skeletalMesh" ], "hosts": [ "maya" @@ -306,6 +307,17 @@ "task_types": [], "tasks": [], "template": "S_{asset}{variant}" + }, + { + "families": [ + "skeletalMesh" + ], + "hosts": [ + "maya" + ], + "task_types": [], + "tasks": [], + "template": "SK_{asset}{variant}" } ] }, diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 362835e558..6317d30b3f 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -60,6 +60,11 @@ "UCX" ] }, + "CreateUnrealSkeletalMesh": { + "enabled": true, + "defaults": [], + "joint_hints": "jnt_org" + }, "CreateAnimation": { "enabled": true, "defaults": [ diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json index 0544b4bab7..6dc10ed2a5 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_create.json @@ -97,6 +97,32 @@ } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "CreateUnrealSkeletalMesh", + "label": "Create Unreal - Skeletal Mesh", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "defaults", + "label": "Default Subsets", + "object_type": "text" + }, + { + "type": "text", + "key": "joint_hints", + "label": "Joint root hint" + } + ] + }, { "type": "schema_template", From 7b9ec117e7a32dd34d634d3a6d9ecaca54bb983f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 4 Mar 2022 19:02:05 +0100 Subject: [PATCH 036/854] OP-2765 - add fallback to uuid for backward compatibility --- openpype/hosts/aftereffects/api/pipeline.py | 2 +- openpype/hosts/aftereffects/api/ws_stub.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 550ff25886..4ae88e649a 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -247,7 +247,7 @@ def remove_instance(instance): if not stub: return - inst_id = instance.get("instance_id") + inst_id = instance.get("instance_id") or instance.get("uuid") # legacy if not inst_id: log.warning("No instance identifier for {}".format(instance)) return diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index 18852d3d6c..1d3b69e038 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -321,7 +321,8 @@ class AfterEffectsServerStub(): cleaned_data = [] for instance in self.get_metadata(): - if instance.get("instance_id") != instance_id: + inst_id = instance.get("instance_id") or instance.get("uuid") + if inst_id != instance_id: cleaned_data.append(instance) payload = json.dumps(cleaned_data, indent=4) From d155f6024903070ebbcfd942e89dff49541a59e7 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 8 Mar 2022 12:11:40 +0900 Subject: [PATCH 037/854] fill attributes to creator CreateMultiverseUsd --- .../plugins/create/create_multiverse_usd.py | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py index 2dc57823f1..fcc6ce231d 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -1,4 +1,4 @@ -from openpype.hosts.maya.api import plugin +from openpype.hosts.maya.api import plugin, lib class CreateMultiverseUsd(plugin.Creator): @@ -8,3 +8,49 @@ class CreateMultiverseUsd(plugin.Creator): label = "Multiverse USD" family = "usd" icon = "cubes" + + def __init__(self, *args, **kwargs): + super(CreateMultiverseUsd, self).__init__(*args, **kwargs) + + self.data["stripNamespaces"] = False + self.data["mergeTransformAndShape"] = False + self.data["writeAncestors"] = True + self.data["flattenParentXforms"] = False + self.data["writeSparseOverrides"] = False + self.data["useMetaPrimPath"] = False + self.data["customRootPath"] = '' + self.data["customAttributes"] = '' + self.data["nodeTypesToIgnore"] = '' + self.data["writeMeshes"] = True + self.data["writeCurves"] = True + self.data["writeParticles"] = True + self.data["writeCameras"] = False + self.data["writeLights"] = False + self.data["writeJoints"] = False + self.data["writeCollections"] = False + self.data["writePositions"] = True + self.data["writeNormals"] = True + self.data["writeUVs"] = True + self.data["writeColorSets"] = False + self.data["writeTangents"] = False + self.data["writeRefPositions"] = False + self.data["writeBlendShapes"] = False + self.data["writeDisplayColor"] = False + self.data["writeSkinWeights"] = False + self.data["writeMaterialAssignment"] = False + self.data["writeHardwareShader"] = False + self.data["writeShadingNetworks"] = False + self.data["writeTransformMatrix"] = True + self.data["writeUsdAttributes"] = False + self.data["timeVaryingTopology"] = False + self.data["customMaterialNamespace"] = '' + + animation_data = lib.collect_animation_data() + + self.data["writeTimeRange"] = False + self.data["timeRangeStart"] = animation_data["frameStart"] + self.data["timeRangeEnd"] = animation_data["frameEnd"] + self.data["timeRangeIncrement"] = animation_data["step"] + self.data["timeRangeNumTimeSamples"] = 0 + self.data["timeRangeSamplesSpan"] = 0.0 + self.data["timeRangeFramesPerSecond"] = 24.0 From 1c58a3d1dfbbd2122a83607aebb574ba328f717d Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 8 Mar 2022 13:00:00 +0900 Subject: [PATCH 038/854] created load plugin to read USD file by multiverse --- .../maya/plugins/load/load_multiverse_usd.py | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 openpype/hosts/maya/plugins/load/load_multiverse_usd.py diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py new file mode 100644 index 0000000000..b46dbdc56b --- /dev/null +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -0,0 +1,45 @@ +from avalon import api + +class MultiverseUsdLoader(api.Loader): + """Load the USD by Multiverse""" + + families = ["usd"] + representations = ["usd", "usda", "usdc", "usdz", "abc"] + + label = "Read USD by Multiverse" + order = -10 + icon = "code-fork" + color = "orange" + + def load(self, context, name=None, namespace=None, options=None): + + import maya.cmds as cmds + from openpype.hosts.maya.api.pipeline import containerise + from openpype.hosts.maya.api.lib import unique_namespace + + asset = context['asset']['name'] + namespace = namespace or unique_namespace( + asset + "_", + prefix="_" if asset[0].isdigit() else "", + suffix="_", + ) + + cmds.loadPlugin("MultiverseForMaya", quiet=True) + + # Root group + label = "{}:{}".format(namespace, name) + root = cmds.group(name=label, empty=True) + + # Create shape and move it under root + import multiverse + shape = multiverse.CreateUsdCompound(self.fname) + cmds.parent(shape, root) + + def update(self, container, representation): + pass + + def switch(self, container, representation): + self.update(container, representation) + + def remove(self, container): + pass From 0e050d37e91d7730985cfae6d1eed62e97dd915b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:30:24 +0100 Subject: [PATCH 039/854] OP-2765 - fix legacy handling when creating --- .../plugins/create/create_render.py | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index c290bd46c3..0a907a02d8 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,4 +1,5 @@ -import json +import avalon.api + from openpype import resources import openpype.hosts.aftereffects.api as api from openpype.pipeline import ( @@ -22,7 +23,9 @@ class RenderCreator(Creator): def collect_instances(self): for instance_data in api.list_instances(): - creator_id = instance_data.get("creator_identifier") + # legacy instances have family=='render' or 'renderLocal', use them + creator_id = (instance_data.get("creator_identifier") or + instance_data.get("family").replace("Local", '')) if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) instance = CreatedInstance.from_existing( @@ -32,22 +35,16 @@ class RenderCreator(Creator): def update_instances(self, update_list): created_inst, changes = update_list[0] - print("RenderCreator update_list:: {}-{}".format(created_inst, changes)) api.get_stub().imprint(created_inst.get("instance_id"), created_inst.data_to_store()) def remove_instances(self, instances): for instance in instances: - print("instance:: {}".format(instance)) api.remove_instance(instance) self._remove_instance_from_context(instance) def create(self, subset_name, data, pre_create_data): - print("Data that can be used in create:\n{}".format( - json.dumps(pre_create_data, indent=4) - )) stub = api.get_stub() # only after After Effects is up - print("pre_create_data:: {}".format(pre_create_data)) if pre_create_data.get("use_selection"): items = stub.get_selected_items( comps=True, folders=False, footages=False @@ -59,7 +56,6 @@ class RenderCreator(Creator): raise CreatorError( "Please select only single composition at time." ) - print("items:: {}".format(items)) if not items: raise CreatorError(( "Nothing to create. Select composition " @@ -73,7 +69,6 @@ class RenderCreator(Creator): api.get_stub().imprint(new_instance.get("instance_id"), new_instance.data_to_store()) - self.log.info(new_instance.data) self._add_instance_to_context(new_instance) def get_default_variants(self): @@ -99,12 +94,20 @@ class RenderCreator(Creator): def _handle_legacy(self, instance_data): """Converts old instances to new format.""" + if not instance_data.get("members"): + instance_data["members"] = [instance_data.get("uuid")] + if instance_data.get("uuid"): - instance_data["item_id"] = instance_data.get("uuid") + # uuid not needed, replaced with unique instance_id + api.get_stub().remove_instance(instance_data.get("uuid")) instance_data.pop("uuid") - if not instance_data.get("members"): - instance_data["members"] = [instance_data["item_id"]] + if not instance_data.get("task"): + instance_data["task"] = avalon.api.Session.get("AVALON_TASK") + + if not instance_data.get("creator_attributes"): + is_old_farm = instance_data["family"] != "renderLocal" + instance_data["creator_attributes"] = {"farm": is_old_farm} + instance_data["family"] = self.family return instance_data - From ca0a38f8de82e488e9353d1f1117a4e60620e41f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:32:26 +0100 Subject: [PATCH 040/854] OP-2765 - fixed exclude filter to user family or families properly Added render.farm to excluded, as in NP family is always 'render' --- openpype/plugins/publish/integrate_new.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 6e0940d459..581902205f 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -103,7 +103,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "hda", "usd" ] - exclude_families = ["clip"] + exclude_families = ["clip", "render.farm"] db_representation_context_keys = [ "project", "asset", "task", "subset", "version", "representation", "family", "hierarchy", "task", "username" @@ -121,11 +121,15 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): subset_grouping_profiles = None def process(self, instance): - self.integrated_file_sizes = {} - if [ef for ef in self.exclude_families - if instance.data["family"] in ef]: - return + for ef in self.exclude_families: + if ( + instance.data["family"] == ef or + ef in instance.data["families"]): + self.log.debug("Excluded family '{}' in '{}' or {}".format( + ef, instance.data["family"], instance.data["families"])) + return + self.integrated_file_sizes = {} try: self.register(instance) self.log.info("Integrated Asset in to the database ...") @@ -214,7 +218,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): # Ensure at least one file is set up for transfer in staging dir. repres = instance.data.get("representations") - assert repres, "Instance has no files to transfer" + repres = instance.data.get("representations") + msg = "Instance {} has no files to transfer".format( + instance.data["family"]) + assert repres, msg assert isinstance(repres, (list, tuple)), ( "Instance 'files' must be a list, got: {0} {1}".format( str(type(repres)), str(repres) From 296a2d162704b9ca0c1974d4b8093fe698760d6b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:34:12 +0100 Subject: [PATCH 041/854] OP-2765 - added publish flag to new instance of workfile --- openpype/hosts/aftereffects/plugins/publish/collect_workfile.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index 1bb476d80b..67f037e6e6 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -65,6 +65,7 @@ class CollectWorkfile(pyblish.api.ContextPlugin): instance.data.update(shared_instance_data) else: instance = existing_instance + instance.data["publish"] = True # for DL # creating representation representation = { From 2d9bac166a466f8489e38997ec440c6f23476f26 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:35:02 +0100 Subject: [PATCH 042/854] OP-2765 - modified proper families renderLocal is legacy, should be removed in the future --- .../hosts/aftereffects/plugins/publish/extract_local_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py index b738068a7b..7323a0b125 100644 --- a/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/extract_local_render.py @@ -12,7 +12,7 @@ class ExtractLocalRender(openpype.api.Extractor): order = openpype.api.Extractor.order - 0.47 label = "Extract Local Render" hosts = ["aftereffects"] - families = ["render"] + families = ["renderLocal", "render.local"] def process(self, instance): stub = get_stub() From bf51f8452b8e2410d049f63389e3179bec31b600 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:40:41 +0100 Subject: [PATCH 043/854] OP-2765 - modified collect render plugin Should handle both legacy and new style of publishing --- .../hosts/aftereffects/plugins/publish/collect_render.py | 8 +++++--- .../aftereffects/plugins/publish/pre_collect_render.py | 9 +++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index b41fb5d5f5..d31571b6b5 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -84,7 +84,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): subset_name = inst.data["subset"] instance = AERenderInstance( family=family, - families=[family], version=version, time="", source=current_file, @@ -124,19 +123,20 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): instance.comp_name = comp.name instance.comp_id = item_id - is_local = "renderLocal" in inst.data["families"] + is_local = "renderLocal" in inst.data["families"] # legacy if inst.data.get("creator_attributes"): is_local = not inst.data["creator_attributes"].get("farm") if is_local: # for local renders instance = self._update_for_local(instance, project_entity) + else: + instance.families = ["render.farm"] instances.append(instance) instances_to_remove.append(inst) for instance in instances_to_remove: context.remove(instance) - return instances def get_expected_files(self, render_instance): @@ -205,10 +205,12 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): return base_dir def _update_for_local(self, instance, project_entity): + """Update old saved instances to current publishing format""" instance.anatomyData["version"] = instance.version instance.anatomyData["subset"] = instance.subset instance.stagingDir = tempfile.mkdtemp() instance.projectEntity = project_entity + instance.families = ["render.local"] settings = get_project_settings(os.getenv("AVALON_PROJECT")) reviewable_subset_filter = (settings["deadline"] diff --git a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py index 56dc884634..614a04b4b7 100644 --- a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py @@ -1,12 +1,14 @@ import json import pyblish.api -from openpype.hosts.aftereffects.api import get_stub, list_instances +from openpype.hosts.aftereffects.api import list_instances class PreCollectRender(pyblish.api.ContextPlugin): """ - Checks if render instance is of new type, adds to families to both + Checks if render instance is of old type, adds to families to both existing collectors work same way. + + Could be removed in the future when no one uses old publish. """ label = "PreCollect Render" @@ -15,7 +17,7 @@ class PreCollectRender(pyblish.api.ContextPlugin): family_remapping = { "render": ("render.farm", "farm"), # (family, label) - "renderLocal": ("render", "local") + "renderLocal": ("render.local", "local") } def process(self, context): @@ -23,7 +25,6 @@ class PreCollectRender(pyblish.api.ContextPlugin): self.log.debug("Not applicable for New Publisher, skip") return - stub = get_stub() for inst in list_instances(): if inst["family"] not in self.family_remapping.keys(): continue From 9e3ea9139a06ad3cc495f8d0c43eb64a7eff8260 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 13:58:20 +0100 Subject: [PATCH 044/854] OP-2765 - Hound --- openpype/hosts/aftereffects/api/pipeline.py | 2 +- .../aftereffects/plugins/create/create_legacy_local_render.py | 2 +- .../hosts/aftereffects/plugins/create/create_legacy_render.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 4ae88e649a..4ade90e4dd 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -152,7 +152,7 @@ def check_inventory(): # Warn about outdated containers. print("Starting new QApplication..") - app = QtWidgets.QApplication(sys.argv) + _app = QtWidgets.QApplication(sys.argv) message_box = QtWidgets.QMessageBox() message_box.setIcon(QtWidgets.QMessageBox.Warning) diff --git a/openpype/hosts/aftereffects/plugins/create/create_legacy_local_render.py b/openpype/hosts/aftereffects/plugins/create/create_legacy_local_render.py index 4fb07f31f8..04413acbcf 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_legacy_local_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_legacy_local_render.py @@ -10,4 +10,4 @@ class CreateLocalRender(create_legacy_render.CreateRender): name = "renderDefault" label = "Render Locally" - family = "renderLocal" \ No newline at end of file + family = "renderLocal" diff --git a/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py b/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py index 7da489a731..8dfc85cdc8 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py @@ -59,4 +59,4 @@ class CreateRender(openpype.api.Creator): stub.imprint(item, self.data) stub.set_label_color(item.id, 14) # Cyan options 0 - 16 - stub.rename_item(item.id, stub.PUBLISH_ICON + self.data["subset"]) \ No newline at end of file + stub.rename_item(item.id, stub.PUBLISH_ICON + self.data["subset"]) From 5e5f6e0879a470688072c3125145bdc544f10cb6 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 8 Mar 2022 17:19:57 +0100 Subject: [PATCH 045/854] fix un-parenting --- .../publish/collect_unreal_skeletalmesh.py | 23 +++++++++++++++++++ .../publish/extract_unreal_skeletalmesh.py | 14 +++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py index 4b1de865c5..7d479b706c 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py @@ -21,3 +21,26 @@ class CollectUnrealSkeletalMesh(pyblish.api.InstancePlugin): frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame + + geo_sets = [ + i for i in instance[:] + if i.lower().startswith("geometry_set") + ] + + joint_sets = [ + i for i in instance[:] + if i.lower().startswith("joints_set") + ] + + instance.data["geometry"] = [] + instance.data["joints"] = [] + + for geo_set in geo_sets: + geo_content = cmds.ls(cmds.sets(geo_set, query=True), long=True) + if geo_content: + instance.data["geometry"] += geo_content + + for join_set in joint_sets: + join_content = cmds.ls(cmds.sets(join_set, query=True), long=True) + if join_content: + instance.data["joints"] += join_content diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index 0ad1a92292..c0b408c3f0 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -29,19 +29,25 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): filename = "{0}.fbx".format(instance.name) path = os.path.join(staging_dir, filename) + geo = instance.data.get("geometry") + joints = instance.data.get("joints") + + to_extract = geo + joints + # The export requires forward slashes because we need # to format it into a string in a mel expression path = path.replace('\\', '/') self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(instance)) + self.log.info("Members: {0}".format(to_extract)) self.log.info("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) with maintained_selection(): - with root_parent(instance): - self.log.info("Un-parenting: {}".format(instance)) - fbx_exporter.export(instance, path) + with root_parent(to_extract): + rooted = [i.split("|")[-1] for i in to_extract] + self.log.info("Un-parenting: {}".format(to_extract)) + fbx_exporter.export(rooted, path) if "representations" not in instance.data: instance.data["representations"] = [] From 3b72117a946d15954112b77107d04f325d30c0a3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 8 Mar 2022 19:11:55 +0100 Subject: [PATCH 046/854] OP-2765 - refactored validator --- .../publish/validate_scene_settings.py | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py index 273ccd295e..0753e3c09a 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -62,12 +62,13 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): expected_settings = get_asset_settings() self.log.info("config from DB::{}".format(expected_settings)) - if any(re.search(pattern, os.getenv('AVALON_TASK')) + task_name = instance.data["anatomyData"]["task"]["name"] + if any(re.search(pattern, task_name) for pattern in self.skip_resolution_check): expected_settings.pop("resolutionWidth") expected_settings.pop("resolutionHeight") - if any(re.search(pattern, os.getenv('AVALON_TASK')) + if any(re.search(pattern, task_name) for pattern in self.skip_timelines_check): expected_settings.pop('fps', None) expected_settings.pop('frameStart', None) @@ -87,10 +88,14 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): duration = instance.data.get("frameEndHandle") - \ instance.data.get("frameStartHandle") + 1 - self.log.debug("filtered config::{}".format(expected_settings)) + self.log.debug("validated items::{}".format(expected_settings)) current_settings = { "fps": fps, + "frameStart": instance.data.get("frameStart"), + "frameEnd": instance.data.get("frameEnd"), + "handleStart": instance.data.get("handleStart"), + "handleEnd": instance.data.get("handleEnd"), "frameStartHandle": instance.data.get("frameStartHandle"), "frameEndHandle": instance.data.get("frameEndHandle"), "resolutionWidth": instance.data.get("resolutionWidth"), @@ -103,24 +108,22 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): invalid_keys = set() for key, value in expected_settings.items(): if value != current_settings[key]: - invalid_settings.append( - "{} expected: {} found: {}".format(key, value, - current_settings[key]) - ) + msg = "'{}' expected: '{}' found: '{}'".format( + key, value, current_settings[key]) + + if key == "duration" and expected_settings.get("handleStart"): + msg += "Handles included in calculation. Remove " \ + "handles in DB or extend frame range in " \ + "Composition Setting." + + invalid_settings.append(msg) invalid_keys.add(key) - if ((expected_settings.get("handleStart") - or expected_settings.get("handleEnd")) - and invalid_settings): - msg = "Handles included in calculation. Remove handles in DB " +\ - "or extend frame range in Composition Setting." - invalid_settings[-1]["reason"] = msg - - msg = "Found invalid settings:\n{}".format( - "\n".join(invalid_settings) - ) - if invalid_settings: + msg = "Found invalid settings:\n{}".format( + "\n".join(invalid_settings) + ) + invalid_keys_str = ",".join(invalid_keys) break_str = "
" invalid_setting_str = "Found invalid settings:
{}".\ From 9f83eec3244ce83a43a5c2240f2f989427d7a2c3 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Wed, 9 Mar 2022 10:21:26 +0900 Subject: [PATCH 047/854] imroved data for creator of multiverse usd on animated parameters --- .../maya/plugins/create/create_multiverse_usd.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py index fcc6ce231d..b7f892deb4 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -45,12 +45,14 @@ class CreateMultiverseUsd(plugin.Creator): self.data["timeVaryingTopology"] = False self.data["customMaterialNamespace"] = '' - animation_data = lib.collect_animation_data() - + # The attributes below are about animated cache. self.data["writeTimeRange"] = False + self.data["timeRangeNumTimeSamples"] = 0 + self.data["timeRangeSamplesSpan"] = 0.0 + + animation_data = lib.collect_animation_data(True) + self.data["timeRangeStart"] = animation_data["frameStart"] self.data["timeRangeEnd"] = animation_data["frameEnd"] self.data["timeRangeIncrement"] = animation_data["step"] - self.data["timeRangeNumTimeSamples"] = 0 - self.data["timeRangeSamplesSpan"] = 0.0 - self.data["timeRangeFramesPerSecond"] = 24.0 + self.data["timeRangeFramesPerSecond"] = animation_data["fps"] From 2adbe78122c10b159341b78d2e542b4f455658d5 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Wed, 9 Mar 2022 10:27:51 +0900 Subject: [PATCH 048/854] implemented load method of multiverse usd loader --- .../maya/plugins/load/load_multiverse_usd.py | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index b46dbdc56b..4e9952b1a1 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -1,5 +1,9 @@ +# -*- coding: utf-8 -*- from avalon import api +import maya.cmds as cmds + + class MultiverseUsdLoader(api.Loader): """Load the USD by Multiverse""" @@ -10,10 +14,9 @@ class MultiverseUsdLoader(api.Loader): order = -10 icon = "code-fork" color = "orange" - + def load(self, context, name=None, namespace=None, options=None): - import maya.cmds as cmds from openpype.hosts.maya.api.pipeline import containerise from openpype.hosts.maya.api.lib import unique_namespace @@ -36,10 +39,25 @@ class MultiverseUsdLoader(api.Loader): cmds.parent(shape, root) def update(self, container, representation): - pass + + path = api.get_representation_path(representation) + + # Update the shape + members = cmds.sets(container['objectName'], query=True) + shapes = cmds.ls(members, type="mvUsdPackedShape", long=True) + + assert len(shapes) == 1, "This is a bug" + + import multiverse + for shape in shapes: + multiverse.SetUsdCompoundAssetPaths(shape, [path]) + + cmds.setAttr(container["objectName"] + ".representation", + str(representation["_id"]), + type="string") def switch(self, container, representation): self.update(container, representation) def remove(self, container): - pass + pass From d6a0d2cd2e4a9ff2ec915eb8c25a9d38ee34286d Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Wed, 9 Mar 2022 10:34:25 +0900 Subject: [PATCH 049/854] use time options for multiverse usd extractor --- .../plugins/publish/extract_multiverse_usd.py | 137 +++++++++++++++++- 1 file changed, 134 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 72b1dcbbe5..a45ebf2f9b 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -13,6 +13,110 @@ class ExtractMultiverseUsd(openpype.api.Extractor): hosts = ["maya"] families = ["usd"] + @property + def options(self): + """Overridable options for Multiverse USD Export + + Given in the following format + - {NAME: EXPECTED TYPE} + + If the overridden option's type does not match, + the option is not included and a warning is logged. + + """ + + return { + "stripNamespaces": bool, + "mergeTransformAndShape": bool, + "writeAncestors": bool, + "flattenParentXforms": bool, + "writeSparseOverrides": bool, + "useMetaPrimPath": bool, + "customRootPath": str, + "customAttributes": str, + "nodeTypesToIgnore": str, + "writeMeshes": bool, + "writeCurves": bool, + "writeParticles": bool, + "writeCameras": bool, + "writeLights": bool, + "writeJoints": bool, + "writeCollections": bool, + "writePositions": bool, + "writeNormals": bool, + "writeUVs": bool, + "writeColorSets": bool, + "writeTangents": bool, + "writeRefPositions": bool, + "writeBlendShapes": bool, + "writeDisplayColor": bool, + "writeSkinWeights": bool, + "writeMaterialAssignment": bool, + "writeHardwareShader": bool, + "writeShadingNetworks": bool, + "writeTransformMatrix": bool, + "writeUsdAttributes": bool, + "timeVaryingTopology": bool, + "customMaterialNamespace": str, + "writeTimeRange": bool, + "timeRangeStart": int, + "timeRangeEnd": int, + "timeRangeIncrement": int, + "timeRangeNumTimeSamples": int, + "timeRangeSamplesSpan": float, + "timeRangeFramesPerSecond": float + } + + @property + def default_options(self): + """The default options for Multiverse USD extraction.""" + start_frame = int(cmds.playbackOptions(query=True, + animationStartTime=True)) + end_frame = int(cmds.playbackOptions(query=True, + animationEndTime=True)) + + return { + "stripNamespaces": False, + "mergeTransformAndShape": False, + "writeAncestors": True, + "flattenParentXforms": False, + "writeSparseOverrides": False, + "useMetaPrimPath": False, + "customRootPath": '', + "customAttributes": '', + "nodeTypesToIgnore": '', + "writeMeshes": True, + "writeCurves": True, + "writeParticles": True, + "writeCameras": False, + "writeLights": False, + "writeJoints": False, + "writeCollections": False, + "writePositions": True, + "writeNormals": True, + "writeUVs": True, + "writeColorSets": False, + "writeTangents": False, + "writeRefPositions": False, + "writeBlendShapes": False, + "writeDisplayColor": False, + "writeSkinWeights": False, + "writeMaterialAssignment": False, + "writeHardwareShader": False, + "writeShadingNetworks": False, + "writeTransformMatrix": True, + "writeUsdAttributes": False, + "timeVaryingTopology": False, + "customMaterialNamespace": '', + "writeTimeRange": False, + "timeRangeStart": 1, + "timeRangeEnd": 1, + "timeRangeIncrement": 1, + "timeRangeNumTimeSamples": 0, + "timeRangeSamplesSpan": 0.0, + "timeRangeFramesPerSecond": 24.0 + } + def process(self, instance): # Load plugin firstly cmds.loadPlugin("MultiverseForMaya", quiet=True) @@ -23,6 +127,10 @@ class ExtractMultiverseUsd(openpype.api.Extractor): file_path = os.path.join(staging_dir, file_name) file_path = file_path.replace('\\', '/') + # Parse export options + options = self.default_options + self.log.info("Export options: {0}".format(options)) + # Perform extraction self.log.info("Performing extraction ...") @@ -34,11 +142,33 @@ class ExtractMultiverseUsd(openpype.api.Extractor): type=("mesh"), noIntermediate=True, long=True) + self.log.info('Collected object {}'.format(members)) # TODO: Deal with asset, composition, overide with options. import multiverse - options = multiverse.AssetWriteOptions() - multiverse.WriteAsset(file_path, members, options) + + time_opts = None + if options["writeTimeRange"]: + time_opts = multiverse.TimeOptions() + + time_opts.writeTimeRange = True + + time_range_start = options["timeRangeStart"] + time_range_end = options["timeRangeEnd"] + time_opts.frameRange = (time_range_start, time_range_end) + + time_opts.frameIncrement = options["timeRangeIncrement"] + time_opts.numTimeSamples = options["timeRangeNumTimeSamples"] + time_opts.timeSamplesSpan = options["timeRangeSamplesSpan"] + time_opts.framePerSecond = options["timeRangeFramesPerSecond"] + + asset_write_opts = multiverse.AssetWriteOptions(time_opts) + for (k, v) in options.iteritems(): + if k == "writeTimeRange" or k.startswith("timeRange"): + continue + setattr(asset_write_opts, k, v) + + multiverse.WriteAsset(file_path, members, asset_write_opts) if "representations" not in instance.data: instance.data["representations"] = [] @@ -51,4 +181,5 @@ class ExtractMultiverseUsd(openpype.api.Extractor): } instance.data["representations"].append(representation) - self.log.info("Extracted {} to {}".format(instance, file_path)) + self.log.info("Extracted instance {} to {}".format( + instance.name, file_path)) From 84b6a6cc6949ea849376f410417c9198a92a9241 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 10:20:58 +0100 Subject: [PATCH 050/854] OP-2868 - added configuration for default variant value to Settings --- .../plugins/create/create_render.py | 16 +++++++++---- .../project_settings/aftereffects.json | 7 ++++++ .../schema_project_aftereffects.json | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 0a907a02d8..e690af63d0 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -18,6 +18,16 @@ class RenderCreator(Creator): create_allow_context_change = False + def __init__( + self, create_context, system_settings, project_settings, headless=False + ): + super(RenderCreator, self).__init__(create_context, system_settings, + project_settings, headless) + self._default_variants = (project_settings["aftereffects"] + ["create"] + ["RenderCreator"] + ["defaults"]) + def get_icon(self): return resources.get_openpype_splash_filepath() @@ -72,11 +82,7 @@ class RenderCreator(Creator): self._add_instance_to_context(new_instance) def get_default_variants(self): - return [ - "myVariant", - "variantTwo", - "different_variant" - ] + return self._default_variants def get_instance_attr_defs(self): return [lib.BoolDef("farm", label="Render on farm")] diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json index 6a9a399069..8083aa0972 100644 --- a/openpype/settings/defaults/project_settings/aftereffects.json +++ b/openpype/settings/defaults/project_settings/aftereffects.json @@ -1,4 +1,11 @@ { + "create": { + "RenderCreator": { + "defaults": [ + "Main" + ] + } + }, "publish": { "ValidateSceneSettings": { "enabled": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json index 4c4cd225ab..1a3eaef540 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json @@ -5,6 +5,29 @@ "label": "AfterEffects", "is_file": true, "children": [ + { + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "RenderCreator", + "label": "Create render", + "children": [ + { + "type": "list", + "key": "defaults", + "label": "Default Variants", + "object_type": "text", + "docstring": "Fill default variant(s) (like 'Main' or 'Default') used in subset name creation." + } + ] + } + ] + }, { "type": "dict", "collapsible": true, From 87d114a272cac020f1a482b6209ad01a9907ba01 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 10:49:43 +0100 Subject: [PATCH 051/854] OP-2765 - added error message when creating same subset --- openpype/hosts/aftereffects/plugins/create/create_render.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 0a907a02d8..e75353c7a5 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -63,6 +63,11 @@ class RenderCreator(Creator): "one composition." )) + for inst in self.create_context.instances: + if subset_name == inst.subset_name: + raise CreatorError("{} already exists".format( + inst.subset_name)) + data["members"] = [items[0].id] new_instance = CreatedInstance(self.family, subset_name, data, self) new_instance.creator_attributes["farm"] = pre_create_data["farm"] From 32f015098b95d7953d94d878f32afbd4022a18df Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 11:08:51 +0100 Subject: [PATCH 052/854] OP-2765 - reimplemented get_context_title --- openpype/hosts/aftereffects/api/pipeline.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 4ade90e4dd..38ab2225bf 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -273,7 +273,12 @@ def update_context_data(data, changes): def get_context_title(): """Returns title for Creator window""" - return "AfterEffects" + import avalon.api + + project_name = avalon.api.Session["AVALON_PROJECT"] + asset_name = avalon.api.Session["AVALON_ASSET"] + task_name = avalon.api.Session["AVALON_TASK"] + return "{}/{}/{}".format(project_name, asset_name, task_name) def _get_stub(): From 56e2121e308f6bdf7e1551336ae3c28104920775 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 15:23:46 +0100 Subject: [PATCH 053/854] OP-2765 - fix local rendering in old publish --- openpype/hosts/aftereffects/plugins/publish/collect_render.py | 4 ++-- .../hosts/aftereffects/plugins/publish/pre_collect_render.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index d31571b6b5..43efd34635 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -57,7 +57,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): compositions_by_id = {item.id: item for item in compositions} for inst in context: family = inst.data["family"] - if family != "render": + if family not in ["render", "renderLocal"]: # legacy continue item_id = inst.data["members"][0] @@ -123,7 +123,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): instance.comp_name = comp.name instance.comp_id = item_id - is_local = "renderLocal" in inst.data["families"] # legacy + is_local = "renderLocal" in inst.data["family"] # legacy if inst.data.get("creator_attributes"): is_local = not inst.data["creator_attributes"].get("farm") if is_local: diff --git a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py index 614a04b4b7..3e84753555 100644 --- a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py @@ -34,7 +34,7 @@ class PreCollectRender(pyblish.api.ContextPlugin): "Please recreate instance.") instance = context.create_instance(inst["subset"]) - inst["families"] = [self.family_remapping[inst["family"]]] + inst["families"] = [self.family_remapping[inst["family"]][0]] instance.data.update(inst) self._debug_log(instance) From ec9b4802f40d6fe1d3dd02ab1195bace33ef0c82 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 16:07:18 +0100 Subject: [PATCH 054/854] OP-2765 - trigger failure when new instance tried to be published by Pyblish This could happen if artist try to switch between old Pyblish and New Publish --- .../hosts/aftereffects/plugins/publish/pre_collect_render.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py index 3e84753555..46bb9865b9 100644 --- a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py @@ -26,6 +26,10 @@ class PreCollectRender(pyblish.api.ContextPlugin): return for inst in list_instances(): + if inst.get("creator_attributes"): + raise ValueError("Instance created in New publisher, " + "cannot be published in Pyblish") + if inst["family"] not in self.family_remapping.keys(): continue From a5c38a8b2f19d24c55c2be564ab701f68f886c36 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 16:24:59 +0100 Subject: [PATCH 055/854] OP-2765 - added new label for families In the future they will be both merged to render.farm (when Harmony is updated to New Publisher). --- openpype/lib/abstract_collect_render.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/lib/abstract_collect_render.py b/openpype/lib/abstract_collect_render.py index 3839aad45d..e160f5a040 100644 --- a/openpype/lib/abstract_collect_render.py +++ b/openpype/lib/abstract_collect_render.py @@ -138,7 +138,9 @@ class AbstractCollectRender(pyblish.api.ContextPlugin): try: if "workfile" in instance.data["families"]: instance.data["publish"] = True - if "renderFarm" in instance.data["families"]: + # TODO merge renderFarm and render.farm + if ("renderFarm" in instance.data["families"] or + "render.farm" in instance.data["families"]): instance.data["remove"] = True except KeyError: # be tolerant if 'families' is missing. From 3b9e319de27548a935b2aaba2064193a674fdd88 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 16:26:37 +0100 Subject: [PATCH 056/854] OP-2765 - fixed resolution between local and farm --- .../hosts/aftereffects/plugins/publish/collect_render.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index 43efd34635..aa5bc58ac2 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -84,6 +84,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): subset_name = inst.data["subset"] instance = AERenderInstance( family=family, + families=inst.data.get("families", []), version=version, time="", source=current_file, @@ -130,7 +131,9 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): # for local renders instance = self._update_for_local(instance, project_entity) else: - instance.families = ["render.farm"] + fam = "render.farm" + if fam not in instance.families: + instance.families.append(fam) instances.append(instance) instances_to_remove.append(inst) @@ -210,7 +213,9 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): instance.anatomyData["subset"] = instance.subset instance.stagingDir = tempfile.mkdtemp() instance.projectEntity = project_entity - instance.families = ["render.local"] + fam = "render.local" + if fam not in instance.families: + instance.families.append(fam) settings = get_project_settings(os.getenv("AVALON_PROJECT")) reviewable_subset_filter = (settings["deadline"] From 592c95b1a66e0067b797b7a6d7458fa8127ac03a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 10 Mar 2022 01:14:21 +0100 Subject: [PATCH 057/854] fixes static mesh side of things --- .../plugins/publish/collect_unreal_staticmesh.py | 15 ++++++++++++--- .../plugins/publish/extract_unreal_staticmesh.py | 2 +- .../publish/validate_unreal_staticmesh_naming.py | 7 +++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 59f8df1ef1..02f21187b3 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- -from maya import cmds +from maya import cmds # noqa import pyblish.api from avalon.api import Session from openpype.api import get_project_settings +from pprint import pformat class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): @@ -24,17 +25,25 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): # take the name from instance (without the `staticMesh_` prefix) instance.data["staticMeshCombinedName"] = "{}_{}".format( sm_prefix, - instance.name[len(instance.data.get("family")) + 1:] - ) + instance.data.get("subset")[len(sm_prefix) + 1:]) + + self.log.info("joined mesh name: {}".format( + instance.data.get("staticMeshCombinedName"))) geometry_set = [i for i in instance if i == "geometry_SET"] instance.data["membersToCombine"] = cmds.sets( geometry_set, query=True) + self.log.info("joining meshes: {}".format( + pformat(instance.data.get("membersToCombine")))) + collision_set = [i for i in instance if i == "collisions_SET"] instance.data["collisionMembers"] = cmds.sets( collision_set, query=True) + self.log.info("collisions: {}".format( + pformat(instance.data.get("collisionMembers")))) + # set fbx overrides on instance instance.data["smoothingGroups"] = True instance.data["smoothMesh"] = True diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 22a3af3059..987370d395 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -46,7 +46,7 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): cmds.duplicate(to_combine[0], name=static_mesh_name, ic=True) delete_bin.extend([static_mesh_name]) - delete_bin.extend(duplicates) + # delete_bin.extend(duplicates) members = [static_mesh_name] members += instance.data["collisionMembers"] diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index d15d52f3bd..1ecf436582 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -115,9 +115,12 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): cls.log.error("{} is invalid".format(obj)) invalid.append(obj) else: + un_prefixed = combined_geometry_name[ + len(static_mesh_prefix) + 1: + ] expected_collision = "{}_{}".format( cl_m.group("prefix"), - combined_geometry_name[len(static_mesh_prefix) + 1:] + un_prefixed ) if not obj.startswith(expected_collision): @@ -130,7 +133,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): cl_m.group("prefix"), cl_m.group("renderName"), cl_m.group("prefix"), - combined_geometry_name, + un_prefixed, )) invalid.append(obj) From 9418a496c8cb96b18644bc9da08904cf4602b053 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 10 Mar 2022 16:32:38 +0900 Subject: [PATCH 058/854] improved multiverse usd loader --- .../maya/plugins/load/load_multiverse_usd.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index 4e9952b1a1..d5006cccb7 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -30,13 +30,24 @@ class MultiverseUsdLoader(api.Loader): cmds.loadPlugin("MultiverseForMaya", quiet=True) # Root group - label = "{}:{}".format(namespace, name) - root = cmds.group(name=label, empty=True) + rootName = "{}:{}".format(namespace, name) + root = cmds.group(name=rootName, empty=True) - # Create shape and move it under root + # Create shape with transform and move it under root import multiverse - shape = multiverse.CreateUsdCompound(self.fname) - cmds.parent(shape, root) + transform = multiverse.CreateUsdCompound(self.fname) + cmds.parent(transform, root) + + # Rename transform + nodes = [root, transform] + self[:] = nodes + + return containerise( + name=name, + namespace=namespace, + nodes=nodes, + context=context, + loader=self.__class__.__name__) def update(self, container, representation): @@ -44,7 +55,7 @@ class MultiverseUsdLoader(api.Loader): # Update the shape members = cmds.sets(container['objectName'], query=True) - shapes = cmds.ls(members, type="mvUsdPackedShape", long=True) + shapes = cmds.ls(members, type="mvUsdCompoundShape", long=True) assert len(shapes) == 1, "This is a bug" From d4f50e2abdf55fed0c12f439062c75b5c780a7e3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 10 Mar 2022 15:10:18 +0100 Subject: [PATCH 059/854] OP-2765 - fix imports for legacy farm creator --- .../aftereffects/plugins/create/create_legacy_render.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py b/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py index 8dfc85cdc8..e4fbb47a33 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_legacy_render.py @@ -1,13 +1,12 @@ -from avalon.api import CreatorError - -import openpype.api +from openpype.pipeline import create +from openpype.pipeline import CreatorError from openpype.hosts.aftereffects.api import ( get_stub, list_instances ) -class CreateRender(openpype.api.Creator): +class CreateRender(create.LegacyCreator): """Render folder for publish. Creates subsets in format 'familyTaskSubsetname', @@ -23,6 +22,7 @@ class CreateRender(openpype.api.Creator): def process(self): stub = get_stub() # only after After Effects is up + items = [] if (self.options or {}).get("useSelection"): items = stub.get_selected_items( comps=True, folders=False, footages=False From a15552f878a0aab7ecfa37053ea2b646161cd37b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 10 Mar 2022 15:10:42 +0100 Subject: [PATCH 060/854] OP-2765 - fix imports for new creator --- .../hosts/aftereffects/plugins/create/create_render.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index e75353c7a5..1a5a826137 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,7 +1,7 @@ -import avalon.api +from avalon import api as avalon_api from openpype import resources -import openpype.hosts.aftereffects.api as api +from openpype.hosts.aftereffects import api from openpype.pipeline import ( Creator, CreatedInstance, @@ -25,7 +25,7 @@ class RenderCreator(Creator): for instance_data in api.list_instances(): # legacy instances have family=='render' or 'renderLocal', use them creator_id = (instance_data.get("creator_identifier") or - instance_data.get("family").replace("Local", '')) + instance_data.get("family", '').replace("Local", '')) if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) instance = CreatedInstance.from_existing( @@ -108,7 +108,7 @@ class RenderCreator(Creator): instance_data.pop("uuid") if not instance_data.get("task"): - instance_data["task"] = avalon.api.Session.get("AVALON_TASK") + instance_data["task"] = avalon_api.Session.get("AVALON_TASK") if not instance_data.get("creator_attributes"): is_old_farm = instance_data["family"] != "renderLocal" From 60edd3abe6bf52271d7f1d84635f0be482d31c65 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 10 Mar 2022 15:13:35 +0100 Subject: [PATCH 061/854] OP-2765 - added functionality to store/retrive context data These data is used for context publish information, for example storing enabling/disabling of validators. Currently not present in AE. --- openpype/hosts/aftereffects/api/pipeline.py | 22 +++++++++++++-------- openpype/hosts/aftereffects/api/ws_stub.py | 10 ++++++---- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 38ab2225bf..978d035020 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -9,6 +9,7 @@ from avalon import io, pipeline from openpype import lib from openpype.api import Logger +from openpype.pipeline import LegacyCreator import openpype.hosts.aftereffects from openpype.pipeline import BaseCreator @@ -34,7 +35,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.register_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) log.info(PUBLISH_PATH) @@ -48,7 +49,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) avalon.api.deregister_plugin_path(avalon.api.Loader, LOAD_PATH) - avalon.api.deregister_plugin_path(avalon.api.Creator, CREATE_PATH) + avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) def application_launch(): @@ -223,10 +224,8 @@ def list_instances(): layers_meta = stub.get_metadata() for instance in layers_meta: - if instance.get("schema") and \ - "container" in instance.get("schema"): - continue - instances.append(instance) + if instance.get("id") == "pyblish.avalon.instance": + instances.append(instance) return instances @@ -263,12 +262,19 @@ def remove_instance(instance): # new publisher section def get_context_data(): - print("get_context_data") + meta = _get_stub().get_metadata() + for item in meta: + if item.get("id") == "publish_context": + item.pop("id") + return item + return {} def update_context_data(data, changes): - print("update_context_data") + item = data + item["id"] = "publish_context" + _get_stub().imprint(item["id"], item) def get_context_title(): diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index 1d3b69e038..d2dc40ec89 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -155,10 +155,12 @@ class AfterEffectsServerStub(): item_ids = [int(item.id) for item in all_items] cleaned_data = [] for meta in result_meta: - # for creation of instance OR loaded container - if 'instance' in meta.get('id') or \ - int(meta.get('members')[0]) in item_ids: - cleaned_data.append(meta) + # do not added instance with nonexistend item id + if meta.get("members"): + if int(meta["members"][0]) not in item_ids: + continue + + cleaned_data.append(meta) payload = json.dumps(cleaned_data, indent=4) From 3b4f96efa601351bb894f64a6e3d2d2e2c55d88b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 10 Mar 2022 15:19:42 +0100 Subject: [PATCH 062/854] OP-2765 - more explicit error message --- .../hosts/aftereffects/plugins/publish/pre_collect_render.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py index 46bb9865b9..03ec184524 100644 --- a/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/pre_collect_render.py @@ -28,7 +28,9 @@ class PreCollectRender(pyblish.api.ContextPlugin): for inst in list_instances(): if inst.get("creator_attributes"): raise ValueError("Instance created in New publisher, " - "cannot be published in Pyblish") + "cannot be published in Pyblish.\n" + "Please publish in New Publisher " + "or recreate instances with legacy Creators") if inst["family"] not in self.family_remapping.keys(): continue From bc596899f5b96e27c9b1e3937384d18c4462ff3e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 10 Mar 2022 23:45:40 +0100 Subject: [PATCH 063/854] fixed fbx settings --- openpype/hosts/maya/api/fbx.py | 6 +++--- .../maya/plugins/publish/collect_unreal_skeletalmesh.py | 5 ----- .../hosts/maya/plugins/publish/collect_unreal_staticmesh.py | 5 ----- openpype/plugins/publish/collect_resources_path.py | 5 ++++- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index 92683da51b..f8fe189589 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -84,10 +84,10 @@ class FBXExtractor: return { "cameras": False, - "smoothingGroups": False, + "smoothingGroups": True, "hardEdges": False, "tangents": False, - "smoothMesh": False, + "smoothMesh": True, "instances": False, "bakeComplexAnimation": True, "bakeComplexStart": start_frame, @@ -101,7 +101,7 @@ class FBXExtractor: "skins": True, "constraints": False, "lights": True, - "embeddedTextures": True, + "embeddedTextures": False, "inputConnections": True, "upAxis": "y", "triangulate": False diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py index 7d479b706c..2b176e3a6d 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py @@ -13,11 +13,6 @@ class CollectUnrealSkeletalMesh(pyblish.api.InstancePlugin): families = ["skeletalMesh"] def process(self, instance): - # set fbx overrides on instance - instance.data["smoothingGroups"] = True - instance.data["smoothMesh"] = True - instance.data["triangulate"] = True - frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 02f21187b3..faa5880e43 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -44,11 +44,6 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): self.log.info("collisions: {}".format( pformat(instance.data.get("collisionMembers")))) - # set fbx overrides on instance - instance.data["smoothingGroups"] = True - instance.data["smoothMesh"] = True - instance.data["triangulate"] = True - frame = cmds.currentTime(query=True) instance.data["frameStart"] = frame instance.data["frameEnd"] = frame diff --git a/openpype/plugins/publish/collect_resources_path.py b/openpype/plugins/publish/collect_resources_path.py index fa181301ee..1f509365c7 100644 --- a/openpype/plugins/publish/collect_resources_path.py +++ b/openpype/plugins/publish/collect_resources_path.py @@ -53,7 +53,10 @@ class CollectResourcesPath(pyblish.api.InstancePlugin): "textures", "action", "background", - "effect" + "effect", + "staticMesh", + "skeletalMesh" + ] def process(self, instance): From 310c572e65e5505996efc0fd8d1aa7307a385267 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 11 Mar 2022 11:40:32 +0900 Subject: [PATCH 064/854] added creator for multiverse usd override --- .../create/create_multiverse_usd_over.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py new file mode 100644 index 0000000000..522487e99b --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py @@ -0,0 +1,35 @@ +from openpype.hosts.maya.api import plugin, lib + + +class CreateMultiverseUsdOver(plugin.Creator): + """Multiverse USD data""" + + name = "usd" + label = "Multiverse USD" + family = "usd" + icon = "cubes" + + def __init__(self, *args, **kwargs): + super(CreateMultiverseUsdOver, self).__init__(*args, **kwargs) + + self.data["writeAll"] = False + self.data["writeTransforms"] = True + self.data["writeVisibility"] = True + self.data["writeAttributes"] = True + self.data["writeMaterials"] = True + self.data["writeVariants"] = True + self.data["writeVariantsDefinition"] = True + self.data["writeActiveState"] = True + self.data["writeNamespaces"] = False + + # The attributes below are about animated cache. + self.data["writeTimeRange"] = True + self.data["timeRangeNumTimeSamples"] = 0 + self.data["timeRangeSamplesSpan"] = 0.0 + + animation_data = lib.collect_animation_data(True) + + self.data["timeRangeStart"] = animation_data["frameStart"] + self.data["timeRangeEnd"] = animation_data["frameEnd"] + self.data["timeRangeIncrement"] = animation_data["step"] + self.data["timeRangeFramesPerSecond"] = animation_data["fps"] From 8ab9a146525219ff6b73483c2ead4e418b2f2933 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 11 Mar 2022 12:15:42 +0900 Subject: [PATCH 065/854] Changed name, label family of multiverse usd override creator --- .../hosts/maya/plugins/create/create_multiverse_usd_over.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py index 522487e99b..3bb563d20c 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py @@ -4,9 +4,9 @@ from openpype.hosts.maya.api import plugin, lib class CreateMultiverseUsdOver(plugin.Creator): """Multiverse USD data""" - name = "usd" - label = "Multiverse USD" - family = "usd" + name = "usdOverrideMain" + label = "Multiverse USD Override" + family = "usd_override" icon = "cubes" def __init__(self, *args, **kwargs): From ab4849ab968203e00db2cc4503f92a9faf83be82 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 11 Mar 2022 12:16:48 +0900 Subject: [PATCH 066/854] Changed name and writeTimeRange of multiverse usd creator --- openpype/hosts/maya/plugins/create/create_multiverse_usd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py index b7f892deb4..6851e0f6bc 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -4,7 +4,7 @@ from openpype.hosts.maya.api import plugin, lib class CreateMultiverseUsd(plugin.Creator): """Multiverse USD data""" - name = "usd" + name = "usdMain" label = "Multiverse USD" family = "usd" icon = "cubes" @@ -46,7 +46,7 @@ class CreateMultiverseUsd(plugin.Creator): self.data["customMaterialNamespace"] = '' # The attributes below are about animated cache. - self.data["writeTimeRange"] = False + self.data["writeTimeRange"] = True self.data["timeRangeNumTimeSamples"] = 0 self.data["timeRangeSamplesSpan"] = 0.0 From 3151784b1826392228776901abd0f93f8a94da79 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 11 Mar 2022 12:18:14 +0900 Subject: [PATCH 067/854] Updated multiverse usd extracor for default option values --- .../hosts/maya/plugins/publish/extract_multiverse_usd.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index a45ebf2f9b..565fbd1ee3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -108,9 +108,9 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "writeUsdAttributes": False, "timeVaryingTopology": False, "customMaterialNamespace": '', - "writeTimeRange": False, - "timeRangeStart": 1, - "timeRangeEnd": 1, + "writeTimeRange": True, + "timeRangeStart": start_frame, + "timeRangeEnd": end_frame, "timeRangeIncrement": 1, "timeRangeNumTimeSamples": 0, "timeRangeSamplesSpan": 0.0, From b36e3127dc50314cf7adc72749ebab2bbbc40451 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 11 Mar 2022 12:20:10 +0900 Subject: [PATCH 068/854] add multiverse usd override extractor plugin --- .../publish/extract_multiverse_usd_over.py | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py new file mode 100644 index 0000000000..f1b9ca88f0 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -0,0 +1,141 @@ +import os + +import avalon.maya +import openpype.api + +from maya import cmds + + +class ExtractMultiverseUsdOverride(openpype.api.Extractor): + """Extractor for USD Override by Multiverse.""" + + label = "Extract Multiverse USD Override" + hosts = ["maya"] + families = ["usd_override"] + + @property + def options(self): + """Overridable options for Multiverse USD Export + + Given in the following format + - {NAME: EXPECTED TYPE} + + If the overridden option's type does not match, + the option is not included and a warning is logged. + + """ + + return { + "writeAll": bool, + "writeTransforms": bool, + "writeVisibility": bool, + "writeAttributes": bool, + "writeMaterials": bool, + "writeVariants": bool, + "writeVariantsDefinition": bool, + "writeActiveState": bool, + "writeNamespaces": bool, + "writeTimeRange": bool, + "timeRangeStart": int, + "timeRangeEnd": int, + "timeRangeIncrement": int, + "timeRangeNumTimeSamples": int, + "timeRangeSamplesSpan": float, + "timeRangeFramesPerSecond": float + } + + @property + def default_options(self): + """The default options for Multiverse USD extraction.""" + start_frame = int(cmds.playbackOptions(query=True, + animationStartTime=True)) + end_frame = int(cmds.playbackOptions(query=True, + animationEndTime=True)) + + return { + "writeAll": False, + "writeTransforms": True, + "writeVisibility": True, + "writeAttributes": True, + "writeMaterials": True, + "writeVariants": True, + "writeVariantsDefinition": True, + "writeActiveState": True, + "writeNamespaces": False, + "writeTimeRange": True, + "timeRangeStart": start_frame, + "timeRangeEnd": end_frame, + "timeRangeIncrement": 1, + "timeRangeNumTimeSamples": 0, + "timeRangeSamplesSpan": 0.0, + "timeRangeFramesPerSecond": 24.0 + } + + def process(self, instance): + # Load plugin firstly + cmds.loadPlugin("MultiverseForMaya", quiet=True) + + # Define output file path + staging_dir = self.staging_dir(instance) + file_name = "{}.usda".format(instance.name) + file_path = os.path.join(staging_dir, file_name) + file_path = file_path.replace('\\', '/') + + # Parse export options + options = self.default_options + self.log.info("Export options: {0}".format(options)) + + # Perform extraction + self.log.info("Performing extraction ...") + + with avalon.maya.maintained_selection(): + members = instance.data("setMembers") + members = cmds.ls(members, + dag=True, + shapes=True, + type=("mvUsdCompoundShape"), + noIntermediate=True, + long=True) + self.log.info('Collected object {}'.format(members)) + + # TODO: Deal with asset, composition, overide with options. + import multiverse + + time_opts = None + if options["writeTimeRange"]: + time_opts = multiverse.TimeOptions() + + time_opts.writeTimeRange = True + + time_range_start = options["timeRangeStart"] + time_range_end = options["timeRangeEnd"] + time_opts.frameRange = (time_range_start, time_range_end) + + time_opts.frameIncrement = options["timeRangeIncrement"] + time_opts.numTimeSamples = options["timeRangeNumTimeSamples"] + time_opts.timeSamplesSpan = options["timeRangeSamplesSpan"] + time_opts.framePerSecond = options["timeRangeFramesPerSecond"] + + over_write_opts = multiverse.OverridesWriteOptions() + for (k, v) in options.iteritems(): + if k == "writeTimeRange" or k.startswith("timeRange"): + continue + setattr(over_write_opts, k, v) + over_write_opts.timeOptions = time_opts + + for member in members: + multiverse.WriteOverrides(file_path, member, over_write_opts) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'usda', + 'ext': 'usda', + 'files': file_name, + "stagingDir": staging_dir + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance {} to {}".format( + instance.name, file_path)) From bf5f5365b1dc90f51cdf5200420d3a0e6954317d Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 11 Mar 2022 12:22:07 +0900 Subject: [PATCH 069/854] added new family usd_override --- openpype/plugins/publish/integrate_new.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index e8dab089af..fc98327f2d 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -103,7 +103,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "effect", "xgen", "hda", - "usd" + "usd", + "usd_override" ] exclude_families = ["clip"] db_representation_context_keys = [ From 52dd76158fab048bd5ca8b9b1435b483aaa3c205 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 11 Mar 2022 19:08:52 +0100 Subject: [PATCH 070/854] simplify whole process --- .../publish/collect_unreal_staticmesh.py | 24 +------ .../publish/extract_unreal_staticmesh.py | 62 ++++++------------- .../validate_unreal_staticmesh_naming.py | 14 +---- 3 files changed, 24 insertions(+), 76 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index faa5880e43..728a26931b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api -from avalon.api import Session -from openpype.api import get_project_settings from pprint import pformat @@ -14,28 +12,12 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): families = ["staticMesh"] def process(self, instance): - project_settings = get_project_settings(Session["AVALON_PROJECT"]) - sm_prefix = ( - project_settings - ["maya"] - ["create"] - ["CreateUnrealStaticMesh"] - ["static_mesh_prefix"] - ) - # take the name from instance (without the `staticMesh_` prefix) - instance.data["staticMeshCombinedName"] = "{}_{}".format( - sm_prefix, - instance.data.get("subset")[len(sm_prefix) + 1:]) - - self.log.info("joined mesh name: {}".format( - instance.data.get("staticMeshCombinedName"))) - geometry_set = [i for i in instance if i == "geometry_SET"] - instance.data["membersToCombine"] = cmds.sets( + instance.data["geometryMembers"] = cmds.sets( geometry_set, query=True) - self.log.info("joining meshes: {}".format( - pformat(instance.data.get("membersToCombine")))) + self.log.info("geometry: {}".format( + pformat(instance.data.get("geometryMembers")))) collision_set = [i for i in instance if i == "collisions_SET"] instance.data["collisionMembers"] = cmds.sets( diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 987370d395..02dd5dc572 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -22,56 +22,30 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): families = ["staticMesh"] def process(self, instance): - to_combine = instance.data.get("membersToCombine") - static_mesh_name = instance.data.get("staticMeshCombinedName") - duplicates = [] + geo = instance.data.get("geometryMembers", []) + members = geo + instance.data.get("collisionMembers", []) - # delete created temporary nodes after extraction - with delete_after() as delete_bin: - # if we have more objects, combine them into one - # or just duplicate the single one - if len(to_combine) > 1: - self.log.info( - "merging {} into {}".format( - " + ".join(to_combine), static_mesh_name)) - duplicates = cmds.duplicate(to_combine, ic=True) - cmds.polyUnite( - *duplicates, - n=static_mesh_name, ch=False) - else: - self.log.info( - "duplicating {} to {} for export".format( - to_combine[0], static_mesh_name) - ) - cmds.duplicate(to_combine[0], name=static_mesh_name, ic=True) + fbx_exporter = fbx.FBXExtractor(log=self.log) - delete_bin.extend([static_mesh_name]) - # delete_bin.extend(duplicates) + # Define output path + staging_dir = self.staging_dir(instance) + filename = "{0}.fbx".format(instance.name) + path = os.path.join(staging_dir, filename) - members = [static_mesh_name] - members += instance.data["collisionMembers"] + # The export requires forward slashes because we need + # to format it into a string in a mel expression + path = path.replace('\\', '/') - fbx_exporter = fbx.FBXExtractor(log=self.log) + self.log.info("Extracting FBX to: {0}".format(path)) + self.log.info("Members: {0}".format(members)) + self.log.info("Instance: {0}".format(instance[:])) - # Define output path - staging_dir = self.staging_dir(instance) - filename = "{0}.fbx".format(instance.name) - path = os.path.join(staging_dir, filename) + fbx_exporter.set_options_from_instance(instance) - # The export requires forward slashes because we need - # to format it into a string in a mel expression - path = path.replace('\\', '/') - - self.log.info("Extracting FBX to: {0}".format(path)) - self.log.info("Members: {0}".format(members)) - self.log.info("Instance: {0}".format(instance[:])) - - fbx_exporter.set_options_from_instance(instance) - - with maintained_selection(): - with root_parent(members): - self.log.info("Un-parenting: {}".format(members)) - fbx_exporter.export(members, path) + with maintained_selection(): + with root_parent(members): + self.log.info("Un-parenting: {}".format(members)) + fbx_exporter.export(members, path) if "representations" not in instance.data: instance.data["representations"] = [] diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 1ecf436582..920e0982dc 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -79,18 +79,13 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): ["static_mesh_prefix"] ) - to_combine = instance.data.get("membersToCombine") - if not to_combine: - raise ValueError("Missing geometry to export.") - combined_geometry_name = instance.data.get( - "staticMeshCombinedName", None) if cls.validate_mesh: # compile regex for testing names regex_mesh = "{}{}".format( ("_" + cls.static_mesh_prefix) or "", cls.regex_mesh ) sm_r = re.compile(regex_mesh) - if not sm_r.match(combined_geometry_name): + if not sm_r.match(instance.data.get("subset")): cls.log.error("Mesh doesn't comply with name validation.") return True @@ -115,12 +110,9 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): cls.log.error("{} is invalid".format(obj)) invalid.append(obj) else: - un_prefixed = combined_geometry_name[ - len(static_mesh_prefix) + 1: - ] expected_collision = "{}_{}".format( cl_m.group("prefix"), - un_prefixed + instance.data.get("subset") ) if not obj.startswith(expected_collision): @@ -133,7 +125,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): cl_m.group("prefix"), cl_m.group("renderName"), cl_m.group("prefix"), - un_prefixed, + instance.data.get("subset"), )) invalid.append(obj) From 28f57a045b714d01113d3efc5317a5c02df63369 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Mon, 14 Mar 2022 12:13:51 +0900 Subject: [PATCH 071/854] added new creator for multiverse usd composition --- .../create/create_multiverse_usd_comp.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py new file mode 100644 index 0000000000..397f31d577 --- /dev/null +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py @@ -0,0 +1,30 @@ +from openpype.hosts.maya.api import plugin, lib + + +class CreateMultiverseUsdComp(plugin.Creator): + """Create Multiverse USD Composition""" + + name = "usdOverrideMain" + label = "Multiverse USD Override" + family = "usd_override" + icon = "cubes" + + def __init__(self, *args, **kwargs): + super(CreateMultiverseUsdComp, self).__init__(*args, **kwargs) + + self.data["stripNamespaces"] = False + self.data["mergeTransformAndShape"] = False + self.data["flattenContent"] = False + self.data["writePendingOverrides"] = False + + # The attributes below are about animated cache. + self.data["writeTimeRange"] = True + self.data["timeRangeNumTimeSamples"] = 0 + self.data["timeRangeSamplesSpan"] = 0.0 + + animation_data = lib.collect_animation_data(True) + + self.data["timeRangeStart"] = animation_data["frameStart"] + self.data["timeRangeEnd"] = animation_data["frameEnd"] + self.data["timeRangeIncrement"] = animation_data["step"] + self.data["timeRangeFramesPerSecond"] = animation_data["fps"] From 9f54faed1c989ac6fa1b1cb904daa0f37fd24e43 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Mon, 14 Mar 2022 12:37:09 +0900 Subject: [PATCH 072/854] fixed label and family for multiverse usd composition creator --- .../hosts/maya/plugins/create/create_multiverse_usd_comp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py index 397f31d577..2f57ccec6c 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py @@ -5,8 +5,8 @@ class CreateMultiverseUsdComp(plugin.Creator): """Create Multiverse USD Composition""" name = "usdOverrideMain" - label = "Multiverse USD Override" - family = "usd_override" + label = "Multiverse USD Composition" + family = "usdComposition" icon = "cubes" def __init__(self, *args, **kwargs): From 86d51270e22650effeff64e16f6943b67c622b6f Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Mon, 14 Mar 2022 14:04:58 +0900 Subject: [PATCH 073/854] declare more families for multiverse usd loader --- openpype/hosts/maya/plugins/load/load_multiverse_usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index d5006cccb7..7214e1cbb6 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -7,7 +7,7 @@ import maya.cmds as cmds class MultiverseUsdLoader(api.Loader): """Load the USD by Multiverse""" - families = ["usd"] + families = ["model", "usd", "usdComposition", "usd_override"] representations = ["usd", "usda", "usdc", "usdz", "abc"] label = "Read USD by Multiverse" From 82929bba785bd1966038002ae63a69bde566f378 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Mon, 14 Mar 2022 14:06:26 +0900 Subject: [PATCH 074/854] renamed family usd_override to usdOverride --- .../hosts/maya/plugins/create/create_multiverse_usd_over.py | 2 +- openpype/hosts/maya/plugins/load/load_multiverse_usd.py | 2 +- .../hosts/maya/plugins/publish/extract_multiverse_usd_over.py | 2 +- openpype/plugins/publish/integrate_new.py | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py index 3bb563d20c..bdec96c2ff 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py @@ -6,7 +6,7 @@ class CreateMultiverseUsdOver(plugin.Creator): name = "usdOverrideMain" label = "Multiverse USD Override" - family = "usd_override" + family = "usdOverride" icon = "cubes" def __init__(self, *args, **kwargs): diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index 7214e1cbb6..3370033141 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -7,7 +7,7 @@ import maya.cmds as cmds class MultiverseUsdLoader(api.Loader): """Load the USD by Multiverse""" - families = ["model", "usd", "usdComposition", "usd_override"] + families = ["model", "usd", "usdComposition", "usdOverride"] representations = ["usd", "usda", "usdc", "usdz", "abc"] label = "Read USD by Multiverse" diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index f1b9ca88f0..e0e65d83d1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -11,7 +11,7 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): label = "Extract Multiverse USD Override" hosts = ["maya"] - families = ["usd_override"] + families = ["usdOverride"] @property def options(self): diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index fc98327f2d..4118583787 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -104,7 +104,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "xgen", "hda", "usd", - "usd_override" + "usdComposition", + "usdOverride" ] exclude_families = ["clip"] db_representation_context_keys = [ From 65b00455614cadd5f279fcfdd37c41f976697c99 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Mar 2022 17:31:57 +0100 Subject: [PATCH 075/854] OP-2766 - fixed not working self.log in New Publisher --- openpype/pipeline/create/creator_plugins.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 1ac2c420a2..f05b132fc6 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -69,7 +69,9 @@ class BaseCreator: @property def log(self): if self._log is None: - self._log = logging.getLogger(self.__class__.__name__) + from openpype.api import Logger + + self._log = Logger.get_logger(self.__class__.__name__) return self._log def _add_instance_to_context(self, instance): From a71dad4608e0be4a91c75769e5edf6722f52f9ff Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Mar 2022 17:35:17 +0100 Subject: [PATCH 076/854] OP-2766 - implemented auto creator for PS Creates workfile instance, updated imprint function. --- openpype/hosts/photoshop/api/pipeline.py | 52 +++++++++---- openpype/hosts/photoshop/api/ws_stub.py | 33 +++++---- .../plugins/create/workfile_creator.py | 73 +++++++++++++++++++ 3 files changed, 131 insertions(+), 27 deletions(-) create mode 100644 openpype/hosts/photoshop/plugins/create/workfile_creator.py diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 1be8129aa1..0e3f1215aa 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -8,7 +8,7 @@ from avalon import pipeline, io from openpype.api import Logger from openpype.lib import register_event_callback -from openpype.pipeline import LegacyCreator +from openpype.pipeline import LegacyCreator, BaseCreator import openpype.hosts.photoshop from . import lib @@ -71,6 +71,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) avalon.api.register_plugin_path(avalon.api.Loader, LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) log.info(PUBLISH_PATH) pyblish.api.register_callback( @@ -144,12 +145,9 @@ def list_instances(): layers_meta = stub.get_layers_metadata() if layers_meta: for key, instance in layers_meta.items(): - schema = instance.get("schema") - if schema and "container" in schema: - continue - - instance['uuid'] = key - instances.append(instance) + if instance.get("id") == "pyblish.avalon.instance": # TODO only this way? + instance['uuid'] = key + instances.append(instance) return instances @@ -170,11 +168,18 @@ def remove_instance(instance): if not stub: return - stub.remove_instance(instance.get("uuid")) - layer = stub.get_layer(instance.get("uuid")) - if layer: - stub.rename_layer(instance.get("uuid"), - layer.name.replace(stub.PUBLISH_ICON, '')) + inst_id = instance.get("instance_id") or instance.get("uuid") # legacy + if not inst_id: + log.warning("No instance identifier for {}".format(instance)) + return + + stub.remove_instance(inst_id) + + if instance.get("members"): + item = stub.get_item(instance["members"][0]) + if item: + stub.rename_item(item.id, + item.name.replace(stub.PUBLISH_ICON, '')) def _get_stub(): @@ -226,6 +231,27 @@ def containerise( "members": [str(layer.id)] } stub = lib.stub() - stub.imprint(layer, data) + stub.imprint(layer.id, data) return layer + + +def get_context_data(): + pass + + +def update_context_data(data, changes): + # item = data + # item["id"] = "publish_context" + # _get_stub().imprint(item["id"], item) + pass + + +def get_context_title(): + """Returns title for Creator window""" + import avalon.api + + project_name = avalon.api.Session["AVALON_PROJECT"] + asset_name = avalon.api.Session["AVALON_ASSET"] + task_name = avalon.api.Session["AVALON_TASK"] + return "{}/{}/{}".format(project_name, asset_name, task_name) \ No newline at end of file diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index 64d89f5420..a99f184080 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -27,6 +27,7 @@ class PSItem(object): members = attr.ib(factory=list) long_name = attr.ib(default=None) color_code = attr.ib(default=None) # color code of layer + instance_id = attr.ib(default=None) class PhotoshopServerStub: @@ -82,7 +83,7 @@ class PhotoshopServerStub: return layers_meta.get(str(layer.id)) - def imprint(self, layer, data, all_layers=None, layers_meta=None): + def imprint(self, item_id, data, all_layers=None, items_meta=None): """Save layer metadata to Headline field of active document Stores metadata in format: @@ -108,28 +109,29 @@ class PhotoshopServerStub: }] - for loaded instances Args: - layer (PSItem): + item_id (str): data(string): json representation for single layer all_layers (list of PSItem): for performance, could be injected for usage in loop, if not, single call will be triggered - layers_meta(string): json representation from Headline + items_meta(string): json representation from Headline (for performance - provide only if imprint is in loop - value should be same) Returns: None """ - if not layers_meta: - layers_meta = self.get_layers_metadata() + if not items_meta: + items_meta = self.get_layers_metadata() # json.dumps writes integer values in a dictionary to string, so # anticipating it here. - if str(layer.id) in layers_meta and layers_meta[str(layer.id)]: + item_id = str(item_id) + if item_id in items_meta.keys(): if data: - layers_meta[str(layer.id)].update(data) + items_meta[item_id].update(data) else: - layers_meta.pop(str(layer.id)) + items_meta.pop(item_id) else: - layers_meta[str(layer.id)] = data + items_meta[item_id] = data # Ensure only valid ids are stored. if not all_layers: @@ -137,12 +139,14 @@ class PhotoshopServerStub: layer_ids = [layer.id for layer in all_layers] cleaned_data = [] - for layer_id in layers_meta: - if int(layer_id) in layer_ids: - cleaned_data.append(layers_meta[layer_id]) + for item in items_meta.values(): + if item.get("members"): + if int(item["members"][0]) not in layer_ids: + continue + + cleaned_data.append(item) payload = json.dumps(cleaned_data, indent=4) - self.websocketserver.call( self.client.call('Photoshop.imprint', payload=payload) ) @@ -528,6 +532,7 @@ class PhotoshopServerStub: d.get('type'), d.get('members'), d.get('long_name'), - d.get("color_code") + d.get("color_code"), + d.get("instance_id") )) return ret diff --git a/openpype/hosts/photoshop/plugins/create/workfile_creator.py b/openpype/hosts/photoshop/plugins/create/workfile_creator.py new file mode 100644 index 0000000000..d66a05cad7 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/create/workfile_creator.py @@ -0,0 +1,73 @@ +from avalon import io + +import openpype.hosts.photoshop.api as api +from openpype.pipeline import ( + AutoCreator, + CreatedInstance +) + + +class PSWorkfileCreator(AutoCreator): + identifier = "workfile" + family = "workfile" + + def get_instance_attr_defs(self): + return [] + + def collect_instances(self): + for instance_data in api.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + subset_name = instance_data["subset"] + instance = CreatedInstance( + self.family, subset_name, instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + # nothing to change on workfiles + pass + + def create(self, options=None): + existing_instance = None + for instance in self.create_context.instances: + if instance.family == self.family: + existing_instance = instance + break + + variant = '' + project_name = io.Session["AVALON_PROJECT"] + asset_name = io.Session["AVALON_ASSET"] + task_name = io.Session["AVALON_TASK"] + host_name = io.Session["AVALON_APP"] + if existing_instance is None: + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": variant + } + data.update(self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + )) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + api.stub().imprint(new_instance.get("instance_id"), + new_instance.data_to_store()) + + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name From cdb2047ef7e205054f2c31fb6f336e259fa93d47 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 14 Mar 2022 17:35:40 +0100 Subject: [PATCH 077/854] OP-2766 - renamed legacy creator --- .../plugins/create/create_legacy_image.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 openpype/hosts/photoshop/plugins/create/create_legacy_image.py diff --git a/openpype/hosts/photoshop/plugins/create/create_legacy_image.py b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py new file mode 100644 index 0000000000..a001b5f171 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py @@ -0,0 +1,99 @@ +from Qt import QtWidgets +from openpype.pipeline import create +from openpype.hosts.photoshop import api as photoshop + + +class CreateImage(create.LegacyCreator): + """Image folder for publish.""" + + name = "imageDefault" + label = "Image" + family = "image" + defaults = ["Main"] + + def process(self): + groups = [] + layers = [] + create_group = False + + stub = photoshop.stub() + if (self.options or {}).get("useSelection"): + multiple_instances = False + selection = stub.get_selected_layers() + self.log.info("selection {}".format(selection)) + if len(selection) > 1: + # Ask user whether to create one image or image per selected + # item. + msg_box = QtWidgets.QMessageBox() + msg_box.setIcon(QtWidgets.QMessageBox.Warning) + msg_box.setText( + "Multiple layers selected." + "\nDo you want to make one image per layer?" + ) + msg_box.setStandardButtons( + QtWidgets.QMessageBox.Yes | + QtWidgets.QMessageBox.No | + QtWidgets.QMessageBox.Cancel + ) + ret = msg_box.exec_() + if ret == QtWidgets.QMessageBox.Yes: + multiple_instances = True + elif ret == QtWidgets.QMessageBox.Cancel: + return + + if multiple_instances: + for item in selection: + if item.group: + groups.append(item) + else: + layers.append(item) + else: + group = stub.group_selected_layers(self.name) + groups.append(group) + + elif len(selection) == 1: + # One selected item. Use group if its a LayerSet (group), else + # create a new group. + if selection[0].group: + groups.append(selection[0]) + else: + layers.append(selection[0]) + elif len(selection) == 0: + # No selection creates an empty group. + create_group = True + else: + group = stub.create_group(self.name) + groups.append(group) + + if create_group: + group = stub.create_group(self.name) + groups.append(group) + + for layer in layers: + stub.select_layers([layer]) + group = stub.group_selected_layers(layer.name) + groups.append(group) + + creator_subset_name = self.data["subset"] + for group in groups: + long_names = [] + group.name = group.name.replace(stub.PUBLISH_ICON, ''). \ + replace(stub.LOADED_ICON, '') + + subset_name = creator_subset_name + if len(groups) > 1: + subset_name += group.name.title().replace(" ", "") + + if group.long_name: + for directory in group.long_name[::-1]: + name = directory.replace(stub.PUBLISH_ICON, '').\ + replace(stub.LOADED_ICON, '') + long_names.append(name) + + self.data.update({"subset": subset_name}) + self.data.update({"uuid": str(group.id)}) + self.data.update({"long_name": "_".join(long_names)}) + stub.imprint(group, self.data) + # reusing existing group, need to rename afterwards + if not create_group: + stub.rename_layer(group.id, stub.PUBLISH_ICON + group.name) From 87b256387d453ce760a13fd6151a67436e7b6ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Mon, 14 Mar 2022 22:30:26 +0100 Subject: [PATCH 078/854] fix for multiple subsets --- .../maya/plugins/publish/collect_unreal_staticmesh.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py index 728a26931b..79d0856fa0 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_staticmesh.py @@ -12,14 +12,20 @@ class CollectUnrealStaticMesh(pyblish.api.InstancePlugin): families = ["staticMesh"] def process(self, instance): - geometry_set = [i for i in instance if i == "geometry_SET"] + geometry_set = [ + i for i in instance + if i.startswith("geometry_SET") + ] instance.data["geometryMembers"] = cmds.sets( geometry_set, query=True) self.log.info("geometry: {}".format( pformat(instance.data.get("geometryMembers")))) - collision_set = [i for i in instance if i == "collisions_SET"] + collision_set = [ + i for i in instance + if i.startswith("collisions_SET") + ] instance.data["collisionMembers"] = cmds.sets( collision_set, query=True) From d31bee0c3e1a1c368dc099cc420a199c67919f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 15 Mar 2022 00:41:46 +0100 Subject: [PATCH 079/854] fix parenting for skeletal meshes --- openpype/hosts/maya/api/fbx.py | 2 +- openpype/hosts/maya/api/lib.py | 20 ++++++++++++++++--- .../publish/extract_unreal_skeletalmesh.py | 19 ++++++++++++------ .../publish/extract_unreal_staticmesh.py | 4 ++-- 4 files changed, 33 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/api/fbx.py b/openpype/hosts/maya/api/fbx.py index f8fe189589..260241f5fc 100644 --- a/openpype/hosts/maya/api/fbx.py +++ b/openpype/hosts/maya/api/fbx.py @@ -198,5 +198,5 @@ class FBXExtractor: path (str): Path to use for export. """ - cmds.select(members, r=1, noExpand=True) + cmds.select(members, r=True, noExpand=True) mel.eval('FBXExport -f "{}" -s'.format(path)) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 41c67a6209..a5199d8443 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -3085,11 +3085,20 @@ def set_colorspace(): @contextlib.contextmanager -def root_parent(nodes): - # type: (list) -> list +def parent_nodes(nodes, parent=None): + # type: (list, str) -> list """Context manager to un-parent provided nodes and return them back.""" import pymel.core as pm # noqa + parent_node = None + delete_parent = False + + if parent: + if not cmds.objExists(parent): + parent_node = pm.createNode("transform", n=parent, ss=False) + delete_parent = True + else: + parent_node = pm.PyNode(parent) node_parents = [] for node in nodes: n = pm.PyNode(node) @@ -3100,9 +3109,14 @@ def root_parent(nodes): node_parents.append((n, root)) try: for node in node_parents: - node[0].setParent(world=True) + if not parent: + node[0].setParent(world=True) + else: + node[0].setParent(parent_node) yield finally: for node in node_parents: if node[1]: node[0].setParent(node[1]) + if delete_parent: + pm.delete(parent_node) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index c0b408c3f0..6f4c70fc07 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -7,9 +7,8 @@ from maya import cmds # noqa import pyblish.api import openpype.api from openpype.hosts.maya.api.lib import ( - root_parent, - maintained_selection, - delete_after + parent_nodes, + maintained_selection ) from openpype.hosts.maya.api import fbx @@ -43,10 +42,18 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): self.log.info("Instance: {0}".format(instance[:])) fbx_exporter.set_options_from_instance(instance) + + parent = "{}{}".format( + instance.data["asset"], + instance.data.get("variant", "") + ) with maintained_selection(): - with root_parent(to_extract): - rooted = [i.split("|")[-1] for i in to_extract] - self.log.info("Un-parenting: {}".format(to_extract)) + with parent_nodes(to_extract, parent=parent): + rooted = [ + "{}|{}".format(parent, i.split("|")[-1]) + for i in to_extract + ] + self.log.info("Un-parenting: {}".format(rooted, path)) fbx_exporter.export(rooted, path) if "representations" not in instance.data: diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 02dd5dc572..c3cc322a29 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -7,7 +7,7 @@ from maya import cmds # noqa import pyblish.api import openpype.api from openpype.hosts.maya.api.lib import ( - root_parent, + parent_nodes, maintained_selection, delete_after ) @@ -43,7 +43,7 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): fbx_exporter.set_options_from_instance(instance) with maintained_selection(): - with root_parent(members): + with parent_nodes(members): self.log.info("Un-parenting: {}".format(members)) fbx_exporter.export(members, path) From 08370f53e564174c87f1dd9316ecb2dc53898f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 15 Mar 2022 01:01:14 +0100 Subject: [PATCH 080/854] fix getting correct name with prefix --- .../plugins/publish/validate_unreal_staticmesh_naming.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index 920e0982dc..c0eeb82688 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -104,6 +104,9 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): cl_r = re.compile(regex_collision) + mesh_name = "{}{}".format(instance.data["asset"], + instance.data.get("variant", [])) + for obj in collision_set: cl_m = cl_r.match(obj) if not cl_m: @@ -112,7 +115,7 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): else: expected_collision = "{}_{}".format( cl_m.group("prefix"), - instance.data.get("subset") + mesh_name ) if not obj.startswith(expected_collision): @@ -121,11 +124,11 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): "Collision object name doesn't match " "static mesh name" ) - cls.log.error("{}_{} != {}_{}".format( + cls.log.error("{}_{} != {}_{}*".format( cl_m.group("prefix"), cl_m.group("renderName"), cl_m.group("prefix"), - instance.data.get("subset"), + mesh_name, )) invalid.append(obj) From 314f789bc91e30a4ddd646225a4442faf3725a8b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 11:22:10 +0100 Subject: [PATCH 081/854] initial commit of publishing development docs --- website/docs/dev_publishing.md | 390 +++++++++++++++++++++++++++++++++ website/sidebars.js | 9 +- 2 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 website/docs/dev_publishing.md diff --git a/website/docs/dev_publishing.md b/website/docs/dev_publishing.md new file mode 100644 index 0000000000..0808818456 --- /dev/null +++ b/website/docs/dev_publishing.md @@ -0,0 +1,390 @@ +--- +id: dev_publishing +title: Publishing +sidebar_label: Publishing +--- + +Publishing workflow consist of 2 parts: +- Creation - Mark what will be published and how. +- Publishing - Use data from creation to go through pyblish process. + +OpenPype is using `pyblish` for publishing process. It is a little bit extented and modified mainly for UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during creation part instead of in publishing part and has limited actions only for failed validator plugins. + +# Creation +Concept of creation does not have to "create" anything but prepare and store metadata about an "instance". Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach. Storing data to workfile gives ability to keep values so artist does not have to do create instances over and over. + +## Created instance +Objected representation of created instance metadata defined by class **CreatedInstance**. Has access to **CreateContext** and **BaseCreator** that initialized the object. Is dictionary like object with few immutable keys (maked with start `*`) that are defined by creator plugin or create context on initialization. Can have more arbitrary data but keep in mind that some keys are reserved. + +| Key | Type | Description | +|---|---|---| +| *id | str | Identifier of metadata type. ATM constant **"pyblish.avalon.instance"** | +| *instance_id | str | Unique ID of instance. Set automatically on instance creation using `str(uuid.uuid4())` | +| *family | str | Instance's family representing type defined by creator plugin. | +| *creator_identifier | str | Identifier of creator that collected/created the instance. | +| *creator_attributes | dict | Dictionary of attributes that are defined by creator plugin (`get_instance_attr_defs`). | +| *publish_attributes | dict | Dictionary of attributes that are defined by publish plugins. | +| variant | str | Variant is entered by artist on creation and may affect **subset**. | +| subset | str | Name of instance. This name will be used as subset name during publishing. | +| active | bool | Is instance active and will be published or not. | +| asset | str | Name of asset in which context was created. | +| task | str | Name of task in which context was created. Can be set to `None`. | + +Task should not be required until subset name template expect it. + +## Create plugin +Main responsibility of create plugin is to create, update, collect and remove instance metadata and propagate changes to create context. Has access to **CreateContext** (`self.create_context`) that discovered the plugin so has also access to other creators and instances. + +### BaseCreator +Base implementation of creator plugin. It is not recommended to use this class as base for production plugins but rather use one of **AutoCreator** and **Creator** variants. + +**Abstractions** +- **`family`** (class attr) - Tells what kind of instance will be created. +```python +class WorkfileCreator(Creator): + family = "workfile" +``` + +- **`collect_instances`** (method) - Collect already existing instances from workfile and add them to create context. This method is called on initialization or reset of **CreateContext**. Each creator is responsible to find it's instances metadata, convert them to **CreatedInstance** object and add the to create context (`self._add_instance_to_context(instnace_obj)`). +```python +def collect_instances(self): + # Using 'pipeline.list_instances' is just example how to get existing instances from scene + # - getting existing instances is different per host implementation + for instance_data in pipeline.list_instances(): + # Process only instances that were created by this creator + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + # Create instance object from existing data + instance = CreatedInstance.from_existing( + instance_data, self + ) + # Add instance to create context + self._add_instance_to_context(instance) +``` + +- **`create`** (method) - Create new object of **CreatedInstance** store it's metadata to workfile and add the instance into create context. Failed creation should raise **CreatorError** if happens error that can artist fix or give him some useful information. Trigger and implementation differs for **Creator** and **AutoCreator**. + +- **`update_instances`** (method) - Update data of instances. Receives tuple with **instance** and **changes**. +```python +def update_instances(self, update_list): + # Loop over changed instances + for instance, changes in update_list: + # Example possible usage of 'changes' to use different node on change + # of node id in instance data (MADE UP) + node = None + if "node_id" in changes: + old_value, new_value = changes["node_id"] + if new_value is not None: + node = pipeline.get_node_by_id(new_value) + + if node is None: + node = pipeline.get_node_by_instance_id(instance.id) + # Get node in scene that represents the instance + # Imprind data to a node + pipeline.imprint(node, instance.data_to_store()) + + +# Most implementations will probably ignore 'changes' completely +def update_instances(self, update_list): + for instance, _ in update_list: + # Get node from scene + node = pipeline.get_node_by_instance_id(instance.id) + # Imprint data to node + pipeline.imprint(node, instance.data_to_store()) +``` + +- **`remove_instances`** (method) - Remove instance metadata from workfile and from create context. +```python +# Possible way how to remove instance +def remove_instances(self, instances): + for instance in instances: + # Remove instance metadata from workflle + pipeline.remove_instance(instance.id) + # Remove instance from create context + self._remove_instance_from_context(instance) + + +# Default implementation of `AutoCreator` +def remove_instances(self, instances): + pass +``` + +:::note +When host implementation use universal way how to store and load instances you should implement host specific creator plugin base class with implemented **collect_instances**, **update_instances** and **remove_instances**. +::: + +**Optional implementations** + +- **`enabled`** (attr) - Boolean if creator plugin is enabled and used. +- **`identifier`** (class attr) - Consistent unique string identifier of the creator plugin. Is used to identify source plugin of existing instances. There can't be 2 creator plugins with same identifier. Default implementation returns `family` attribute. +```python +class RenderLayerCreator(Creator): + family = "render" + identifier = "render_layer" + + +class RenderPassCreator(Creator): + family = "render" + identifier = "render_pass" +``` + +- **`label`** (attr) - String label of creator plugin which will showed in UI, `identifier` is used when not set. It should be possible to use html tags. +```python +class RenderLayerCreator(Creator): + label = "Render Layer" +``` + +- **`icon`** (attr) - Icon of creator and it's instances. Value can be a path to image file, full name of qtawesome icon, `QPixmap` or `QIcon`. For complex cases or cases when `Qt` objects are returned it is recommended to override `get_icon` method and handle the logic or import `Qt` inside the method to not break headless usage of creator plugin. For list of qtawesome icons check qtawesome github repository (look for used version in pyproject.toml). +- **`get_icon`** (method) - Default implementation returns `self.icon`. +```python +class RenderLayerCreator(Creator): + # Use font awesome 5 icon + icon = "fa5.building" +``` + + +- **`get_instance_attr_defs`** (method) - Attribute definitions of instance. Creator can define attribute values with default values for each instance. These attributes may affect how will be instance processed during publishing. Attribute defiitions can be used from `openpype.pipeline.lib.attribute_definitions` (NOTE: Will be moved to `openpype.lib.attribute_definitions` soon). Attribute definitions define basic type of values for different cases e.g. boolean, number, string, enumerator, etc. Their advantage is that they can be created dynamically and +```python +from openpype.pipeline import attribute_definitions + + +class RenderLayerCreator(Creator): + def get_instance_attr_defs(self): + # Return empty list if '_allow_farm_render' is not enabled (can be set during initialization) + if not self._allow_farm_render: + return [] + # Give artist option to change if should be rendered on farm or locally + return [ + attribute_definitions.BoolDef( + "render_farm", + default=False, + label="Render on Farm" + ) + ] +``` + +- **`get_subset_name`** (method) - Calculate subset name based on passed data. Data can be extended using `get_dynamic_data` method. Default implementation is using `get_subset_name` from `openpype.lib` which is recommended. + +- **`get_dynamic_data`** (method) - Can be used to extend data for subset template which may be required in some cases. + + +### AutoCreator +Creator that is triggered on reset of create context. Can be used for families that are expected to be created automatically without artist interaction (e.g. **workfile**). Method `create` is triggered after collecting of all creators. + +:::important +**AutoCreator** has implemented **remove_instances** to do nothing as removing of auto created instances would in most of cases lead to create new instance immediately. +::: + +```python +def __init__( + self, create_context, system_settings, project_settings, *args, **kwargs +): + super(MyCreator, self).__init__( + create_context, system_settings, project_settings, *args, **kwargs + ) + # Get variant value from settings + variant_name = ( + project_settings["my_host"][self.identifier]["variant"] + ).strip() + if not variant_name: + variant_name = "Main" + self._variant_name = variant_name + +# Create does not expect any arguments +def create(self): + # Look for existing instance in create context + existing_instance = None + for instance in self.create_context.instances: + if instance.creator_identifier == self.identifier: + existing_instance = instance + break + + # Collect current context information + # - variant can be filled from settings + variant = self._variant_name + # Only place where we can look for current context + project_name = io.Session["AVALON_PROJECT"] + asset_name = io.Session["AVALON_ASSET"] + task_name = io.Session["AVALON_TASK"] + host_name = io.Session["AVALON_APP"] + + # Create new instance if does not exist yet + if existing_instance is None: + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + data = { + "asset": asset_name, + "task": task_name, + "variant": variant + } + data.update(self.get_dynamic_data( + variant, task_name, asset_doc, project_name, host_name + )) + + new_instance = CreatedInstance( + self.family, subset_name, data, self + ) + self._add_instance_to_context(new_instance) + + # Update instance context if is not the same + elif ( + existing_instance["asset"] != asset_name + or existing_instance["task"] != task_name + ): + asset_doc = io.find_one({"type": "asset", "name": asset_name}) + subset_name = self.get_subset_name( + variant, task_name, asset_doc, project_name, host_name + ) + existing_instance["asset"] = asset_name + existing_instance["task"] = task_name +``` + +### Creator +Implementation of creator plugin that is triggered manually by artist in UI (or by code). Has extended options for UI purposes than **AutoCreator** and **create** method expect more arguments. + +- **`create_allow_context_change`** (class attr) - Allow to set context in UI before creation. Some creator may not allow it or their logic would not use the context selection (e.g. bulk creators). +```python +class BulkRenderCreator(Creator): + create_allow_context_change = False +``` +- **`get_default_variants`** (method) - Returns list of default variants that are showed in create dialog. Uses **default_variants** by default. +- **`default_variants`** (attr) - Attribute for default implementation of **get_default_variants**. + +- **`get_default_variant`** (method) - Return default variant that is prefilled in UI. By default returns `None`, in that case first item from **get_default_variants** is used if there is any or **"Main"**. + +- **`get_description`** (method) - Returns short string description of creator. Uses **description** by default. +- **`description`** (attr) - Attribute for default implementation of **get_description**. + +- **`get_detailed_description`** (method) - Returns detailed string description of creator. Can contain markdown. Uses **detailed_description** by default. +- **`detailed_description`** (attr) - Attribute for default implementation of **get_detailed_description**. + +- **`get_pre_create_attr_defs`** (method) - Similar to **get_instance_attr_defs** returns attribute definitions but they are filled before creation. When creation is called from UI the values are passed to **create** method. + +- **`create`** (method) - Code where creation of metadata + +```python +from openpype.pipeline import attribute_definitions + + +class RenderLayerCreator(Creator): + def __init__( + self, context, system_settings, project_settings, *args, **kwargs + ): + super(RenderLayerCreator, self).__init__( + context, system_settings, project_settings, *args, **kwargs + ) + plugin_settings = ( + project_settings["my_host"]["create"][self.__class__.__name__] + ) + self._allow_farm_render = plugin_settings["allow_farm_render"] + + def get_instance_attr_defs(self): + # Return empty list if '_allow_farm_render' is not enabled (can be set during initialization) + if not self._allow_farm_render: + return [] + # Give artist option to change if should be rendered on farm or locally + return [ + attribute_definitions.BoolDef( + "render_farm", + default=False, + label="Render on Farm" + ) + ] + + def get_pre_create_attr_defs(self): + return [ + # Give user option to use selection or not + attribute_definitions.BoolDef( + "use_selection", + default=False, + label="Use selection" + ), + # Set to render on farm in creator dialog + # - this value is not automatically passed to instance attributes + # creator must do that during creation + attribute_definitions.BoolDef( + "render_farm", + default=False, + label="Render on Farm" + ) + ] + + def create(self, subset_name, instance_data, pre_create_data): + # ARGS: + # - 'subset_name' - precalculated subset name + # - 'instance_data' - context data + # - 'asset' - asset name + # - 'task' - task name + # - 'variant' - variant + # - 'family' - instnace family + # Check if should use selection or not + if pre_create_data.get("use_selection"): + items = pipeline.get_selection() + else: + items = [pipeline.create_write()] + + # Validations related to selection + if len(items) > 1: + raise CreatorError("Please select only single item at time.") + + elif not items: + raise CreatorError("Nothing to create. Select at least one item.") + + # Create instence object + new_instance = CreatedInstance(self.family, subset_name, data, self) + # Pass value from pre create attribute to instance + # - use them only when pre create date contain the data + if "render_farm" in pre_create_data: + use_farm = pre_create_data["render_farm"] + new_instance.creator_attributes["render_farm"] = use_farm + + # Store metadata to workfile + pipeline.imprint(new_instance.id, new_instance.data_to_store()) + + # Add instance to context + self._add_instance_to_context(new_instance) +``` + +## Create context +Controller and wrapper around creation is `CreateContext` which cares about loading `CreatedInstance` + +# Publish +OpenPype is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. The main differences are that OpenPype's publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. + +## Exceptions +OpenPype define few specific exceptions that should be used in publish plugins. + +### Validation exception +Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. + +Exception `PublishValidationError` 3 arguments: +- **message** Which is not used in UI but for headless publishing. +- **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin. +- **description** Detailed description of happened issue where markdown and html can be used. + + +### Known errors +When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown. + +## Plugin extension +Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). + +```python +import pyblish.api +from openpype.pipeline import OpenPypePyblishPluginMixin + + +# Example context plugin +class MyExtendedPlugin( + pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin +): + pass + +``` + +### Extensions +Currently only extension is ability to define attributes for instances during creation. Method `get_attribute_defs` returns attribute definitions for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be implemented `convert_attribute_values`. Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure. + +Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. diff --git a/website/sidebars.js b/website/sidebars.js index 16af1e1151..fe76336e7e 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -136,6 +136,13 @@ module.exports = { "dev_requirements", "dev_build", "dev_testing", - "dev_contribute" + "dev_contribute", + { + type: "category", + label: "Hosts development", + items: [ + "dev_publishing" + ] + } ] }; From b70a22e4734acefc842792fbeff5dcd11c35025d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 14:40:00 +0100 Subject: [PATCH 082/854] added attributes as default return value for methods --- openpype/pipeline/create/creator_plugins.py | 57 ++++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 1ac2c420a2..cae55431a9 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -46,6 +46,11 @@ class BaseCreator: # - may not be used if `get_icon` is reimplemented icon = None + # Instance attribute definitions that can be changed per instance + # - returns list of attribute definitions from + # `openpype.pipeline.attribute_definitions` + instance_attr_defs = [] + def __init__( self, create_context, system_settings, project_settings, headless=False ): @@ -56,10 +61,13 @@ class BaseCreator: # - we may use UI inside processing this attribute should be checked self.headless = headless - @abstractproperty + @property def identifier(self): - """Identifier of creator (must be unique).""" - pass + """Identifier of creator (must be unique). + + Default implementation returns plugin's family. + """ + return self.family @abstractproperty def family(self): @@ -90,11 +98,39 @@ class BaseCreator: pass @abstractmethod - def collect_instances(self, attr_plugins=None): + def collect_instances(self): + """Collect existing instances related to this creator plugin. + + The implementation differs on host abilities. The creator has to + collect metadata about instance and create 'CreatedInstance' object + which should be added to 'CreateContext'. + + Example: + ```python + def collect_instances(self): + # Getting existing instances is different per host implementation + for instance_data in pipeline.list_instances(): + # Process only instances that were created by this creator + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + # Create instance object from existing data + instance = CreatedInstance.from_existing( + instance_data, self + ) + # Add instance to create context + self._add_instance_to_context(instance) + ``` + """ pass @abstractmethod def update_instances(self, update_list): + """Store changes of existing instances so they can be recollected. + + Args: + update_list(list): Gets list of tuples. Each item + contain changed instance and it's changes. + """ pass @abstractmethod @@ -178,7 +214,7 @@ class BaseCreator: list: Attribute definitions that can be tweaked for created instance. """ - return [] + return self.instance_attr_defs class Creator(BaseCreator): @@ -191,6 +227,9 @@ class Creator(BaseCreator): # - default_variants may not be used if `get_default_variants` is overriden default_variants = [] + # Default variant used in 'get_default_variant' + default_variant = None + # Short description of family # - may not be used if `get_description` is overriden description = None @@ -204,6 +243,10 @@ class Creator(BaseCreator): # e.g. for buld creators create_allow_context_change = True + # Precreate attribute definitions showed before creation + # - similar to instance attribute definitions + pre_create_attr_defs = [] + @abstractmethod def create(self, subset_name, instance_data, pre_create_data): """Create new instance and store it. @@ -263,7 +306,7 @@ class Creator(BaseCreator): `get_default_variants` should be used. """ - return None + return self.default_variant def get_pre_create_attr_defs(self): """Plugin attribute definitions needed for creation. @@ -276,7 +319,7 @@ class Creator(BaseCreator): list: Attribute definitions that can be tweaked for created instance. """ - return [] + return self.pre_create_attr_defs class AutoCreator(BaseCreator): From 19edc98d4909b4e855cdc8f2d9a8fc7f5a155c4c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 14:40:16 +0100 Subject: [PATCH 083/854] update list has named tuple 'UpdateData' --- openpype/pipeline/create/context.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index c2757a4502..15417a4ff8 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -18,6 +18,8 @@ from openpype.api import ( get_project_settings ) +UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) + class ImmutableKeyError(TypeError): """Accessed key is immutable so does not allow changes or removements.""" @@ -1080,7 +1082,7 @@ class CreateContext: for instance in cretor_instances: instance_changes = instance.changes() if instance_changes: - update_list.append((instance, instance_changes)) + update_list.append(UpdateData(instance, instance_changes)) creator = self.creators[identifier] if update_list: From bfce93027ccd5ebbb227b7af80ba8d73c77f3453 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Mar 2022 15:00:17 +0100 Subject: [PATCH 084/854] Update openpype/hosts/aftereffects/plugins/create/create_render.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/aftereffects/plugins/create/create_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 1a5a826137..550fb6b0ef 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -72,7 +72,7 @@ class RenderCreator(Creator): new_instance = CreatedInstance(self.family, subset_name, data, self) new_instance.creator_attributes["farm"] = pre_create_data["farm"] - api.get_stub().imprint(new_instance.get("instance_id"), + api.get_stub().imprint(new_instance.id, new_instance.data_to_store()) self._add_instance_to_context(new_instance) From d3441215749e303311370a41a9c82aa934b6cfb0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Mar 2022 15:00:33 +0100 Subject: [PATCH 085/854] Update openpype/hosts/aftereffects/plugins/create/create_render.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/aftereffects/plugins/create/create_render.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 550fb6b0ef..88462667ed 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -70,7 +70,9 @@ class RenderCreator(Creator): data["members"] = [items[0].id] new_instance = CreatedInstance(self.family, subset_name, data, self) - new_instance.creator_attributes["farm"] = pre_create_data["farm"] + if "farm" in pre_create_data: + use_farm = pre_create_data["farm"] + new_instance.creator_attributes["farm"] = use_farm api.get_stub().imprint(new_instance.id, new_instance.data_to_store()) From d4abe6ea8788111ba77ff7dab5b64ef59a63182f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 15:39:41 +0100 Subject: [PATCH 086/854] added more information about publishing --- website/docs/dev_publishing.md | 203 +++++++++++++++++++++++++++------ 1 file changed, 165 insertions(+), 38 deletions(-) diff --git a/website/docs/dev_publishing.md b/website/docs/dev_publishing.md index 0808818456..b16a42bb43 100644 --- a/website/docs/dev_publishing.md +++ b/website/docs/dev_publishing.md @@ -8,7 +8,7 @@ Publishing workflow consist of 2 parts: - Creation - Mark what will be published and how. - Publishing - Use data from creation to go through pyblish process. -OpenPype is using `pyblish` for publishing process. It is a little bit extented and modified mainly for UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during creation part instead of in publishing part and has limited actions only for failed validator plugins. +OpenPype is using [pyblish](https://pyblish.com/) for publishing process. OpenPype a little bit extend and modify few functions mainly for reports and UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during creation part instead of in publishing part and has limited plugin actions only for failed validation plugins. # Creation Concept of creation does not have to "create" anything but prepare and store metadata about an "instance". Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach. Storing data to workfile gives ability to keep values so artist does not have to do create instances over and over. @@ -30,7 +30,33 @@ Objected representation of created instance metadata defined by class **CreatedI | asset | str | Name of asset in which context was created. | | task | str | Name of task in which context was created. Can be set to `None`. | +:::note Task should not be required until subset name template expect it. +::: + +object of **CreatedInstance** has method **data_to_store** which returns dictionary that can be parsed to json string. This method will return all data related to instance so can be re-created using `CreatedInstance.from_existing(data)`. + +## Create context +Controller and wrapper around creation is `CreateContext` which cares about loading of plugins needed for creation. And validates required functions in host implementation. + +Context discovers creator and publish plugins. Trigger collections of existing instances on creators and trigger creation itself. Also keeps in mind instance objects by their ids. + +Creator plugins can call **creator_adds_instance** or **creator_removed_instance** to add/remove instance but these methods are not meant to be called directly out of creator. The reason is that is creator's responsibility to remove metadata or decide if should remove the instance. + +### Required functions in host implementation +Host implementation **must** have implemented **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instane but are needed for creation and publishing process. Right now are there stored data about enabled/disabled optional publish plugins. When data are not stored and loaded properly reset of publishing will cause that they will be set to default value. Similar to instance data can be context data also parsed to json string. + +There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return string showed in UI as a title. Output string may contain html tags. It is recommended to return context path (it will be created function this purposes) in this order `"{project name}/{asset hierarchy}/{asset name}/{task name}"`. + +Another optional function is **get_current_context**. This function is handy in hosts where is possible to open multiple workfiles in one process so using global context variables are not relevant because artist can switch between opened workfiles without being acknowledged. When function is not implemented or won't return right keys the global +```json +# Expected keys in output +{ + "project_name": "MyProject", + "asset_name": "sq01_sh0010", + "task_name": "Modeling" +} +``` ## Create plugin Main responsibility of create plugin is to create, update, collect and remove instance metadata and propagate changes to create context. Has access to **CreateContext** (`self.create_context`) that discovered the plugin so has also access to other creators and instances. @@ -134,16 +160,17 @@ class RenderLayerCreator(Creator): label = "Render Layer" ``` -- **`icon`** (attr) - Icon of creator and it's instances. Value can be a path to image file, full name of qtawesome icon, `QPixmap` or `QIcon`. For complex cases or cases when `Qt` objects are returned it is recommended to override `get_icon` method and handle the logic or import `Qt` inside the method to not break headless usage of creator plugin. For list of qtawesome icons check qtawesome github repository (look for used version in pyproject.toml). -- **`get_icon`** (method) - Default implementation returns `self.icon`. +- **`get_icon`** (attr) - Icon of creator and it's instances. Value can be a path to image file, full name of qtawesome icon, `QPixmap` or `QIcon`. For complex cases or cases when `Qt` objects are returned it is recommended to override `get_icon` method and handle the logic or import `Qt` inside the method to not break headless usage of creator plugin. For list of qtawesome icons check qtawesome github repository (look for used version in pyproject.toml). Default implementation return **icon** attribute. +- **`icon`** (method) - Attribute for default implementation of **get_icon**. ```python class RenderLayerCreator(Creator): # Use font awesome 5 icon icon = "fa5.building" ``` +- **`get_instance_attr_defs`** (method) - Attribute definitions of instance. Creator can define attribute values with default values for each instance. These attributes may affect how will be instance processed during publishing. Attribute defiitions can be used from `openpype.pipeline.lib.attribute_definitions` (NOTE: Will be moved to `openpype.lib.attribute_definitions` soon). Attribute definitions define basic type of values for different cases e.g. boolean, number, string, enumerator, etc. Default implementations returns **instance_attr_defs**. +- **`instance_attr_defs`** (attr) - Attribute for default implementation of **get_instance_attr_defs**. -- **`get_instance_attr_defs`** (method) - Attribute definitions of instance. Creator can define attribute values with default values for each instance. These attributes may affect how will be instance processed during publishing. Attribute defiitions can be used from `openpype.pipeline.lib.attribute_definitions` (NOTE: Will be moved to `openpype.lib.attribute_definitions` soon). Attribute definitions define basic type of values for different cases e.g. boolean, number, string, enumerator, etc. Their advantage is that they can be created dynamically and ```python from openpype.pipeline import attribute_definitions @@ -172,7 +199,7 @@ class RenderLayerCreator(Creator): Creator that is triggered on reset of create context. Can be used for families that are expected to be created automatically without artist interaction (e.g. **workfile**). Method `create` is triggered after collecting of all creators. :::important -**AutoCreator** has implemented **remove_instances** to do nothing as removing of auto created instances would in most of cases lead to create new instance immediately. +**AutoCreator** has implemented **remove_instances** to do nothing as removing of auto created instances would lead to create new instance immediately or on refresh. ::: ```python @@ -244,41 +271,53 @@ def create(self): ### Creator Implementation of creator plugin that is triggered manually by artist in UI (or by code). Has extended options for UI purposes than **AutoCreator** and **create** method expect more arguments. -- **`create_allow_context_change`** (class attr) - Allow to set context in UI before creation. Some creator may not allow it or their logic would not use the context selection (e.g. bulk creators). +**Abstractions** +- **`create`** (method) - Code where creation of metadata + +**Optional implementations** +- **`create_allow_context_change`** (class attr) - Allow to set context in UI before creation. Some creator may not allow it or their logic would not use the context selection (e.g. bulk creators). Is set to `True` but default. ```python class BulkRenderCreator(Creator): create_allow_context_change = False ``` -- **`get_default_variants`** (method) - Returns list of default variants that are showed in create dialog. Uses **default_variants** by default. +- **`get_default_variants`** (method) - Returns list of default variants that are listed in create dialog for user. Returns **default_variants** attribute by default. - **`default_variants`** (attr) - Attribute for default implementation of **get_default_variants**. -- **`get_default_variant`** (method) - Return default variant that is prefilled in UI. By default returns `None`, in that case first item from **get_default_variants** is used if there is any or **"Main"**. +- **`get_default_variant`** (method) - Returns default variant that is prefilled in UI (value does not have to be in default variants). By default returns **default_variant** attribute. If returns `None` then UI logic will take first item from **get_default_variants** if there is any otherwise **"Main"** is used. +- **`default_variant`** (attr) - Attribute for default implementation of **get_default_variant**. -- **`get_description`** (method) - Returns short string description of creator. Uses **description** by default. +- **`get_description`** (method) - Returns short string description of creator. Returns **description** attribute by default. - **`description`** (attr) - Attribute for default implementation of **get_description**. -- **`get_detailed_description`** (method) - Returns detailed string description of creator. Can contain markdown. Uses **detailed_description** by default. +- **`get_detailed_description`** (method) - Returns detailed string description of creator. Can contain markdown. Returns **detailed_description** attribute by default. - **`detailed_description`** (attr) - Attribute for default implementation of **get_detailed_description**. -- **`get_pre_create_attr_defs`** (method) - Similar to **get_instance_attr_defs** returns attribute definitions but they are filled before creation. When creation is called from UI the values are passed to **create** method. - -- **`create`** (method) - Code where creation of metadata +- **`get_pre_create_attr_defs`** (method) - Similar to **get_instance_attr_defs** returns attribute definitions but they are filled before creation. When creation is called from UI the values are passed to **create** method. Returns **pre_create_attr_defs** attribute by default. +- **`pre_create_attr_defs`** (attr) - Attribute for default implementation of **get_pre_create_attr_defs**. ```python -from openpype.pipeline import attribute_definitions +from openpype.pipeline import Creator, attribute_definitions -class RenderLayerCreator(Creator): +class CreateRender(Creator): + family = "render" + label = "Render" + icon = "fa.eye" + description = "Render scene viewport" + def __init__( self, context, system_settings, project_settings, *args, **kwargs ): - super(RenderLayerCreator, self).__init__( + super(CreateRender, self).__init__( context, system_settings, project_settings, *args, **kwargs ) plugin_settings = ( project_settings["my_host"]["create"][self.__class__.__name__] ) + # Get information if studio has enabled farm publishing self._allow_farm_render = plugin_settings["allow_farm_render"] + # Get default variants from settings + self.default_variants = plugin_settings["variants"] def get_instance_attr_defs(self): # Return empty list if '_allow_farm_render' is not enabled (can be set during initialization) @@ -294,22 +333,26 @@ class RenderLayerCreator(Creator): ] def get_pre_create_attr_defs(self): - return [ - # Give user option to use selection or not + # Give user option to use selection or not + attrs = [ attribute_definitions.BoolDef( "use_selection", default=False, label="Use selection" - ), + ) + ] + if self._allow_farm_render: # Set to render on farm in creator dialog # - this value is not automatically passed to instance attributes # creator must do that during creation - attribute_definitions.BoolDef( - "render_farm", - default=False, - label="Render on Farm" + attrs.append( + attribute_definitions.BoolDef( + "render_farm", + default=False, + label="Render on Farm" + ) ) - ] + return attrs def create(self, subset_name, instance_data, pre_create_data): # ARGS: @@ -319,6 +362,7 @@ class RenderLayerCreator(Creator): # - 'task' - task name # - 'variant' - variant # - 'family' - instnace family + # Check if should use selection or not if pre_create_data.get("use_selection"): items = pipeline.get_selection() @@ -347,44 +391,127 @@ class RenderLayerCreator(Creator): self._add_instance_to_context(new_instance) ``` -## Create context -Controller and wrapper around creation is `CreateContext` which cares about loading `CreatedInstance` - # Publish -OpenPype is using `pyblish` for publishing process which is a little bit extented and modified mainly for UI purposes. The main differences are that OpenPype's publish UI does not allow to enable/disable instances or plugins that can be done during creation part. Also does support actions only for validators after validation exception. - ## Exceptions OpenPype define few specific exceptions that should be used in publish plugins. ### Validation exception Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. -Exception `PublishValidationError` 3 arguments: +Exception `PublishValidationError` expects 4 arguments: - **message** Which is not used in UI but for headless publishing. - **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin. - **description** Detailed description of happened issue where markdown and html can be used. +- **detail** Is optional to give even more detailed information for advanced users. At this moment is detail showed under description but it is in plan to have detail in collapsible widget. +Extended version is `PublishXmlValidationError` which uses xml files with stored descriptions. This helps to avoid having huge markdown texts inside code. The exception has 4 arguments: +- **plugin** The plugin object which raises the exception to find it's related xml file. +- **message** Exception message for publishing without UI or different pyblish UI. +- **key** Optional argument says which error from xml is used as validation plugin may raise error with different messages based on the current errors. Default is **"main"**. +- **formatting_data** Optional dictionary to format data in the error. This is used to fill detailed description with data from the publishing so artist can get more precise information. + +**Where and how to create xml file** + +Xml files for `PublishXmlValidationError` must be located in **./help** subfolder next to plugin and the filename must match the filename of plugin. +``` +# File location related to plugin file +β”” publish + β”œ help + β”‚ β”œ validate_scene.xml + β”‚ β”” ... + β”œ validate_scene.py + β”” ... +``` + +Xml file content has **<root>** node which may contain any amount of **<error>** nodes, but each of them must have **id** attribute with unique value. That is then used for **key**. Each error must have **<title>** and **<description>** and **<detail>**. Text content may contain python formatting keys that can be filled when exception is raised. +```xml + + + + Subset context + ## Invalid subset context + +Context of the given subset doesn't match your current scene. + +### How to repair? + +Yout can fix this with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata. + +After that restart publishing with Reload button. + + +### How could this happen? + +The subset was created in different scene with different context +or the scene file was copy pasted from different context. + + + +``` ### Known errors When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown. ## Plugin extension -Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). +Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). Publish plugins that inherit from this mixin can define attributes that will be shown in **CreatedInstance**. One of most important usages is to be able turn on/off optional plugins. + +Attributes are defined by return value of `get_attribute_defs` method. Attribute definitions are for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be re-implemented `convert_attribute_values`. Default implementation just converts the values to right types. + +:::important +Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure. +::: + +Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. ```python import pyblish.api -from openpype.pipeline import OpenPypePyblishPluginMixin +from openpype.pipeline import ( + OpenPypePyblishPluginMixin, + attribute_definitions, +) # Example context plugin class MyExtendedPlugin( pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin ): - pass + optional = True + active = True + @classmethod + def get_attribute_defs(cls): + return [ + attribute_definitions.BoolDef( + # Key under which it will be stored + "process", + # Use 'active' as default value + default=cls.active, + # Use plugin label as label for attribute + label=cls.label + ) + ] + + def process_plugin(self, context): + # First check if plugin is optional + if not self.optional: + return True + + # Get 'process' key + process_value = ( + context.data + .get("publish_attributes", {}) + # Attribute values are stored by class names + .get(self.__class__.__name__, {}) + # Access the key + .get("process") + ) + if process_value or process_value is None: + return True + return False + + def process(self, context): + if not self.process_plugin(context): + return + # Do plugin logic + ... ``` - -### Extensions -Currently only extension is ability to define attributes for instances during creation. Method `get_attribute_defs` returns attribute definitions for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be implemented `convert_attribute_values`. Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure. - -Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. From bf08a0241991ac97ad1544bebe1bad5efe82754e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 15:53:31 +0100 Subject: [PATCH 087/854] few minor changes --- website/docs/dev_publishing.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/docs/dev_publishing.md b/website/docs/dev_publishing.md index b16a42bb43..58f271641d 100644 --- a/website/docs/dev_publishing.md +++ b/website/docs/dev_publishing.md @@ -11,10 +11,10 @@ Publishing workflow consist of 2 parts: OpenPype is using [pyblish](https://pyblish.com/) for publishing process. OpenPype a little bit extend and modify few functions mainly for reports and UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during creation part instead of in publishing part and has limited plugin actions only for failed validation plugins. # Creation -Concept of creation does not have to "create" anything but prepare and store metadata about an "instance". Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach. Storing data to workfile gives ability to keep values so artist does not have to do create instances over and over. +Concept of creation does not have to "create" anything but prepare and store metadata about an "instance" (becomes a subset after publish process). Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach that is the reason why it is creator plugin responsibility. Storing the metadata to workfile gives ability to keep values so artist does not have to do create and set what should be published and how over and over. ## Created instance -Objected representation of created instance metadata defined by class **CreatedInstance**. Has access to **CreateContext** and **BaseCreator** that initialized the object. Is dictionary like object with few immutable keys (maked with start `*`) that are defined by creator plugin or create context on initialization. Can have more arbitrary data but keep in mind that some keys are reserved. +Objected representation of created instance metadata defined by class **CreatedInstance**. Has access to **CreateContext** and **BaseCreator** that initialized the object. Is dictionary like object with few immutable keys (marked with start `*` in table). The immutable keys are set by creator plugin or create context on initialization and thei values can't change. Instance can have more arbitrary data, for example ids of nodes in scene but keep in mind that some keys are reserved. | Key | Type | Description | |---|---|---| @@ -25,7 +25,7 @@ Objected representation of created instance metadata defined by class **CreatedI | *creator_attributes | dict | Dictionary of attributes that are defined by creator plugin (`get_instance_attr_defs`). | | *publish_attributes | dict | Dictionary of attributes that are defined by publish plugins. | | variant | str | Variant is entered by artist on creation and may affect **subset**. | -| subset | str | Name of instance. This name will be used as subset name during publishing. | +| subset | str | Name of instance. This name will be used as subset name during publishing. Can be changed on context change or variant change. | | active | bool | Is instance active and will be published or not. | | asset | str | Name of asset in which context was created. | | task | str | Name of task in which context was created. Can be set to `None`. | @@ -48,7 +48,7 @@ Host implementation **must** have implemented **get_context_data** and **update_ There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return string showed in UI as a title. Output string may contain html tags. It is recommended to return context path (it will be created function this purposes) in this order `"{project name}/{asset hierarchy}/{asset name}/{task name}"`. -Another optional function is **get_current_context**. This function is handy in hosts where is possible to open multiple workfiles in one process so using global context variables are not relevant because artist can switch between opened workfiles without being acknowledged. When function is not implemented or won't return right keys the global +Another optional function is **get_current_context**. This function is handy in hosts where is possible to open multiple workfiles in one process so using global context variables is not relevant because artist can switch between opened workfiles without being acknowledged. When function is not implemented or won't return right keys the global context is used. ```json # Expected keys in output { @@ -59,7 +59,7 @@ Another optional function is **get_current_context**. This function is handy in ``` ## Create plugin -Main responsibility of create plugin is to create, update, collect and remove instance metadata and propagate changes to create context. Has access to **CreateContext** (`self.create_context`) that discovered the plugin so has also access to other creators and instances. +Main responsibility of create plugin is to create, update, collect and remove instance metadata and propagate changes to create context. Has access to **CreateContext** (`self.create_context`) that discovered the plugin so has also access to other creators and instances. Create plugins have a lot of responsibility so it is recommended to implement common code per host. ### BaseCreator Base implementation of creator plugin. It is not recommended to use this class as base for production plugins but rather use one of **AutoCreator** and **Creator** variants. From 0b9febaf3e215f45ecfba9c2a053db2cb9053ae4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 16 Mar 2022 16:05:17 +0100 Subject: [PATCH 088/854] changed sidebar label --- website/sidebars.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/sidebars.js b/website/sidebars.js index fe76336e7e..105afc30eb 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -139,7 +139,7 @@ module.exports = { "dev_contribute", { type: "category", - label: "Hosts development", + label: "Hosts integrations", items: [ "dev_publishing" ] From bff1b77c0635493c3236f663c7a444eaf2d350e4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Mar 2022 16:17:46 +0100 Subject: [PATCH 089/854] OP-2766 - changed format of layer metadata Removing uuid, replaced with members[0] and instance_id. Layers metadata now returned as a list, not dictionary to follow AE implementation. --- openpype/hosts/photoshop/api/pipeline.py | 3 +- openpype/hosts/photoshop/api/ws_stub.py | 60 ++++++++++++------------ 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 0e3f1215aa..8d64942c9e 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -144,9 +144,8 @@ def list_instances(): instances = [] layers_meta = stub.get_layers_metadata() if layers_meta: - for key, instance in layers_meta.items(): + for instance in layers_meta: if instance.get("id") == "pyblish.avalon.instance": # TODO only this way? - instance['uuid'] = key instances.append(instance) return instances diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index a99f184080..dd29ef4e84 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -81,7 +81,11 @@ class PhotoshopServerStub: if layers_meta is None: layers_meta = self.get_layers_metadata() - return layers_meta.get(str(layer.id)) + for layer_meta in layers_meta: + if layer_meta.get("members"): + if layer.id == layer_meta["members"][0]: + return layer + print("Unable to find layer metadata for {}".format(layer.id)) def imprint(self, item_id, data, all_layers=None, items_meta=None): """Save layer metadata to Headline field of active document @@ -125,13 +129,21 @@ class PhotoshopServerStub: # json.dumps writes integer values in a dictionary to string, so # anticipating it here. item_id = str(item_id) - if item_id in items_meta.keys(): - if data: - items_meta[item_id].update(data) + is_new = True + result_meta = [] + for item_meta in items_meta: + if ((item_meta.get('members') and + item_id == str(item_meta.get('members')[0])) or + item_meta.get("instance_id") == item_id): + is_new = False + if data: + item_meta.update(data) + result_meta.append(item_meta) else: - items_meta.pop(item_id) - else: - items_meta[item_id] = data + result_meta.append(item_meta) + + if is_new: + result_meta.append(data) # Ensure only valid ids are stored. if not all_layers: @@ -139,7 +151,7 @@ class PhotoshopServerStub: layer_ids = [layer.id for layer in all_layers] cleaned_data = [] - for item in items_meta.values(): + for item in result_meta: if item.get("members"): if int(item["members"][0]) not in layer_ids: continue @@ -374,38 +386,27 @@ class PhotoshopServerStub: (Headline accessible by File > File Info) Returns: - (string): - json documents + (list) example: {"8":{"active":true,"subset":"imageBG", "family":"image","id":"pyblish.avalon.instance", "asset":"Town"}} 8 is layer(group) id - used for deletion, update etc. """ - layers_data = {} res = self.websocketserver.call(self.client.call('Photoshop.read')) + layers_data = [] try: - layers_data = json.loads(res) + if res: + layers_data = json.loads(res) except json.decoder.JSONDecodeError: pass # format of metadata changed from {} to [] because of standardization # keep current implementation logic as its working - if not isinstance(layers_data, dict): - temp_layers_meta = {} - for layer_meta in layers_data: - layer_id = layer_meta.get("uuid") - if not layer_id: - layer_id = layer_meta.get("members")[0] - - temp_layers_meta[layer_id] = layer_meta - layers_data = temp_layers_meta - else: - # legacy version of metadata + if isinstance(layers_data, dict): for layer_id, layer_meta in layers_data.items(): if layer_meta.get("schema") != "openpype:container-2.0": - layer_meta["uuid"] = str(layer_id) - else: layer_meta["members"] = [str(layer_id)] - + layers_data = list(layers_data.values()) return layers_data def import_smart_object(self, path, layer_name, as_reference=False): @@ -476,11 +477,12 @@ class PhotoshopServerStub: ) def remove_instance(self, instance_id): - cleaned_data = {} + cleaned_data = [] - for key, instance in self.get_layers_metadata().items(): - if key != instance_id: - cleaned_data[key] = instance + for item in self.get_layers_metadata(): + inst_id = item.get("instance_id") or item.get("uuid") + if inst_id != instance_id: + cleaned_data.append(inst_id) payload = json.dumps(cleaned_data, indent=4) From c46b41804d108cc976aae64410ce520ac3117dda Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Mar 2022 16:18:07 +0100 Subject: [PATCH 090/854] OP-2766 - implemented new image Creator Working implementation of New Publisher (not full backward compatibility yet). --- openpype/hosts/photoshop/api/__init__.py | 8 +- .../photoshop/plugins/create/create_image.py | 156 ++++++++++++------ .../plugins/create/create_legacy_image.py | 2 +- .../plugins/create/workfile_creator.py | 2 + .../plugins/publish/collect_instances.py | 4 + .../plugins/publish/collect_workfile.py | 30 ++-- .../plugins/publish/extract_image.py | 9 +- 7 files changed, 148 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/photoshop/api/__init__.py b/openpype/hosts/photoshop/api/__init__.py index 17ea957066..94152b5706 100644 --- a/openpype/hosts/photoshop/api/__init__.py +++ b/openpype/hosts/photoshop/api/__init__.py @@ -12,7 +12,10 @@ from .pipeline import ( remove_instance, install, uninstall, - containerise + containerise, + get_context_data, + update_context_data, + get_context_title ) from .plugin import ( PhotoshopLoader, @@ -43,6 +46,9 @@ __all__ = [ "install", "uninstall", "containerise", + "get_context_data", + "update_context_data", + "get_context_title", # Plugin "PhotoshopLoader", diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index a001b5f171..a73b79e0fd 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,46 +1,50 @@ -from Qt import QtWidgets -from openpype.pipeline import create -from openpype.hosts.photoshop import api as photoshop +from avalon import api as avalon_api +from openpype.hosts.photoshop import api +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib, + CreatorError +) -class CreateImage(create.LegacyCreator): - """Image folder for publish.""" - - name = "imageDefault" +class ImageCreator(Creator): + """Creates image instance for publishing.""" + identifier = "image" label = "Image" family = "image" - defaults = ["Main"] + description = "Image creator" - def process(self): + def collect_instances(self): + import json + self.log.info("ImageCreator: api.list_instances():: {}".format( + json.dumps(api.list_instances(), indent=4))) + for instance_data in api.list_instances(): + # legacy instances have family=='image' + creator_id = (instance_data.get("creator_identifier") or + instance_data.get("family")) + + self.log.info("ImageCreator: instance_data:: {}".format(json.dumps(instance_data, indent=4))) + if creator_id == self.identifier: + instance_data = self._handle_legacy(instance_data) + + layer = api.stub().get_layer(instance_data["members"][0]) + instance_data["layer"] = layer + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def create(self, subset_name, data, pre_create_data): groups = [] layers = [] create_group = False - stub = photoshop.stub() - if (self.options or {}).get("useSelection"): - multiple_instances = False - selection = stub.get_selected_layers() - self.log.info("selection {}".format(selection)) + stub = api.stub() # only after PS is up + multiple_instances = pre_create_data.get("create_multiple") + selection = stub.get_selected_layers() + if pre_create_data.get("use_selection"): if len(selection) > 1: - # Ask user whether to create one image or image per selected - # item. - msg_box = QtWidgets.QMessageBox() - msg_box.setIcon(QtWidgets.QMessageBox.Warning) - msg_box.setText( - "Multiple layers selected." - "\nDo you want to make one image per layer?" - ) - msg_box.setStandardButtons( - QtWidgets.QMessageBox.Yes | - QtWidgets.QMessageBox.No | - QtWidgets.QMessageBox.Cancel - ) - ret = msg_box.exec_() - if ret == QtWidgets.QMessageBox.Yes: - multiple_instances = True - elif ret == QtWidgets.QMessageBox.Cancel: - return - if multiple_instances: for item in selection: if item.group: @@ -48,25 +52,25 @@ class CreateImage(create.LegacyCreator): else: layers.append(item) else: - group = stub.group_selected_layers(self.name) + group = stub.group_selected_layers(subset_name) groups.append(group) - elif len(selection) == 1: # One selected item. Use group if its a LayerSet (group), else # create a new group. - if selection[0].group: - groups.append(selection[0]) + selected_item = selection[0] + if selected_item.group: + groups.append(selected_item) else: - layers.append(selection[0]) + layers.append(selected_item) elif len(selection) == 0: # No selection creates an empty group. create_group = True else: - group = stub.create_group(self.name) + group = stub.create_group(subset_name) groups.append(group) if create_group: - group = stub.create_group(self.name) + group = stub.create_group(subset_name) groups.append(group) for layer in layers: @@ -74,26 +78,78 @@ class CreateImage(create.LegacyCreator): group = stub.group_selected_layers(layer.name) groups.append(group) - creator_subset_name = self.data["subset"] for group in groups: long_names = [] - group.name = group.name.replace(stub.PUBLISH_ICON, ''). \ - replace(stub.LOADED_ICON, '') + group.name = self._clean_highlights(stub, group.name) - subset_name = creator_subset_name if len(groups) > 1: subset_name += group.name.title().replace(" ", "") if group.long_name: for directory in group.long_name[::-1]: - name = directory.replace(stub.PUBLISH_ICON, '').\ - replace(stub.LOADED_ICON, '') + name = self._clean_highlights(stub, directory) long_names.append(name) - self.data.update({"subset": subset_name}) - self.data.update({"uuid": str(group.id)}) - self.data.update({"long_name": "_".join(long_names)}) - stub.imprint(group, self.data) + data.update({"subset": subset_name}) + data.update({"layer": group}) + data.update({"members": [str(group.id)]}) + data.update({"long_name": "_".join(long_names)}) + + new_instance = CreatedInstance(self.family, subset_name, data, + self) + + stub.imprint(new_instance.get("instance_id"), + new_instance.data_to_store()) + self._add_instance_to_context(new_instance) # reusing existing group, need to rename afterwards if not create_group: stub.rename_layer(group.id, stub.PUBLISH_ICON + group.name) + + def update_instances(self, update_list): + self.log.info("update_list:: {}".format(update_list)) + created_inst, changes = update_list[0] + api.stub().imprint(created_inst.get("instance_id"), + created_inst.data_to_store()) + + def remove_instances(self, instances): + for instance in instances: + api.remove_instance(instance) + self._remove_instance_from_context(instance) + + def get_default_variants(self): + return [ + "Main" + ] + + def get_pre_create_attr_defs(self): + output = [ + lib.BoolDef("use_selection", default=True, label="Use selection"), + lib.BoolDef("create_multiple", + default=True, + label="Create separate instance for each selected") + ] + return output + + def get_detail_description(self): + return """Creator for Image instances""" + + def _handle_legacy(self, instance_data): + """Converts old instances to new format.""" + if not instance_data.get("members"): + instance_data["members"] = [instance_data.get("uuid")] + + if instance_data.get("uuid"): + # uuid not needed, replaced with unique instance_id + api.stub().remove_instance(instance_data.get("uuid")) + instance_data.pop("uuid") + + if not instance_data.get("task"): + instance_data["task"] = avalon_api.Session.get("AVALON_TASK") + + return instance_data + + def _clean_highlights(self, stub, item): + return item.replace(stub.PUBLISH_ICON, '').replace(stub.LOADED_ICON, + '') + + diff --git a/openpype/hosts/photoshop/plugins/create/create_legacy_image.py b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py index a001b5f171..6fa455fa03 100644 --- a/openpype/hosts/photoshop/plugins/create/create_legacy_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py @@ -91,7 +91,7 @@ class CreateImage(create.LegacyCreator): long_names.append(name) self.data.update({"subset": subset_name}) - self.data.update({"uuid": str(group.id)}) + self.data.update({"members": [str(group.id)]}) self.data.update({"long_name": "_".join(long_names)}) stub.imprint(group, self.data) # reusing existing group, need to rename afterwards diff --git a/openpype/hosts/photoshop/plugins/create/workfile_creator.py b/openpype/hosts/photoshop/plugins/create/workfile_creator.py index d66a05cad7..2a2fda3cc4 100644 --- a/openpype/hosts/photoshop/plugins/create/workfile_creator.py +++ b/openpype/hosts/photoshop/plugins/create/workfile_creator.py @@ -15,6 +15,7 @@ class PSWorkfileCreator(AutoCreator): return [] def collect_instances(self): + print("coll::{}".format(api.list_instances())) for instance_data in api.list_instances(): creator_id = instance_data.get("creator_identifier") if creator_id == self.identifier: @@ -29,6 +30,7 @@ class PSWorkfileCreator(AutoCreator): pass def create(self, options=None): + print("create") existing_instance = None for instance in self.create_context.instances: if instance.family == self.family: diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index c3e27e9646..ee402dcabf 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -21,6 +21,10 @@ class CollectInstances(pyblish.api.ContextPlugin): } def process(self, context): + if context.data.get("newPublishing"): + self.log.debug("Not applicable for New Publisher, skip") + return + stub = photoshop.stub() layers = stub.get_layers() layers_meta = stub.get_layers_metadata() diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index db1ede14d5..bdbd379a33 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -10,6 +10,13 @@ class CollectWorkfile(pyblish.api.ContextPlugin): hosts = ["photoshop"] def process(self, context): + existing_instance = None + for instance in context: + if instance.data["family"] == "workfile": + self.log.debug("Workfile instance found, won't create new") + existing_instance = instance + break + family = "workfile" task = os.getenv("AVALON_TASK", None) subset = family + task.capitalize() @@ -19,16 +26,19 @@ class CollectWorkfile(pyblish.api.ContextPlugin): base_name = os.path.basename(file_path) # Create instance - instance = context.create_instance(subset) - instance.data.update({ - "subset": subset, - "label": base_name, - "name": base_name, - "family": family, - "families": [], - "representations": [], - "asset": os.environ["AVALON_ASSET"] - }) + if existing_instance is None: + instance = context.create_instance(subset) + instance.data.update({ + "subset": subset, + "label": base_name, + "name": base_name, + "family": family, + "families": [], + "representations": [], + "asset": os.environ["AVALON_ASSET"] + }) + else: + instance = existing_instance # creating representation _, ext = os.path.splitext(file_path) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 04ce77ee34..d27c5bc028 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -16,7 +16,8 @@ class ExtractImage(openpype.api.Extractor): formats = ["png", "jpg"] def process(self, instance): - + print("PPPPPP") + self.log.info("fdfdsfdfs") staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) @@ -26,7 +27,13 @@ class ExtractImage(openpype.api.Extractor): with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): + self.log.info("instance.data:: {}".format(instance.data)) + print("instance.data::: {}".format(instance.data)) layer = instance.data.get("layer") + self.log.info("layer:: {}".format(layer)) + print("layer::: {}".format(layer)) + if not layer: + return ids = set([layer.id]) add_ids = instance.data.pop("ids", None) if add_ids: From a5ac3ab55b2c67604ef8e2530c57bdf242e6c599 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Mar 2022 16:21:29 +0100 Subject: [PATCH 091/854] OP-2766 - implemented new context methods --- openpype/hosts/photoshop/api/pipeline.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 8d64942c9e..0a99d1779d 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -236,14 +236,21 @@ def containerise( def get_context_data(): - pass + """Get stored values for context (validation enable/disable etc)""" + meta = _get_stub().get_layers_metadata() + for item in meta: + if item.get("id") == "publish_context": + item.pop("id") + return item + + return {} def update_context_data(data, changes): - # item = data - # item["id"] = "publish_context" - # _get_stub().imprint(item["id"], item) - pass + """Store value needed for context""" + item = data + item["id"] = "publish_context" + _get_stub().imprint(item["id"], item) def get_context_title(): From c120135f1fa467587a1b41f0e3c6282a2285b200 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 16 Mar 2022 21:08:35 +0100 Subject: [PATCH 092/854] Removed submodule openpype/modules/default_modules/ftrack/python2_vendor/arrow --- openpype/modules/default_modules/ftrack/python2_vendor/arrow | 1 - 1 file changed, 1 deletion(-) delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/arrow diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/arrow b/openpype/modules/default_modules/ftrack/python2_vendor/arrow deleted file mode 160000 index b746fedf72..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/arrow +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 From d1a733cf885e8955746ef6e71db01b76fe7c96be Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 16 Mar 2022 21:08:43 +0100 Subject: [PATCH 093/854] Removed submodule openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api --- .../default_modules/ftrack/python2_vendor/ftrack-python-api | 1 - 1 file changed, 1 deletion(-) delete mode 160000 openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api diff --git a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api deleted file mode 160000 index d277f474ab..0000000000 --- a/openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e From fdb9f0da77f1a996be4160f52acdc1036238bc39 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 16 Mar 2022 21:08:50 +0100 Subject: [PATCH 094/854] Removed submodule repos/avalon-unreal-integration --- repos/avalon-unreal-integration | 1 - 1 file changed, 1 deletion(-) delete mode 160000 repos/avalon-unreal-integration 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 99288ee03aa06613734c7feac627281bb94cb938 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 17 Mar 2022 17:29:54 +0900 Subject: [PATCH 095/854] added multiverse usd composition extractor --- .../publish/extract_multiverse_usd_comp.py | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py new file mode 100644 index 0000000000..f35096e516 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -0,0 +1,129 @@ +import os + +import avalon.maya +import openpype.api + +from maya import cmds + + +class ExtractMultiverseUsdComposition(openpype.api.Extractor): + """Extractor of Multiverse USD Composition.""" + + label = "Extract Multiverse USD Composition" + hosts = ["maya"] + families = ["usdComposition"] + + @property + def options(self): + """Overridable options for Multiverse USD Export + + Given in the following format + - {NAME: EXPECTED TYPE} + + If the overridden option's type does not match, + the option is not included and a warning is logged. + + """ + + return { + "stripNamespaces": bool, + "mergeTransformAndShape": bool, + "flattenContent": bool, + "writePendingOverrides": bool, + "writeTimeRange": bool, + "timeRangeStart": int, + "timeRangeEnd": int, + "timeRangeIncrement": int, + "timeRangeNumTimeSamples": int, + "timeRangeSamplesSpan": float, + "timeRangeFramesPerSecond": float + } + + @property + def default_options(self): + """The default options for Multiverse USD extraction.""" + start_frame = int(cmds.playbackOptions(query=True, + animationStartTime=True)) + end_frame = int(cmds.playbackOptions(query=True, + animationEndTime=True)) + + return { + "stripNamespaces": False, + "mergeTransformAndShape": False, + "flattenContent": False, + "writePendingOverrides": False, + "writeTimeRange": True, + "timeRangeStart": start_frame, + "timeRangeEnd": end_frame, + "timeRangeIncrement": 1, + "timeRangeNumTimeSamples": 0, + "timeRangeSamplesSpan": 0.0, + "timeRangeFramesPerSecond": 24.0 + } + + def process(self, instance): + # Load plugin firstly + cmds.loadPlugin("MultiverseForMaya", quiet=True) + + # Define output file path + staging_dir = self.staging_dir(instance) + file_name = "{}.usda".format(instance.name) + file_path = os.path.join(staging_dir, file_name) + file_path = file_path.replace('\\', '/') + + # Parse export options + options = self.default_options + self.log.info("Export options: {0}".format(options)) + + # Perform extraction + self.log.info("Performing extraction ...") + + with avalon.maya.maintained_selection(): + members = instance.data("setMembers") + members = cmds.ls(members, + dag=True, + shapes=True, + type=("mvUsdCompoundShape"), + noIntermediate=True, + long=True) + self.log.info('Collected object {}'.format(members)) + + # TODO: Deal with asset, composition, overide with options. + import multiverse + + time_opts = None + if options["writeTimeRange"]: + time_opts = multiverse.TimeOptions() + + time_opts.writeTimeRange = True + + time_range_start = options["timeRangeStart"] + time_range_end = options["timeRangeEnd"] + time_opts.frameRange = (time_range_start, time_range_end) + + time_opts.frameIncrement = options["timeRangeIncrement"] + time_opts.numTimeSamples = options["timeRangeNumTimeSamples"] + time_opts.timeSamplesSpan = options["timeRangeSamplesSpan"] + time_opts.framePerSecond = options["timeRangeFramesPerSecond"] + + comp_write_opts = multiverse.CompositionWriteOptions() + for (k, v) in options.iteritems(): + if k == "writeTimeRange" or k.startswith("timeRange"): + continue + setattr(comp_write_opts, k, v) + comp_write_opts.timeOptions = time_opts + multiverse.WriteComposition(file_path, members, comp_write_opts) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'usda', + 'ext': 'usda', + 'files': file_name, + "stagingDir": staging_dir + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance {} to {}".format( + instance.name, file_path)) From df5fdcc54c6ff125d307036b26a07572671047c9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Mar 2022 10:45:54 +0100 Subject: [PATCH 096/854] OP-2766 - do not store PSItem in metadata PSItem is not serializable --- openpype/hosts/photoshop/plugins/create/create_image.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index a73b79e0fd..4fc9a86635 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -108,6 +108,7 @@ class ImageCreator(Creator): def update_instances(self, update_list): self.log.info("update_list:: {}".format(update_list)) created_inst, changes = update_list[0] + created_inst.pop("layer") # not storing PSItem layer to metadata api.stub().imprint(created_inst.get("instance_id"), created_inst.data_to_store()) From c422176553ff27cff8d5113958fadf0dc4ddf12e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Mar 2022 11:29:41 +0100 Subject: [PATCH 097/854] OP-2766 - removed hardcoded ftrack, CollectFtrackFamily should be used Added defaults for Ftrack Settings. --- .../plugins/publish/collect_review.py | 25 +++++++++++++------ .../defaults/project_settings/ftrack.json | 12 +++++++++ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 5ab48b76da..4b6f855a6a 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -1,12 +1,24 @@ +""" +Requires: + None + +Provides: + instance -> family ("review") +""" + import os import pyblish.api class CollectReview(pyblish.api.ContextPlugin): - """Gather the active document as review instance.""" + """Gather the active document as review instance. - label = "Review" + Triggers once even if no 'image' is published as by defaults it creates + flatten image from a workfile. + """ + + label = "Collect Review" order = pyblish.api.CollectorOrder hosts = ["photoshop"] @@ -15,16 +27,13 @@ class CollectReview(pyblish.api.ContextPlugin): task = os.getenv("AVALON_TASK", None) subset = family + task.capitalize() - file_path = context.data["currentFile"] - base_name = os.path.basename(file_path) - instance = context.create_instance(subset) instance.data.update({ "subset": subset, - "label": base_name, - "name": base_name, + "label": subset, + "name": subset, "family": family, - "families": ["ftrack"], + "families": [], "representations": [], "asset": os.environ["AVALON_ASSET"] }) diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 01831efad1..015413e64f 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -344,6 +344,18 @@ "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] + }, + { + "hosts": [ + "photoshop" + ], + "families": [ + "review" + ], + "task_types": [], + "tasks": [], + "add_ftrack_family": true, + "advanced_filtering": [] } ] }, From 5da139c957c79c5a51b1cff8c1973bf554f72853 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Thu, 17 Mar 2022 11:53:57 +0100 Subject: [PATCH 098/854] minor styling tweaks --- website/docs/dev_publishing.md | 74 +++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 33 deletions(-) diff --git a/website/docs/dev_publishing.md b/website/docs/dev_publishing.md index 58f271641d..49e720a7e0 100644 --- a/website/docs/dev_publishing.md +++ b/website/docs/dev_publishing.md @@ -2,18 +2,21 @@ id: dev_publishing title: Publishing sidebar_label: Publishing +toc_max_heading_level: 4 --- Publishing workflow consist of 2 parts: -- Creation - Mark what will be published and how. -- Publishing - Use data from creation to go through pyblish process. +- Creating - Mark what will be published and how. +- Publishing - Use data from Creating to go through pyblish process. -OpenPype is using [pyblish](https://pyblish.com/) for publishing process. OpenPype a little bit extend and modify few functions mainly for reports and UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during creation part instead of in publishing part and has limited plugin actions only for failed validation plugins. +OpenPype is using [pyblish](https://pyblish.com/) for publishing process. OpenPype a little bit extend and modify few functions mainly for reports and UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during Creating part instead of in publishing part and has limited plugin actions only for failed validation plugins. -# Creation -Concept of creation does not have to "create" anything but prepare and store metadata about an "instance" (becomes a subset after publish process). Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach that is the reason why it is creator plugin responsibility. Storing the metadata to workfile gives ability to keep values so artist does not have to do create and set what should be published and how over and over. +## **Creating** + +Concept of Creating does not have to "create" anything but prepare and store metadata about an "instance" (becomes a subset after publish process). Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach that is the reason why it is creator plugin responsibility. Storing the metadata to workfile gives ability to keep values so artist does not have to do create and set what should be published and how over and over. + +### Created instance -## Created instance Objected representation of created instance metadata defined by class **CreatedInstance**. Has access to **CreateContext** and **BaseCreator** that initialized the object. Is dictionary like object with few immutable keys (marked with start `*` in table). The immutable keys are set by creator plugin or create context on initialization and thei values can't change. Instance can have more arbitrary data, for example ids of nodes in scene but keep in mind that some keys are reserved. | Key | Type | Description | @@ -36,15 +39,16 @@ Task should not be required until subset name template expect it. object of **CreatedInstance** has method **data_to_store** which returns dictionary that can be parsed to json string. This method will return all data related to instance so can be re-created using `CreatedInstance.from_existing(data)`. -## Create context -Controller and wrapper around creation is `CreateContext` which cares about loading of plugins needed for creation. And validates required functions in host implementation. +#### *Create context* {#category-doc-link} -Context discovers creator and publish plugins. Trigger collections of existing instances on creators and trigger creation itself. Also keeps in mind instance objects by their ids. +Controller and wrapper around Creating is `CreateContext` which cares about loading of plugins needed for Creating. And validates required functions in host implementation. + +Context discovers creator and publish plugins. Trigger collections of existing instances on creators and trigger Creating itself. Also keeps in mind instance objects by their ids. Creator plugins can call **creator_adds_instance** or **creator_removed_instance** to add/remove instance but these methods are not meant to be called directly out of creator. The reason is that is creator's responsibility to remove metadata or decide if should remove the instance. -### Required functions in host implementation -Host implementation **must** have implemented **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instane but are needed for creation and publishing process. Right now are there stored data about enabled/disabled optional publish plugins. When data are not stored and loaded properly reset of publishing will cause that they will be set to default value. Similar to instance data can be context data also parsed to json string. +#### Required functions in host implementation +Host implementation **must** have implemented **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instance but are needed for Creating and publishing process. Right now are there stored data about enabled/disabled optional publish plugins. When data are not stored and loaded properly reset of publishing will cause that they will be set to default value. Similar to instance data can be context data also parsed to json string. There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return string showed in UI as a title. Output string may contain html tags. It is recommended to return context path (it will be created function this purposes) in this order `"{project name}/{asset hierarchy}/{asset name}/{task name}"`. @@ -58,10 +62,10 @@ Another optional function is **get_current_context**. This function is handy in } ``` -## Create plugin +### Create plugin Main responsibility of create plugin is to create, update, collect and remove instance metadata and propagate changes to create context. Has access to **CreateContext** (`self.create_context`) that discovered the plugin so has also access to other creators and instances. Create plugins have a lot of responsibility so it is recommended to implement common code per host. -### BaseCreator +#### *BaseCreator* Base implementation of creator plugin. It is not recommended to use this class as base for production plugins but rather use one of **AutoCreator** and **Creator** variants. **Abstractions** @@ -88,7 +92,7 @@ def collect_instances(self): self._add_instance_to_context(instance) ``` -- **`create`** (method) - Create new object of **CreatedInstance** store it's metadata to workfile and add the instance into create context. Failed creation should raise **CreatorError** if happens error that can artist fix or give him some useful information. Trigger and implementation differs for **Creator** and **AutoCreator**. +- **`create`** (method) - Create new object of **CreatedInstance** store it's metadata to workfile and add the instance into create context. Failed Creating should raise **CreatorError** if happens error that can artist fix or give him some useful information. Trigger and implementation differs for **Creator** and **AutoCreator**. - **`update_instances`** (method) - Update data of instances. Receives tuple with **instance** and **changes**. ```python @@ -195,7 +199,7 @@ class RenderLayerCreator(Creator): - **`get_dynamic_data`** (method) - Can be used to extend data for subset template which may be required in some cases. -### AutoCreator +#### *AutoCreator* Creator that is triggered on reset of create context. Can be used for families that are expected to be created automatically without artist interaction (e.g. **workfile**). Method `create` is triggered after collecting of all creators. :::important @@ -268,14 +272,11 @@ def create(self): existing_instance["task"] = task_name ``` -### Creator +#### *Creator* Implementation of creator plugin that is triggered manually by artist in UI (or by code). Has extended options for UI purposes than **AutoCreator** and **create** method expect more arguments. -**Abstractions** -- **`create`** (method) - Code where creation of metadata - **Optional implementations** -- **`create_allow_context_change`** (class attr) - Allow to set context in UI before creation. Some creator may not allow it or their logic would not use the context selection (e.g. bulk creators). Is set to `True` but default. +- **`create_allow_context_change`** (class attr) - Allow to set context in UI before Creating. Some creator may not allow it or their logic would not use the context selection (e.g. bulk creators). Is set to `True` but default. ```python class BulkRenderCreator(Creator): create_allow_context_change = False @@ -391,11 +392,11 @@ class CreateRender(Creator): self._add_instance_to_context(new_instance) ``` -# Publish -## Exceptions +## **Publish** +### Exceptions OpenPype define few specific exceptions that should be used in publish plugins. -### Validation exception +#### *Validation exception* Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. Exception `PublishValidationError` expects 4 arguments: @@ -449,10 +450,10 @@ or the scene file was copy pasted from different context. ``` -### Known errors +#### *Known errors* When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown. -## Plugin extension +### Plugin extension Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). Publish plugins that inherit from this mixin can define attributes that will be shown in **CreatedInstance**. One of most important usages is to be able turn on/off optional plugins. Attributes are defined by return value of `get_attribute_defs` method. Attribute definitions are for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be re-implemented `convert_attribute_values`. Default implementation just converts the values to right types. @@ -463,18 +464,22 @@ Values of publish attributes from created instance are never removed automatical Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. -```python -import pyblish.api -from openpype.pipeline import ( +
+ Toggle me! + + ``` python + + import pyblish.api + from openpype.pipeline import ( OpenPypePyblishPluginMixin, attribute_definitions, -) + ) -# Example context plugin -class MyExtendedPlugin( + # Example context plugin + class MyExtendedPlugin( pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin -): + ): optional = True active = True @@ -514,4 +519,7 @@ class MyExtendedPlugin( return # Do plugin logic ... -``` + ``` +
+ + From c2b37588a647514f76cd6626fb239b9c0d21a203 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 17 Mar 2022 17:24:15 +0100 Subject: [PATCH 099/854] nuke: making better readability --- .../publish/extract_review_data_mov.py | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 544b9e04da..f5bb03fc69 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -51,39 +51,18 @@ class ExtractReviewDataMov(openpype.api.Extractor): f_subsets = o_data["filter"]["sebsets"] # test if family found in context - test_families = any([ - # first if exact family set is matching - # make sure only interesetion of list is correct - bool(set(families).intersection(f_families)), - # and if famiies are set at all - # if not then return True because we want this preset - # to be active if nothig is set - bool(not f_families) - ]) + # using intersection to make sure all defined + # families are present in combinantion + test_families = not f_families or any( + set(families).intersection(f_families)) # test task types from filter - test_task_types = any([ - # check if actual task type is defined in task types - # set in preset's filter - bool(task_type in f_task_types), - # and if taskTypes are defined in preset filter - # if not then return True, because we want this filter - # to be active if no taskType is set - bool(not f_task_types) - ]) + test_task_types = not f_task_types or any( + task_type in f_task_types) # test subsets from filter - test_subsets = any([ - # check if any of subset filter inputs - # converted to regex patern is not found in subset - # we keep strict case sensitivity - bool(next(( - s for s in f_subsets - if re.search(re.compile(s), subset) - ), None)), - # but if no subsets were set then make this acuntable too - bool(not f_subsets) - ]) + test_subsets = not f_subsets or any( + re.search(s, subset) for s in f_subsets) # we need all filters to be positive for this # preset to be activated From f8c06e0e1fb0d91b09a76c4611cfdf4d1ac546d8 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 18 Mar 2022 11:15:19 +0900 Subject: [PATCH 100/854] fix iteration for options among multiverse extractor --- openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py | 3 ++- .../hosts/maya/plugins/publish/extract_multiverse_usd_comp.py | 3 ++- .../hosts/maya/plugins/publish/extract_multiverse_usd_over.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 565fbd1ee3..96e0e79c29 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -163,7 +163,8 @@ class ExtractMultiverseUsd(openpype.api.Extractor): time_opts.framePerSecond = options["timeRangeFramesPerSecond"] asset_write_opts = multiverse.AssetWriteOptions(time_opts) - for (k, v) in options.iteritems(): + options_items = getattr(options, "iteritems", options.items) + for (k, v) in options_items: if k == "writeTimeRange" or k.startswith("timeRange"): continue setattr(asset_write_opts, k, v) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index f35096e516..ef54e9ceff 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -107,7 +107,8 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): time_opts.framePerSecond = options["timeRangeFramesPerSecond"] comp_write_opts = multiverse.CompositionWriteOptions() - for (k, v) in options.iteritems(): + options_items = getattr(options, "iteritems", options.items) + for (k, v) in options_items: if k == "writeTimeRange" or k.startswith("timeRange"): continue setattr(comp_write_opts, k, v) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index e0e65d83d1..df76614f5a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -117,7 +117,8 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): time_opts.framePerSecond = options["timeRangeFramesPerSecond"] over_write_opts = multiverse.OverridesWriteOptions() - for (k, v) in options.iteritems(): + options_items = getattr(options, "iteritems", options.items) + for (k, v) in options_items: if k == "writeTimeRange" or k.startswith("timeRange"): continue setattr(over_write_opts, k, v) From ca5017f730f71de4a4034b06cd3ce5835724e4fa Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Fri, 18 Mar 2022 12:23:42 +0900 Subject: [PATCH 101/854] fix iteration loop --- openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py | 2 +- .../hosts/maya/plugins/publish/extract_multiverse_usd_comp.py | 2 +- .../hosts/maya/plugins/publish/extract_multiverse_usd_over.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 96e0e79c29..7c13252957 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -164,7 +164,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): asset_write_opts = multiverse.AssetWriteOptions(time_opts) options_items = getattr(options, "iteritems", options.items) - for (k, v) in options_items: + for (k, v) in options_items(): if k == "writeTimeRange" or k.startswith("timeRange"): continue setattr(asset_write_opts, k, v) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index ef54e9ceff..449a99e1be 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -108,7 +108,7 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): comp_write_opts = multiverse.CompositionWriteOptions() options_items = getattr(options, "iteritems", options.items) - for (k, v) in options_items: + for (k, v) in options_items(): if k == "writeTimeRange" or k.startswith("timeRange"): continue setattr(comp_write_opts, k, v) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index df76614f5a..406ff8ba11 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -118,7 +118,7 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): over_write_opts = multiverse.OverridesWriteOptions() options_items = getattr(options, "iteritems", options.items) - for (k, v) in options_items: + for (k, v) in options_items(): if k == "writeTimeRange" or k.startswith("timeRange"): continue setattr(over_write_opts, k, v) From 4c1fa1d632e7880131f62bb6dc8f064795cb431a Mon Sep 17 00:00:00 2001 From: jrsndlr Date: Fri, 18 Mar 2022 11:50:41 +0100 Subject: [PATCH 102/854] no-audio tag Allow skipping audio for reviews. --- .../projects_schema/schemas/schema_representation_tags.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json index 7607e1a8c1..484fbf9d07 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_representation_tags.json @@ -24,6 +24,9 @@ }, { "sequence": "Output as image sequence" + }, + { + "no-audio": "Do not add audio" } ] } From 7fa905a5e3d028586dcd93aa480b78060ff6b800 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 12:30:04 +0100 Subject: [PATCH 103/854] removed registering of inventory actions from hosts that don't have any --- openpype/hosts/aftereffects/api/pipeline.py | 1 - openpype/hosts/blender/api/pipeline.py | 1 - openpype/hosts/flame/api/pipeline.py | 5 +---- openpype/hosts/hiero/api/pipeline.py | 2 -- openpype/hosts/resolve/api/pipeline.py | 3 --- 5 files changed, 1 insertion(+), 11 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 681f1c51a7..71270e1a12 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -29,7 +29,6 @@ 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 check_inventory(): diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 07a7509dd7..6c1dfb6cc7 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -31,7 +31,6 @@ 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") ORIGINAL_EXCEPTHOOK = sys.excepthook diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 930c6abe29..650416d58b 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -26,7 +26,6 @@ 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") AVALON_CONTAINERS = "AVALON_CONTAINERS" @@ -34,12 +33,10 @@ log = Logger.get_logger(__name__) def install(): - pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) - avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) log.info("OpenPype Flame plug-ins registred ...") # register callback for switching publishable @@ -47,6 +44,7 @@ def install(): log.info("OpenPype Flame host installed ...") + def uninstall(): pyblish.deregister_host("flame") @@ -54,7 +52,6 @@ def uninstall(): pyblish.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) - avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index eff126c0b6..131d8c98d4 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -28,7 +28,6 @@ PLUGINS_DIR = os.path.join(HOST_DIR, "plugins") PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish").replace("\\", "/") LOAD_PATH = os.path.join(PLUGINS_DIR, "load").replace("\\", "/") CREATE_PATH = os.path.join(PLUGINS_DIR, "create").replace("\\", "/") -INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory").replace("\\", "/") AVALON_CONTAINERS = ":AVALON_CONTAINERS" @@ -51,7 +50,6 @@ def install(): pyblish.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) - avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index fa309e3503..c538507c63 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -22,7 +22,6 @@ log = Logger().get_logger(__name__) 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") AVALON_CONTAINERS = ":AVALON_CONTAINERS" @@ -48,7 +47,6 @@ def install(): register_loader_plugin_path(LOAD_PATH) avalon.register_plugin_path(LegacyCreator, CREATE_PATH) - avalon.register_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) @@ -73,7 +71,6 @@ def uninstall(): deregister_loader_plugin_path(LOAD_PATH) avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) - avalon.deregister_plugin_path(avalon.InventoryAction, INVENTORY_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) From d16f8f1384b56030ccb8f68784d90565fb21b7a5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 13:15:48 +0100 Subject: [PATCH 104/854] moved remaining plugins/actions from avalon into openpype --- openpype/pipeline/__init__.py | 32 ++++++- openpype/pipeline/actions.py | 148 ++++++++++++++++++++++++++++++ openpype/pipeline/load/plugins.py | 1 + openpype/pipeline/thumbnails.py | 147 +++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 openpype/pipeline/actions.py create mode 100644 openpype/pipeline/thumbnails.py diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 26970e4edc..80c9cafcab 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -41,6 +41,22 @@ from .publish import ( OpenPypePyblishPluginMixin ) +from .actions import ( + LauncherAction, + + InventoryAction, + + discover_launcher_actions, + register_launcher_action, + register_launcher_action_path, + + discover_inventory_actions, + register_inventory_action, + register_inventory_action_path, + deregister_inventory_action, + deregister_inventory_action_path, +) + __all__ = ( "attribute_definitions", @@ -82,5 +98,19 @@ __all__ = ( "PublishValidationError", "PublishXmlValidationError", "KnownPublishError", - "OpenPypePyblishPluginMixin" + "OpenPypePyblishPluginMixin", + + # --- Plugins --- + "LauncherAction", + "InventoryAction", + + "discover_launcher_actions", + "register_launcher_action", + "register_launcher_action_path", + + "discover_inventory_actions", + "register_inventory_action", + "register_inventory_action_path", + "deregister_inventory_action", + "deregister_inventory_action_path", ) diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py new file mode 100644 index 0000000000..544acbc8d3 --- /dev/null +++ b/openpype/pipeline/actions.py @@ -0,0 +1,148 @@ +import os +import copy +import logging + + +class LauncherAction(object): + """A custom action available""" + name = None + label = None + icon = None + color = None + order = 0 + + log = logging.getLogger("LauncherAction") + log.propagate = True + + def is_compatible(self, session): + """Return whether the class is compatible with the Session.""" + return True + + def process(self, session, **kwargs): + pass + + +class InventoryAction(object): + """A custom action for the scene inventory tool + + If registered the action will be visible in the Right Mouse Button menu + under the submenu "Actions". + + """ + + label = None + icon = None + color = None + order = 0 + + log = logging.getLogger("InventoryAction") + log.propagate = True + + @staticmethod + def is_compatible(container): + """Override function in a custom class + + This method is specifically used to ensure the action can operate on + the container. + + Args: + container(dict): the data of a loaded asset, see host.ls() + + Returns: + bool + """ + return bool(container.get("objectName")) + + def process(self, containers): + """Override function in a custom class + + This method will receive all containers even those which are + incompatible. It is advised to create a small filter along the lines + of this example: + + valid_containers = filter(self.is_compatible(c) for c in containers) + + The return value will need to be a True-ish value to trigger + the data_changed signal in order to refresh the view. + + You can return a list of container names to trigger GUI to select + treeview items. + + You can return a dict to carry extra GUI options. For example: + { + "objectNames": [container names...], + "options": {"mode": "toggle", + "clear": False} + } + Currently workable GUI options are: + - clear (bool): Clear current selection before selecting by action. + Default `True`. + - mode (str): selection mode, use one of these: + "select", "deselect", "toggle". Default is "select". + + Args: + containers (list): list of dictionaries + + Return: + bool, list or dict + + """ + return True + + +# Launcher action +def discover_launcher_actions(): + import avalon.api + + return avalon.api.discover(LauncherAction) + + +def register_launcher_action(plugin): + import avalon.api + + return avalon.api.register_plugin(LauncherAction, plugin) + + +def register_launcher_action_path(path): + import avalon.api + + return avalon.api.register_plugin_path(LauncherAction, path) + + +# Inventory action +def discover_inventory_actions(): + import avalon.api + + actions = avalon.api.discover(InventoryAction) + filtered_actions = [] + for action in actions: + if action is not InventoryAction: + print("DISCOVERED", action) + filtered_actions.append(action) + else: + print("GOT SOURCE") + return filtered_actions + + +def register_inventory_action(plugin): + import avalon.api + + return avalon.api.register_plugin(InventoryAction, plugin) + + +def deregister_inventory_action(plugin): + import avalon.api + + avalon.api.deregister_plugin(InventoryAction, plugin) + + +def register_inventory_action_path(path): + import avalon.api + + return avalon.api.register_plugin_path(InventoryAction, path) + + +def deregister_inventory_action_path(path): + import avalon.api + + return avalon.api.deregister_plugin_path(InventoryAction, path) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index 601ad3b258..9b2b6bb084 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -127,4 +127,5 @@ def register_loader_plugin_path(path): def deregister_loader_plugin(plugin): import avalon.api + avalon.api.deregister_plugin(LoaderPlugin, plugin) diff --git a/openpype/pipeline/thumbnails.py b/openpype/pipeline/thumbnails.py new file mode 100644 index 0000000000..12bab83be6 --- /dev/null +++ b/openpype/pipeline/thumbnails.py @@ -0,0 +1,147 @@ +import os +import copy +import logging + +log = logging.getLogger(__name__) + + +def get_thumbnail_binary(thumbnail_entity, thumbnail_type, dbcon=None): + if not thumbnail_entity: + return + + resolvers = discover_thumbnail_resolvers() + resolvers = sorted(resolvers, key=lambda cls: cls.priority) + if dbcon is None: + from avalon import io + dbcon = io + + for Resolver in resolvers: + available_types = Resolver.thumbnail_types + if ( + thumbnail_type not in available_types + and "*" not in available_types + and ( + isinstance(available_types, (list, tuple)) + and len(available_types) == 0 + ) + ): + continue + try: + instance = Resolver(dbcon) + result = instance.process(thumbnail_entity, thumbnail_type) + if result: + return result + + except Exception: + log.warning("Resolver {0} failed durring process.".format( + Resolver.__class__.__name__, exc_info=True + )) + + +class ThumbnailResolver(object): + """Determine how to get data from thumbnail entity. + + "priority" - determines the order of processing in `get_thumbnail_binary`, + lower number is processed earlier. + "thumbnail_types" - it is expected that thumbnails will be used in more + more than one level, there is only ["thumbnail"] type at the moment + of creating this docstring but it is expected to add "ico" and "full" + in future. + """ + + priority = 100 + thumbnail_types = ["*"] + + def __init__(self, dbcon): + self._log = None + self.dbcon = dbcon + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + def process(self, thumbnail_entity, thumbnail_type): + pass + + +class TemplateResolver(ThumbnailResolver): + + priority = 90 + + def process(self, thumbnail_entity, thumbnail_type): + + if not os.environ.get("AVALON_THUMBNAIL_ROOT"): + return + + template = thumbnail_entity["data"].get("template") + if not template: + self.log.debug("Thumbnail entity does not have set template") + return + + project = self.dbcon.find_one( + {"type": "project"}, + { + "name": True, + "data.code": True + } + ) + + template_data = copy.deepcopy( + thumbnail_entity["data"].get("template_data") or {} + ) + template_data.update({ + "_id": str(thumbnail_entity["_id"]), + "thumbnail_type": thumbnail_type, + "thumbnail_root": os.environ.get("AVALON_THUMBNAIL_ROOT"), + "project": { + "name": project["name"], + "code": project["data"].get("code") + } + }) + + try: + filepath = os.path.normpath(template.format(**template_data)) + except KeyError: + self.log.warning(( + "Missing template data keys for template <{0}> || Data: {1}" + ).format(template, str(template_data))) + return + + if not os.path.exists(filepath): + self.log.warning("File does not exist \"{0}\"".format(filepath)) + return + + with open(filepath, "rb") as _file: + content = _file.read() + + return content + + +class BinaryThumbnail(ThumbnailResolver): + def process(self, thumbnail_entity, thumbnail_type): + return thumbnail_entity["data"].get("binary_data") + + +# Thumbnail resolvers +def discover_thumbnail_resolvers(): + import avalon.api + + return avalon.api.discover(ThumbnailResolver) + + +def register_thumbnail_resolver(plugin): + import avalon.api + + return avalon.api.register_plugin(ThumbnailResolver, plugin) + + +def register_thumbnail_resolver_path(path): + import avalon.api + + return avalon.api.register_plugin_path(ThumbnailResolver, path) + + +register_thumbnail_resolver(TemplateResolver) +register_thumbnail_resolver(BinaryThumbnail) From 0710540aa482053612e023ccf0388623ee1afe85 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 13:17:19 +0100 Subject: [PATCH 105/854] changed imports of moved plugins/actions --- openpype/__init__.py | 3 ++- openpype/hosts/fusion/api/pipeline.py | 8 ++++---- .../plugins/inventory/select_containers.py | 4 ++-- .../plugins/inventory/set_tool_color.py | 4 ++-- openpype/hosts/maya/api/pipeline.py | 8 ++++---- .../plugins/inventory/import_modelrender.py | 6 ++++-- .../plugins/inventory/import_reference.py | 5 ++--- openpype/hosts/nuke/api/pipeline.py | 5 ++++- .../plugins/inventory/repair_old_loaders.py | 4 ++-- .../plugins/inventory/select_containers.py | 4 ++-- openpype/tools/launcher/actions.py | 19 +++++++++++++------ openpype/tools/launcher/models.py | 5 +++-- openpype/tools/loader/widgets.py | 5 +++-- openpype/tools/sceneinventory/view.py | 5 +++-- 14 files changed, 50 insertions(+), 35 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 99629a4257..8b94b2dc3f 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -78,6 +78,7 @@ def install(): from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, + register_inventory_action, ) from avalon import pipeline @@ -124,7 +125,7 @@ def install(): pyblish.register_plugin_path(path) register_loader_plugin_path(path) avalon.register_plugin_path(LegacyCreator, path) - avalon.register_plugin_path(avalon.InventoryAction, path) + register_inventory_action(path) # apply monkey patched discover to original one log.info("Patching discovery") diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 92e54ad6f5..51442d23ff 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -15,6 +15,8 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + register_inventory_action_path, + deregister_inventory_action_path, ) import openpype.hosts.fusion @@ -69,7 +71,7 @@ def install(): register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) - avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) + register_inventory_action_path(INVENTORY_PATH) pyblish.api.register_callback( "instanceToggled", on_pyblish_instance_toggled @@ -93,9 +95,7 @@ def uninstall(): deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) - avalon.api.deregister_plugin_path( - avalon.api.InventoryAction, INVENTORY_PATH - ) + deregister_inventory_action_path(INVENTORY_PATH) pyblish.api.deregister_callback( "instanceToggled", on_pyblish_instance_toggled diff --git a/openpype/hosts/fusion/plugins/inventory/select_containers.py b/openpype/hosts/fusion/plugins/inventory/select_containers.py index 294c134505..d554b73a5b 100644 --- a/openpype/hosts/fusion/plugins/inventory/select_containers.py +++ b/openpype/hosts/fusion/plugins/inventory/select_containers.py @@ -1,7 +1,7 @@ -from avalon import api +from openpype.pipeline import InventoryAction -class FusionSelectContainers(api.InventoryAction): +class FusionSelectContainers(InventoryAction): label = "Select Containers" icon = "mouse-pointer" diff --git a/openpype/hosts/fusion/plugins/inventory/set_tool_color.py b/openpype/hosts/fusion/plugins/inventory/set_tool_color.py index 2f5ae4d241..c7530ce674 100644 --- a/openpype/hosts/fusion/plugins/inventory/set_tool_color.py +++ b/openpype/hosts/fusion/plugins/inventory/set_tool_color.py @@ -1,6 +1,6 @@ -from avalon import api from Qt import QtGui, QtWidgets +from openpype.pipeline import InventoryAction from openpype import style from openpype.hosts.fusion.api import ( get_current_comp, @@ -8,7 +8,7 @@ from openpype.hosts.fusion.api import ( ) -class FusionSetToolColor(api.InventoryAction): +class FusionSetToolColor(InventoryAction): """Update the color of the selected tools""" label = "Set Tool Color" diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 5cdc3ff4fd..3c09417b21 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -23,7 +23,9 @@ from openpype.lib.path_tools import HostDirmap from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, + register_inventory_action_path, deregister_loader_plugin_path, + deregister_inventory_action_path, ) from openpype.hosts.maya.lib import copy_workspace_mel from . import menu, lib @@ -59,7 +61,7 @@ def install(): register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) - avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) + register_inventory_action_path(INVENTORY_PATH) log.info(PUBLISH_PATH) log.info("Installing callbacks ... ") @@ -188,9 +190,7 @@ def uninstall(): deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) - avalon.api.deregister_plugin_path( - avalon.api.InventoryAction, INVENTORY_PATH - ) + deregister_inventory_action_path(INVENTORY_PATH) menu.uninstall() diff --git a/openpype/hosts/maya/plugins/inventory/import_modelrender.py b/openpype/hosts/maya/plugins/inventory/import_modelrender.py index c5d3d0c8f4..8fc26930cb 100644 --- a/openpype/hosts/maya/plugins/inventory/import_modelrender.py +++ b/openpype/hosts/maya/plugins/inventory/import_modelrender.py @@ -1,6 +1,8 @@ import json -from avalon import api, io +from avalon import io + from openpype.pipeline import ( + InventoryAction, get_representation_context, get_representation_path_from_context, ) @@ -10,7 +12,7 @@ from openpype.hosts.maya.api.lib import ( ) -class ImportModelRender(api.InventoryAction): +class ImportModelRender(InventoryAction): label = "Import Model Render Sets" icon = "industry" diff --git a/openpype/hosts/maya/plugins/inventory/import_reference.py b/openpype/hosts/maya/plugins/inventory/import_reference.py index 2fa132a867..afb1e0e17f 100644 --- a/openpype/hosts/maya/plugins/inventory/import_reference.py +++ b/openpype/hosts/maya/plugins/inventory/import_reference.py @@ -1,11 +1,10 @@ from maya import cmds -from avalon import api - +from openpype.pipeline import InventoryAction from openpype.hosts.maya.api.plugin import get_reference_node -class ImportReference(api.InventoryAction): +class ImportReference(InventoryAction): """Imports selected reference to inside of the file.""" label = "Import Reference" diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index fd2e16b8d3..fef4a1d401 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -18,7 +18,9 @@ from openpype.lib import register_event_callback from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, + register_inventory_action_path, deregister_loader_plugin_path, + deregister_inventory_action_path, ) from openpype.tools.utils import host_tools @@ -105,7 +107,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) - avalon.api.register_plugin_path(avalon.api.InventoryAction, INVENTORY_PATH) + register_inventory_action_path(INVENTORY_PATH) # Register Avalon event for workfiles loading. register_event_callback("workio.open_file", check_inventory_versions) @@ -131,6 +133,7 @@ def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_inventory_action_path(INVENTORY_PATH) pyblish.api.deregister_callback( "instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py index 5f834be557..c04c939a8d 100644 --- a/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py +++ b/openpype/hosts/nuke/plugins/inventory/repair_old_loaders.py @@ -1,9 +1,9 @@ -from avalon import api from openpype.api import Logger +from openpype.pipeline import InventoryAction from openpype.hosts.nuke.api.lib import set_avalon_knob_data -class RepairOldLoaders(api.InventoryAction): +class RepairOldLoaders(InventoryAction): label = "Repair Old Loaders" icon = "gears" diff --git a/openpype/hosts/nuke/plugins/inventory/select_containers.py b/openpype/hosts/nuke/plugins/inventory/select_containers.py index 3f174b3562..d7d5f00b87 100644 --- a/openpype/hosts/nuke/plugins/inventory/select_containers.py +++ b/openpype/hosts/nuke/plugins/inventory/select_containers.py @@ -1,8 +1,8 @@ -from avalon import api +from openpype.pipeline import InventoryAction from openpype.hosts.nuke.api.commands import viewer_update_and_undo_stop -class SelectContainers(api.InventoryAction): +class SelectContainers(InventoryAction): label = "Select Containers" icon = "mouse-pointer" diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py index fbaef05261..546bda1c34 100644 --- a/openpype/tools/launcher/actions.py +++ b/openpype/tools/launcher/actions.py @@ -1,6 +1,7 @@ import os -from avalon import api +from Qt import QtWidgets, QtGui + from openpype import PLUGINS_DIR from openpype import style from openpype.api import Logger, resources @@ -8,7 +9,10 @@ from openpype.lib import ( ApplictionExecutableNotFound, ApplicationLaunchFailed ) -from Qt import QtWidgets, QtGui +from openpype.pipeline import ( + LauncherAction, + register_launcher_action_path, +) def register_actions_from_paths(paths): @@ -29,14 +33,15 @@ def register_actions_from_paths(paths): print("Path was not found: {}".format(path)) continue - api.register_plugin_path(api.Action, path) + register_launcher_action_path(path) def register_config_actions(): """Register actions from the configuration for Launcher""" actions_dir = os.path.join(PLUGINS_DIR, "actions") - register_actions_from_paths([actions_dir]) + if os.path.exists(actions_dir): + register_actions_from_paths([actions_dir]) def register_environment_actions(): @@ -46,7 +51,9 @@ def register_environment_actions(): register_actions_from_paths(paths_str.split(os.pathsep)) -class ApplicationAction(api.Action): +# TODO move to 'openpype.pipeline.actions' +# - remove Qt related stuff and implement exceptions to show error in launcher +class ApplicationAction(LauncherAction): """Pype's application launcher Application action based on pype's ApplicationManager system. @@ -74,7 +81,7 @@ class ApplicationAction(api.Action): @property def log(self): if self._log is None: - self._log = Logger().get_logger(self.__class__.__name__) + self._log = Logger.get_logger(self.__class__.__name__) return self._log def is_compatible(self, session): diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index 85d553fca4..13567e7916 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -8,12 +8,13 @@ import time import appdirs from Qt import QtCore, QtGui import qtawesome -from avalon import api + from openpype.lib import JSONSettingRegistry from openpype.lib.applications import ( CUSTOM_LAUNCH_APP_GROUPS, ApplicationManager ) +from openpype.pipeline import discover_launcher_actions from openpype.tools.utils.lib import ( DynamicQThread, get_project_icon, @@ -68,7 +69,7 @@ class ActionModel(QtGui.QStandardItemModel): def discover(self): """Set up Actions cache. Run this for each new project.""" # Discover all registered actions - actions = api.discover(api.Action) + actions = discover_launcher_actions() # Get available project actions and the application actions app_actions = self.get_application_actions() diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index b14bdd0e93..a4c7d4bd24 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -7,9 +7,10 @@ import collections from Qt import QtWidgets, QtCore, QtGui -from avalon import api, pipeline +from avalon import api from openpype.pipeline import HeroVersionType +from openpype.pipeline.thumbnails import get_thumbnail_binary from openpype.pipeline.load import ( discover_loader_plugins, SubsetLoaderPlugin, @@ -863,7 +864,7 @@ class ThumbnailWidget(QtWidgets.QLabel): if not thumbnail_ent: return - thumbnail_bin = pipeline.get_thumbnail_binary( + thumbnail_bin = get_thumbnail_binary( thumbnail_ent, "thumbnail", self.dbcon ) if not thumbnail_bin: diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index c38390c614..2f9996a4ae 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -5,13 +5,14 @@ from functools import partial from Qt import QtWidgets, QtCore import qtawesome -from avalon import io, api +from avalon import io from openpype import style from openpype.pipeline import ( HeroVersionType, update_container, remove_container, + discover_inventory_actions, ) from openpype.modules import ModulesManager from openpype.tools.utils.lib import ( @@ -487,7 +488,7 @@ class SceneInventoryView(QtWidgets.QTreeView): containers = containers or [dict()] # Check which action will be available in the menu - Plugins = api.discover(api.InventoryAction) + Plugins = discover_inventory_actions() compatible = [p() for p in Plugins if any(p.is_compatible(c) for c in containers)] From 5fcc6a035891a3c4ecddd3ce24f5c3592778e96f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 13:37:24 +0100 Subject: [PATCH 106/854] remove unused imports --- openpype/pipeline/actions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py index 544acbc8d3..a045c92aa7 100644 --- a/openpype/pipeline/actions.py +++ b/openpype/pipeline/actions.py @@ -1,5 +1,3 @@ -import os -import copy import logging From b2b129d9e89e6daf4e189055e6580310e718df4c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 18 Mar 2022 13:49:03 +0100 Subject: [PATCH 107/854] nuke: simplification of simplified code --- .../publish/extract_review_data_mov.py | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index f5bb03fc69..93615fb23d 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -53,27 +53,18 @@ class ExtractReviewDataMov(openpype.api.Extractor): # test if family found in context # using intersection to make sure all defined # families are present in combinantion - test_families = not f_families or any( - set(families).intersection(f_families)) + if f_families and not any( + set(families).intersection(f_families)): + continue # test task types from filter - test_task_types = not f_task_types or any( - task_type in f_task_types) + if f_task_types and not any( + task_type in f_task_types): + continue # test subsets from filter - test_subsets = not f_subsets or any( - re.search(s, subset) for s in f_subsets) - - # we need all filters to be positive for this - # preset to be activated - test_all = all([ - test_families, - test_task_types, - test_subsets - ]) - - # if it is not positive then skip this preset - if not test_all: + if f_subsets and not any( + re.search(s, subset) for s in f_subsets): continue self.log.info( From 945bdc5a9b8689110c7c6dfd3141b894ee441b50 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 18 Mar 2022 14:04:01 +0100 Subject: [PATCH 108/854] nuke: fixing any to bool - adding family to testing families - adding debug logging for filtering --- .../plugins/publish/extract_review_data_mov.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 93615fb23d..22ebdda05d 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -25,6 +25,8 @@ class ExtractReviewDataMov(openpype.api.Extractor): def process(self, instance): families = instance.data["families"] + families.append(instance.data["family"]) + task_type = instance.context.data["taskType"] subset = instance.data["subset"] self.log.info("Creating staging dir...") @@ -50,6 +52,18 @@ class ExtractReviewDataMov(openpype.api.Extractor): f_task_types = o_data["filter"]["task_types"] f_subsets = o_data["filter"]["sebsets"] + self.log.debug( + "f_families `{}` > families: {}".format( + f_families, families)) + + self.log.debug( + "f_task_types `{}` > task_type: {}".format( + f_task_types, task_type)) + + self.log.debug( + "f_subsets `{}` > subset: {}".format( + f_subsets, subset)) + # test if family found in context # using intersection to make sure all defined # families are present in combinantion @@ -58,7 +72,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): continue # test task types from filter - if f_task_types and not any( + if f_task_types and not bool( task_type in f_task_types): continue From f03e5974931df57e3ac844e690d5f134754c52aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Fri, 18 Mar 2022 14:05:32 +0100 Subject: [PATCH 109/854] Update openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py Co-authored-by: Roy Nieterau --- openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 22ebdda05d..6f6e07fc28 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -66,7 +66,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): # test if family found in context # using intersection to make sure all defined - # families are present in combinantion + # families are present in combination if f_families and not any( set(families).intersection(f_families)): continue From e8d0839cafb5dac3c8d6b5d5b4f524db864d80f0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Fri, 18 Mar 2022 14:06:12 +0100 Subject: [PATCH 110/854] Change label to 'Actions' Co-authored-by: Roy Nieterau --- openpype/pipeline/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 80c9cafcab..2ee8d4f118 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -100,7 +100,7 @@ __all__ = ( "KnownPublishError", "OpenPypePyblishPluginMixin", - # --- Plugins --- + # --- Actions --- "LauncherAction", "InventoryAction", From 050851731ad35edaeb5ee59fd747b9feab0e67b3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 15:19:34 +0100 Subject: [PATCH 111/854] renamed 'thumbnails.py' to 'thumbnail.py' --- openpype/pipeline/{thumbnails.py => thumbnail.py} | 0 openpype/tools/loader/widgets.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename openpype/pipeline/{thumbnails.py => thumbnail.py} (100%) diff --git a/openpype/pipeline/thumbnails.py b/openpype/pipeline/thumbnail.py similarity index 100% rename from openpype/pipeline/thumbnails.py rename to openpype/pipeline/thumbnail.py diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index a4c7d4bd24..2de43cf42a 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -10,7 +10,7 @@ from Qt import QtWidgets, QtCore, QtGui from avalon import api from openpype.pipeline import HeroVersionType -from openpype.pipeline.thumbnails import get_thumbnail_binary +from openpype.pipeline.thumbnail import get_thumbnail_binary from openpype.pipeline.load import ( discover_loader_plugins, SubsetLoaderPlugin, From a6a1d0fc545d8fc5a8781f40468a95a261ca3b01 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 18 Mar 2022 15:22:46 +0100 Subject: [PATCH 112/854] OP-2766 - fixed broken remove_instance --- openpype/hosts/photoshop/api/ws_stub.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/api/ws_stub.py b/openpype/hosts/photoshop/api/ws_stub.py index dd29ef4e84..fa076ecc7e 100644 --- a/openpype/hosts/photoshop/api/ws_stub.py +++ b/openpype/hosts/photoshop/api/ws_stub.py @@ -77,14 +77,28 @@ class PhotoshopServerStub: layer: (PSItem) layers_meta: full list from Headline (for performance in loops) Returns: + (dict) of layer metadata stored in PS file + + Example: + { + 'id': 'pyblish.avalon.container', + 'loader': 'ImageLoader', + 'members': ['64'], + 'name': 'imageMainMiddle', + 'namespace': 'Hero_imageMainMiddle_001', + 'representation': '6203dc91e80934d9f6ee7d96', + 'schema': 'openpype:container-2.0' + } """ if layers_meta is None: layers_meta = self.get_layers_metadata() for layer_meta in layers_meta: + layer_id = layer_meta.get("uuid") # legacy if layer_meta.get("members"): - if layer.id == layer_meta["members"][0]: - return layer + layer_id = layer_meta["members"][0] + if str(layer.id) == str(layer_id): + return layer_meta print("Unable to find layer metadata for {}".format(layer.id)) def imprint(self, item_id, data, all_layers=None, items_meta=None): @@ -399,7 +413,7 @@ class PhotoshopServerStub: if res: layers_data = json.loads(res) except json.decoder.JSONDecodeError: - pass + raise ValueError("{} cannot be parsed, recreate meta".format(res)) # format of metadata changed from {} to [] because of standardization # keep current implementation logic as its working if isinstance(layers_data, dict): @@ -482,7 +496,7 @@ class PhotoshopServerStub: for item in self.get_layers_metadata(): inst_id = item.get("instance_id") or item.get("uuid") if inst_id != instance_id: - cleaned_data.append(inst_id) + cleaned_data.append(item) payload = json.dumps(cleaned_data, indent=4) From e7c5aa16df8e46090140572cc77c607e6da4e707 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 18 Mar 2022 17:23:26 +0300 Subject: [PATCH 113/854] Fixes comparing against render filename Fixes comparison against AOV pattern match to produce correct "review" flags of the "beauty" pass, for proper generation of review burnin and thumbnail. --- .../modules/deadline/plugins/publish/submit_publish_job.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 19d504b6c9..2c8bcdf4fc 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -448,7 +448,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): preview = False if app in self.aov_filter.keys(): for aov_pattern in self.aov_filter[app]: - if re.match(aov_pattern, aov): + # Matching against the AOV pattern in the render files + # In order to match the AOV name, we must compare against the render filename string + # We are grabbing the render filename string from the collection that we have grabbed from expected files (exp_files) + render_file_name = os.path.basename(col[0]) + if re.match(aov_pattern, render_file_name): preview = True break From 01f2c8c1044ddeb78912dc2f6e401a4700e1a67d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 18 Mar 2022 15:24:51 +0100 Subject: [PATCH 114/854] OP-2766 - fixed layer and variant keys --- .../hosts/photoshop/plugins/create/create_image.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 4fc9a86635..c24d8bde2f 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -16,18 +16,13 @@ class ImageCreator(Creator): description = "Image creator" def collect_instances(self): - import json - self.log.info("ImageCreator: api.list_instances():: {}".format( - json.dumps(api.list_instances(), indent=4))) for instance_data in api.list_instances(): # legacy instances have family=='image' creator_id = (instance_data.get("creator_identifier") or instance_data.get("family")) - self.log.info("ImageCreator: instance_data:: {}".format(json.dumps(instance_data, indent=4))) if creator_id == self.identifier: instance_data = self._handle_legacy(instance_data) - layer = api.stub().get_layer(instance_data["members"][0]) instance_data["layer"] = layer instance = CreatedInstance.from_existing( @@ -106,9 +101,10 @@ class ImageCreator(Creator): stub.rename_layer(group.id, stub.PUBLISH_ICON + group.name) def update_instances(self, update_list): - self.log.info("update_list:: {}".format(update_list)) + self.log.debug("update_list:: {}".format(update_list)) created_inst, changes = update_list[0] - created_inst.pop("layer") # not storing PSItem layer to metadata + if created_inst.get("layer"): + created_inst.pop("layer") # not storing PSItem layer to metadata api.stub().imprint(created_inst.get("instance_id"), created_inst.data_to_store()) @@ -147,6 +143,9 @@ class ImageCreator(Creator): if not instance_data.get("task"): instance_data["task"] = avalon_api.Session.get("AVALON_TASK") + if not instance_data.get("variant"): + instance_data["variant"] = '' + return instance_data def _clean_highlights(self, stub, item): From 509f3289418a297ac17e3a0de93a987390cd8370 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 18 Mar 2022 17:25:34 +0300 Subject: [PATCH 115/854] Fix regex in global OpenPype Deadline settings Fixes the "beauty" regex for the "reviewable subsets filter" in the Publish Deadline settings. --- .../defaults/project_settings/deadline.json | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 5bb0a4022e..1859b480a1 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -15,33 +15,6 @@ "deadline" ] }, - "ProcessSubmittedJobOnFarm": { - "enabled": true, - "deadline_department": "", - "deadline_pool": "", - "deadline_group": "", - "deadline_chunk_size": 1, - "deadline_priority": 50, - "publishing_script": "", - "skip_integration_repre_list": [], - "aov_filter": { - "maya": [ - ".+(?:\\.|_)([Bb]eauty)(?:\\.|_).*" - ], - "nuke": [ - ".*" - ], - "aftereffects": [ - ".*" - ], - "celaction": [ - ".*" - ], - "harmony": [ - ".*" - ] - } - }, "MayaSubmitDeadline": { "enabled": true, "optional": false, @@ -95,6 +68,33 @@ "group": "", "department": "", "multiprocess": true + }, + "ProcessSubmittedJobOnFarm": { + "enabled": true, + "deadline_department": "", + "deadline_pool": "", + "deadline_group": "", + "deadline_chunk_size": 1, + "deadline_priority": 50, + "publishing_script": "", + "skip_integration_repre_list": [], + "aov_filter": { + "maya": [ + ".*(?:[\\._-])*([Bb]eauty)(?:[\\.|_])*.*" + ], + "nuke": [ + ".*" + ], + "aftereffects": [ + ".*" + ], + "celaction": [ + ".*" + ], + "harmony": [ + ".*" + ] + } } } } \ No newline at end of file From 89bdf2965cd8c92608f65cfd302d24ec5e4bcc5c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 15:54:19 +0100 Subject: [PATCH 116/854] moved AVALON_CONTAINER_ID from avalon into openpype --- openpype/hosts/aftereffects/api/pipeline.py | 5 +++-- openpype/hosts/blender/api/pipeline.py | 3 +-- openpype/hosts/blender/plugins/load/load_abc.py | 7 +++++-- openpype/hosts/blender/plugins/load/load_audio.py | 6 ++++-- openpype/hosts/blender/plugins/load/load_camera_blend.py | 6 ++++-- openpype/hosts/blender/plugins/load/load_camera_fbx.py | 6 ++++-- openpype/hosts/blender/plugins/load/load_fbx.py | 6 ++++-- openpype/hosts/blender/plugins/load/load_layout_blend.py | 2 +- openpype/hosts/blender/plugins/load/load_layout_json.py | 2 +- openpype/hosts/blender/plugins/load/load_model.py | 6 ++++-- openpype/hosts/blender/plugins/load/load_rig.py | 2 +- openpype/hosts/flame/api/pipeline.py | 3 ++- openpype/hosts/fusion/api/pipeline.py | 2 +- openpype/hosts/harmony/api/pipeline.py | 2 +- openpype/hosts/hiero/api/pipeline.py | 3 ++- openpype/hosts/houdini/api/pipeline.py | 2 +- openpype/hosts/houdini/plugins/load/load_image.py | 3 ++- openpype/hosts/houdini/plugins/load/load_usd_layer.py | 5 +++-- openpype/hosts/houdini/plugins/load/load_usd_reference.py | 5 +++-- openpype/hosts/maya/api/pipeline.py | 2 +- openpype/hosts/maya/api/plugin.py | 2 +- .../hosts/maya/plugins/publish/extract_maya_scene_raw.py | 2 +- openpype/hosts/nuke/api/pipeline.py | 4 ++-- openpype/hosts/photoshop/api/pipeline.py | 5 +++-- openpype/hosts/resolve/api/pipeline.py | 2 +- openpype/hosts/tvpaint/api/pipeline.py | 2 +- openpype/hosts/unreal/api/pipeline.py | 2 +- .../unreal/plugins/load/load_alembic_geometrycache.py | 8 +++++--- .../unreal/plugins/load/load_alembic_skeletalmesh.py | 8 +++++--- .../hosts/unreal/plugins/load/load_alembic_staticmesh.py | 8 +++++--- openpype/hosts/unreal/plugins/load/load_animation.py | 8 +++++--- openpype/hosts/unreal/plugins/load/load_camera.py | 5 +++-- openpype/hosts/unreal/plugins/load/load_layout.py | 2 +- openpype/hosts/unreal/plugins/load/load_rig.py | 8 +++++--- openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py | 8 +++++--- openpype/pipeline/__init__.py | 6 ++++++ openpype/pipeline/constants.py | 2 ++ 37 files changed, 100 insertions(+), 60 deletions(-) create mode 100644 openpype/pipeline/constants.py diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 681f1c51a7..47d0bdacc5 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -5,7 +5,7 @@ from Qt import QtWidgets import pyblish.api import avalon.api -from avalon import io, pipeline +from avalon import io from openpype import lib from openpype.api import Logger @@ -13,6 +13,7 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) import openpype.hosts.aftereffects from openpype.lib import register_event_callback @@ -149,7 +150,7 @@ def containerise(name, """ data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace, "loader": str(loader), diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 07a7509dd7..8c580cf214 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -12,12 +12,12 @@ from . import ops import pyblish.api import avalon.api from avalon import io, schema -from avalon.pipeline import AVALON_CONTAINER_ID from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from openpype.api import Logger from openpype.lib import ( @@ -31,7 +31,6 @@ 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") ORIGINAL_EXCEPTHOOK = sys.excepthook diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 3daaeceffe..1b2e800769 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -6,11 +6,14 @@ from typing import Dict, List, Optional import bpy -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) + from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) from openpype.hosts.blender.api import plugin, lib diff --git a/openpype/hosts/blender/plugins/load/load_audio.py b/openpype/hosts/blender/plugins/load/load_audio.py index b95c5db270..3f4fcc17de 100644 --- a/openpype/hosts/blender/plugins/load/load_audio.py +++ b/openpype/hosts/blender/plugins/load/load_audio.py @@ -6,12 +6,14 @@ from typing import Dict, List, Optional import bpy -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) diff --git a/openpype/hosts/blender/plugins/load/load_camera_blend.py b/openpype/hosts/blender/plugins/load/load_camera_blend.py index 6ed2e8a575..f00027f0b4 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_blend.py +++ b/openpype/hosts/blender/plugins/load/load_camera_blend.py @@ -7,12 +7,14 @@ from typing import Dict, List, Optional import bpy -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) logger = logging.getLogger("openpype").getChild( diff --git a/openpype/hosts/blender/plugins/load/load_camera_fbx.py b/openpype/hosts/blender/plugins/load/load_camera_fbx.py index 626ed44f08..97f844e610 100644 --- a/openpype/hosts/blender/plugins/load/load_camera_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_camera_fbx.py @@ -6,12 +6,14 @@ from typing import Dict, List, Optional import bpy -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) diff --git a/openpype/hosts/blender/plugins/load/load_fbx.py b/openpype/hosts/blender/plugins/load/load_fbx.py index 2d249ef647..ee2e7d175c 100644 --- a/openpype/hosts/blender/plugins/load/load_fbx.py +++ b/openpype/hosts/blender/plugins/load/load_fbx.py @@ -6,12 +6,14 @@ from typing import Dict, List, Optional import bpy -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) from openpype.hosts.blender.api import plugin, lib from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) diff --git a/openpype/hosts/blender/plugins/load/load_layout_blend.py b/openpype/hosts/blender/plugins/load/load_layout_blend.py index d87df3c010..cf8e89ed1f 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_blend.py +++ b/openpype/hosts/blender/plugins/load/load_layout_blend.py @@ -10,12 +10,12 @@ from openpype import lib from openpype.pipeline import ( legacy_create, get_representation_path, + AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) diff --git a/openpype/hosts/blender/plugins/load/load_layout_json.py b/openpype/hosts/blender/plugins/load/load_layout_json.py index 0693937fec..a0580af4a0 100644 --- a/openpype/hosts/blender/plugins/load/load_layout_json.py +++ b/openpype/hosts/blender/plugins/load/load_layout_json.py @@ -13,12 +13,12 @@ from openpype.pipeline import ( load_container, get_representation_path, loaders_from_representation, + AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api.pipeline import ( AVALON_INSTANCES, AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) from openpype.hosts.blender.api import plugin diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index 18d01dcb29..0a5d98ffa0 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -6,12 +6,14 @@ from typing import Dict, List, Optional import bpy -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID, +) from openpype.hosts.blender.api import plugin from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index cec088076c..4dfa96167f 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -10,6 +10,7 @@ from openpype import lib from openpype.pipeline import ( legacy_create, get_representation_path, + AVALON_CONTAINER_ID, ) from openpype.hosts.blender.api import ( plugin, @@ -18,7 +19,6 @@ from openpype.hosts.blender.api import ( from openpype.hosts.blender.api.pipeline import ( AVALON_CONTAINERS, AVALON_PROPERTY, - AVALON_CONTAINER_ID ) diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index 930c6abe29..aae03cce17 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -4,13 +4,14 @@ Basic avalon integration import os import contextlib from avalon import api as avalon -from avalon.pipeline import AVALON_CONTAINER_ID from pyblish import api as pyblish + from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from .lib import ( set_segment_data_marker, diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index 92e54ad6f5..d498f0fd75 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -8,13 +8,13 @@ import contextlib import pyblish.api import avalon.api -from avalon.pipeline import AVALON_CONTAINER_ID from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) import openpype.hosts.fusion diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index f967da15ca..cdc58a6f19 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -6,7 +6,6 @@ import pyblish.api from avalon import io import avalon.api -from avalon.pipeline import AVALON_CONTAINER_ID from openpype import lib from openpype.lib import register_event_callback @@ -14,6 +13,7 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) import openpype.hosts.harmony import openpype.hosts.harmony.api as harmony diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index eff126c0b6..8e70c6a638 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -4,7 +4,7 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from avalon.pipeline import AVALON_CONTAINER_ID + from avalon import api as avalon from avalon import schema from pyblish import api as pyblish @@ -13,6 +13,7 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from openpype.tools.utils import host_tools from . import lib, menu, events diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 7d4e58efb7..d079c9ea81 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -8,12 +8,12 @@ import hdefereval import pyblish.api import avalon.api -from avalon.pipeline import AVALON_CONTAINER_ID from avalon.lib import find_submodule from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, + AVALON_CONTAINER_ID, ) import openpype.hosts.houdini from openpype.hosts.houdini.api import lib diff --git a/openpype/hosts/houdini/plugins/load/load_image.py b/openpype/hosts/houdini/plugins/load/load_image.py index bd9ea3eee3..671f08f18f 100644 --- a/openpype/hosts/houdini/plugins/load/load_image.py +++ b/openpype/hosts/houdini/plugins/load/load_image.py @@ -3,6 +3,7 @@ import os from openpype.pipeline import ( load, get_representation_path, + AVALON_CONTAINER_ID, ) from openpype.hosts.houdini.api import lib, pipeline @@ -73,7 +74,7 @@ class ImageLoader(load.LoaderPlugin): # Imprint it manually data = { "schema": "avalon-core:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, "loader": str(self.__class__.__name__), diff --git a/openpype/hosts/houdini/plugins/load/load_usd_layer.py b/openpype/hosts/houdini/plugins/load/load_usd_layer.py index d803e6abfe..48580fc3aa 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_layer.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_layer.py @@ -1,8 +1,9 @@ from openpype.pipeline import ( load, get_representation_path, + AVALON_CONTAINER_ID, ) -from openpype.hosts.houdini.api import lib, pipeline +from openpype.hosts.houdini.api import lib class USDSublayerLoader(load.LoaderPlugin): @@ -43,7 +44,7 @@ class USDSublayerLoader(load.LoaderPlugin): # Imprint it manually data = { "schema": "avalon-core:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, "loader": str(self.__class__.__name__), diff --git a/openpype/hosts/houdini/plugins/load/load_usd_reference.py b/openpype/hosts/houdini/plugins/load/load_usd_reference.py index fdb443f4cf..6851c77e6d 100644 --- a/openpype/hosts/houdini/plugins/load/load_usd_reference.py +++ b/openpype/hosts/houdini/plugins/load/load_usd_reference.py @@ -1,8 +1,9 @@ from openpype.pipeline import ( load, get_representation_path, + AVALON_CONTAINER_ID, ) -from openpype.hosts.houdini.api import lib, pipeline +from openpype.hosts.houdini.api import lib class USDReferenceLoader(load.LoaderPlugin): @@ -43,7 +44,7 @@ class USDReferenceLoader(load.LoaderPlugin): # Imprint it manually data = { "schema": "avalon-core:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "name": node_name, "namespace": namespace, "loader": str(self.__class__.__name__), diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index 5cdc3ff4fd..12446b6d1c 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -10,7 +10,6 @@ import pyblish.api import avalon.api from avalon.lib import find_submodule -from avalon.pipeline import AVALON_CONTAINER_ID import openpype.hosts.maya from openpype.tools.utils import host_tools @@ -24,6 +23,7 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from openpype.hosts.maya.lib import copy_workspace_mel from . import menu, lib diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index 84379bc145..3721868823 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -4,11 +4,11 @@ from maya import cmds import qargparse -from avalon.pipeline import AVALON_CONTAINER_ID from openpype.pipeline import ( LegacyCreator, LoaderPlugin, get_representation_path, + AVALON_CONTAINER_ID, ) from .pipeline import containerise diff --git a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py index 389995d30c..3a47cdadb5 100644 --- a/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py +++ b/openpype/hosts/maya/plugins/publish/extract_maya_scene_raw.py @@ -6,7 +6,7 @@ from maya import cmds import openpype.api from openpype.hosts.maya.api.lib import maintained_selection -from avalon.pipeline import AVALON_CONTAINER_ID +from openpype.pipeline import AVALON_CONTAINER_ID class ExtractMayaSceneRaw(openpype.api.Extractor): diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index fd2e16b8d3..657b24eb2a 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -6,7 +6,6 @@ import nuke import pyblish.api import avalon.api -from avalon import pipeline import openpype from openpype.api import ( @@ -19,6 +18,7 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from openpype.tools.utils import host_tools @@ -330,7 +330,7 @@ def containerise(node, data = OrderedDict( [ ("schema", "openpype:container-2.0"), - ("id", pipeline.AVALON_CONTAINER_ID), + ("id", AVALON_CONTAINER_ID), ("name", name), ("namespace", namespace), ("loader", str(loader)), diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index e814e1ca4d..ac7f20ab59 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -3,7 +3,7 @@ from Qt import QtWidgets import pyblish.api import avalon.api -from avalon import pipeline, io +from avalon import io from openpype.api import Logger from openpype.lib import register_event_callback @@ -11,6 +11,7 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) import openpype.hosts.photoshop @@ -221,7 +222,7 @@ def containerise( data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "name": name, "namespace": namespace, "loader": str(loader), diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index fa309e3503..0083a4547d 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -6,13 +6,13 @@ import contextlib from collections import OrderedDict from avalon import api as avalon from avalon import schema -from avalon.pipeline import AVALON_CONTAINER_ID from pyblish import api as pyblish from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from . import lib from . import PLUGINS_DIR diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index 46c9d3a1dd..ec880a1abc 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -10,7 +10,6 @@ import pyblish.api import avalon.api from avalon import io -from avalon.pipeline import AVALON_CONTAINER_ID from openpype.hosts import tvpaint from openpype.api import get_current_project_settings @@ -19,6 +18,7 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from .lib import ( diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 9ec11b942d..713c588976 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -4,13 +4,13 @@ import logging from typing import List import pyblish.api -from avalon.pipeline import AVALON_CONTAINER_ID from avalon import api from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, deregister_loader_plugin_path, + AVALON_CONTAINER_ID, ) from openpype.tools.utils import host_tools import openpype.hosts.unreal diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py index 3508fe5ed7..6ac3531b40 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py @@ -2,8 +2,10 @@ """Loader for published alembics.""" import os -from avalon import pipeline -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID +) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline @@ -117,7 +119,7 @@ class PointCacheAlembicLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py index 180942de51..b2c3889f68 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py @@ -2,8 +2,10 @@ """Load Skeletal Mesh alembics.""" import os -from avalon import pipeline -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID +) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -81,7 +83,7 @@ class SkeletalMeshAlembicLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py index 4e00af1d97..5a73c72c64 100644 --- a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py +++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py @@ -2,8 +2,10 @@ """Loader for Static Mesh alembics.""" import os -from avalon import pipeline -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID +) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -100,7 +102,7 @@ class StaticMeshAlembicLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 8ef81f7851..c9a1633031 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -3,8 +3,10 @@ import os import json -from avalon import pipeline -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID +) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -135,7 +137,7 @@ class AnimationFBXLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 0de9470ef9..40bca0b0c7 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -2,7 +2,8 @@ """Load camera from FBX.""" import os -from avalon import io, pipeline +from avalon import io +from openpype.pipeline import AVALON_CONTAINER_ID from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -116,7 +117,7 @@ class CameraLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index 19ee179d20..7f6ce7d822 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -11,12 +11,12 @@ from unreal import AssetToolsHelpers from unreal import FBXImportType from unreal import MathLibrary as umath -from avalon.pipeline import AVALON_CONTAINER_ID from openpype.pipeline import ( discover_loader_plugins, loaders_from_representation, load_container, get_representation_path, + AVALON_CONTAINER_ID, ) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline diff --git a/openpype/hosts/unreal/plugins/load/load_rig.py b/openpype/hosts/unreal/plugins/load/load_rig.py index 3d5616364c..ff844a5e94 100644 --- a/openpype/hosts/unreal/plugins/load/load_rig.py +++ b/openpype/hosts/unreal/plugins/load/load_rig.py @@ -2,8 +2,10 @@ """Load Skeletal Meshes form FBX.""" import os -from avalon import pipeline -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID +) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -101,7 +103,7 @@ class SkeletalMeshFBXLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py index 587fc83a77..282d249947 100644 --- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py +++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py @@ -2,8 +2,10 @@ """Load Static meshes form FBX.""" import os -from avalon import pipeline -from openpype.pipeline import get_representation_path +from openpype.pipeline import ( + get_representation_path, + AVALON_CONTAINER_ID +) from openpype.hosts.unreal.api import plugin from openpype.hosts.unreal.api import pipeline as unreal_pipeline import unreal # noqa @@ -95,7 +97,7 @@ class StaticMeshFBXLoader(plugin.Loader): data = { "schema": "openpype:container-2.0", - "id": pipeline.AVALON_CONTAINER_ID, + "id": AVALON_CONTAINER_ID, "asset": asset, "namespace": asset_dir, "container_name": container_name, diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 26970e4edc..0fc6af744a 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -1,3 +1,7 @@ +from .constants import ( + AVALON_CONTAINER_ID, +) + from .lib import attribute_definitions from .create import ( @@ -43,6 +47,8 @@ from .publish import ( __all__ = ( + "AVALON_CONTAINER_ID", + "attribute_definitions", # --- Create --- diff --git a/openpype/pipeline/constants.py b/openpype/pipeline/constants.py new file mode 100644 index 0000000000..90890cc0a8 --- /dev/null +++ b/openpype/pipeline/constants.py @@ -0,0 +1,2 @@ +# Metadata ID of loaded container into scene +AVALON_CONTAINER_ID = "pyblish.avalon.container" From e323429ab4939996ae058cdba95e09d07747c7f5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 16:08:15 +0100 Subject: [PATCH 117/854] moved host workfile extensions --- openpype/hosts/aftereffects/api/workio.py | 4 ++-- openpype/hosts/blender/api/workio.py | 5 +++-- openpype/hosts/fusion/api/workio.py | 6 ++++-- openpype/hosts/harmony/api/workio.py | 5 +++-- openpype/hosts/hiero/api/workio.py | 8 ++++---- openpype/hosts/houdini/api/workio.py | 4 ++-- openpype/hosts/maya/api/workio.py | 5 +++-- openpype/hosts/nuke/api/workio.py | 5 +++-- openpype/hosts/photoshop/api/workio.py | 5 ++--- openpype/hosts/tvpaint/api/workio.py | 3 ++- openpype/lib/applications.py | 3 ++- openpype/pipeline/__init__.py | 2 ++ openpype/pipeline/constants.py | 17 +++++++++++++++++ 13 files changed, 49 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/aftereffects/api/workio.py b/openpype/hosts/aftereffects/api/workio.py index 04c7834d8f..5a8f86ead5 100644 --- a/openpype/hosts/aftereffects/api/workio.py +++ b/openpype/hosts/aftereffects/api/workio.py @@ -1,8 +1,8 @@ """Host API required Work Files tool""" import os +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS from .launch_logic import get_stub -from avalon import api def _active_document(): @@ -14,7 +14,7 @@ def _active_document(): def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["aftereffects"] + return HOST_WORKFILE_EXTENSIONS["aftereffects"] def has_unsaved_changes(): diff --git a/openpype/hosts/blender/api/workio.py b/openpype/hosts/blender/api/workio.py index fd68761982..5eb9f82999 100644 --- a/openpype/hosts/blender/api/workio.py +++ b/openpype/hosts/blender/api/workio.py @@ -4,7 +4,8 @@ from pathlib import Path from typing import List, Optional import bpy -from avalon import api + +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS class OpenFileCacher: @@ -77,7 +78,7 @@ def has_unsaved_changes() -> bool: def file_extensions() -> List[str]: """Return the supported file extensions for Blender scene files.""" - return api.HOST_WORKFILE_EXTENSIONS["blender"] + return HOST_WORKFILE_EXTENSIONS["blender"] def work_root(session: dict) -> str: diff --git a/openpype/hosts/fusion/api/workio.py b/openpype/hosts/fusion/api/workio.py index ec9ac7481a..a1710c6e3a 100644 --- a/openpype/hosts/fusion/api/workio.py +++ b/openpype/hosts/fusion/api/workio.py @@ -1,12 +1,14 @@ """Host API required Work Files tool""" import sys import os -from avalon import api + +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS + from .pipeline import get_current_comp def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["fusion"] + return HOST_WORKFILE_EXTENSIONS["fusion"] def has_unsaved_changes(): diff --git a/openpype/hosts/harmony/api/workio.py b/openpype/hosts/harmony/api/workio.py index 38a00ae414..ab1cb9b1a9 100644 --- a/openpype/hosts/harmony/api/workio.py +++ b/openpype/hosts/harmony/api/workio.py @@ -2,20 +2,21 @@ import os import shutil +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS + from .lib import ( ProcessContext, get_local_harmony_path, zip_and_move, launch_zip_file ) -from avalon import api # used to lock saving until previous save is done. save_disabled = False def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["harmony"] + return HOST_WORKFILE_EXTENSIONS["harmony"] def has_unsaved_changes(): diff --git a/openpype/hosts/hiero/api/workio.py b/openpype/hosts/hiero/api/workio.py index dacb11624f..394cb5e2ab 100644 --- a/openpype/hosts/hiero/api/workio.py +++ b/openpype/hosts/hiero/api/workio.py @@ -1,14 +1,14 @@ import os import hiero -from avalon import api + from openpype.api import Logger +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS - -log = Logger().get_logger(__name__) +log = Logger.get_logger(__name__) def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["hiero"] + return HOST_WORKFILE_EXTENSIONS["hiero"] def has_unsaved_changes(): diff --git a/openpype/hosts/houdini/api/workio.py b/openpype/hosts/houdini/api/workio.py index e7310163ea..e0213023fd 100644 --- a/openpype/hosts/houdini/api/workio.py +++ b/openpype/hosts/houdini/api/workio.py @@ -2,11 +2,11 @@ import os import hou -from avalon import api +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["houdini"] + return HOST_WORKFILE_EXTENSIONS["houdini"] def has_unsaved_changes(): diff --git a/openpype/hosts/maya/api/workio.py b/openpype/hosts/maya/api/workio.py index 698c48e81e..fd4961c4bf 100644 --- a/openpype/hosts/maya/api/workio.py +++ b/openpype/hosts/maya/api/workio.py @@ -1,11 +1,12 @@ """Host API required Work Files tool""" import os from maya import cmds -from avalon import api + +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS def file_extensions(): - return api.HOST_WORKFILE_EXTENSIONS["maya"] + return HOST_WORKFILE_EXTENSIONS["maya"] def has_unsaved_changes(): diff --git a/openpype/hosts/nuke/api/workio.py b/openpype/hosts/nuke/api/workio.py index dbc24fdc9b..68fcb0927f 100644 --- a/openpype/hosts/nuke/api/workio.py +++ b/openpype/hosts/nuke/api/workio.py @@ -1,11 +1,12 @@ """Host API required Work Files tool""" import os import nuke -import avalon.api + +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS def file_extensions(): - return avalon.api.HOST_WORKFILE_EXTENSIONS["nuke"] + return HOST_WORKFILE_EXTENSIONS["nuke"] def has_unsaved_changes(): diff --git a/openpype/hosts/photoshop/api/workio.py b/openpype/hosts/photoshop/api/workio.py index 0bf3ed2bd9..951c5dbfff 100644 --- a/openpype/hosts/photoshop/api/workio.py +++ b/openpype/hosts/photoshop/api/workio.py @@ -1,8 +1,7 @@ """Host API required Work Files tool""" import os -import avalon.api - +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS from . import lib @@ -15,7 +14,7 @@ def _active_document(): def file_extensions(): - return avalon.api.HOST_WORKFILE_EXTENSIONS["photoshop"] + return HOST_WORKFILE_EXTENSIONS["photoshop"] def has_unsaved_changes(): diff --git a/openpype/hosts/tvpaint/api/workio.py b/openpype/hosts/tvpaint/api/workio.py index c513bec6cf..88bdd7117e 100644 --- a/openpype/hosts/tvpaint/api/workio.py +++ b/openpype/hosts/tvpaint/api/workio.py @@ -4,6 +4,7 @@ """ from avalon import api +from openpype.pipeline import HOST_WORKFILE_EXTENSIONS from .lib import ( execute_george, execute_george_through_file @@ -47,7 +48,7 @@ def has_unsaved_changes(): def file_extensions(): """Return the supported file extensions for Blender scene files.""" - return api.HOST_WORKFILE_EXTENSIONS["tvpaint"] + return HOST_WORKFILE_EXTENSIONS["tvpaint"] def work_root(session): diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index ef175ac89a..a7621be2b5 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1544,6 +1544,7 @@ def _prepare_last_workfile(data, workdir): workdir (str): Path to folder where workfiles should be stored. """ import avalon.api + from openpype.pipeline import HOST_WORKFILE_EXTENSIONS log = data["log"] @@ -1592,7 +1593,7 @@ def _prepare_last_workfile(data, workdir): # Last workfile path last_workfile_path = data.get("last_workfile_path") or "" if not last_workfile_path: - extensions = avalon.api.HOST_WORKFILE_EXTENSIONS.get(app.host_name) + extensions = HOST_WORKFILE_EXTENSIONS.get(app.host_name) if extensions: anatomy = data["anatomy"] project_settings = data["project_settings"] diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 0fc6af744a..000441c720 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -1,5 +1,6 @@ from .constants import ( AVALON_CONTAINER_ID, + HOST_WORKFILE_EXTENSIONS, ) from .lib import attribute_definitions @@ -48,6 +49,7 @@ from .publish import ( __all__ = ( "AVALON_CONTAINER_ID", + "HOST_WORKFILE_EXTENSIONS", "attribute_definitions", diff --git a/openpype/pipeline/constants.py b/openpype/pipeline/constants.py index 90890cc0a8..e6496cbf95 100644 --- a/openpype/pipeline/constants.py +++ b/openpype/pipeline/constants.py @@ -1,2 +1,19 @@ # Metadata ID of loaded container into scene AVALON_CONTAINER_ID = "pyblish.avalon.container" + +# TODO get extensions from host implementations +HOST_WORKFILE_EXTENSIONS = { + "blender": [".blend"], + "celaction": [".scn"], + "tvpaint": [".tvpp"], + "fusion": [".comp"], + "harmony": [".zip"], + "houdini": [".hip", ".hiplc", ".hipnc"], + "maya": [".ma", ".mb"], + "nuke": [".nk"], + "hiero": [".hrox"], + "photoshop": [".psd", ".psb"], + "premiere": [".prproj"], + "resolve": [".drp"], + "aftereffects": [".aep"] +} From 93eca512b8a2860d875934924765009d4f51b777 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 18 Mar 2022 16:23:00 +0100 Subject: [PATCH 118/854] use ObjectId imported from bson instead of avalon.io --- openpype/hosts/aftereffects/api/pipeline.py | 3 ++- .../blender/plugins/publish/extract_layout.py | 8 +++++--- openpype/hosts/fusion/api/lib.py | 3 ++- openpype/hosts/harmony/api/pipeline.py | 3 ++- openpype/hosts/hiero/api/lib.py | 5 ++++- openpype/hosts/maya/api/setdress.py | 10 ++++++---- .../maya/plugins/inventory/import_modelrender.py | 3 ++- .../hosts/maya/plugins/load/load_vrayproxy.py | 4 +++- openpype/hosts/nuke/api/command.py | 5 +++-- openpype/hosts/nuke/api/lib.py | 6 +++--- openpype/hosts/photoshop/api/pipeline.py | 3 ++- .../unreal/plugins/publish/extract_layout.py | 4 +++- openpype/lib/avalon_context.py | 4 +++- openpype/pipeline/load/utils.py | 15 ++++++++------- .../publish/collect_scene_loaded_versions.py | 5 +++-- .../plugins/publish/integrate_hero_version.py | 7 ++++--- openpype/plugins/publish/integrate_inputlinks.py | 8 +++++--- openpype/plugins/publish/integrate_new.py | 11 ++++++----- openpype/tools/mayalookassigner/commands.py | 3 ++- openpype/tools/mayalookassigner/vray_proxies.py | 3 ++- openpype/tools/sceneinventory/model.py | 3 ++- openpype/tools/sceneinventory/switch_dialog.py | 5 +++-- openpype/tools/sceneinventory/view.py | 9 +++++---- 23 files changed, 80 insertions(+), 50 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 47d0bdacc5..b578b03d70 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -2,6 +2,7 @@ import os import sys from Qt import QtWidgets +from bson.objectid import ObjectId import pyblish.api import avalon.api @@ -43,7 +44,7 @@ def check_inventory(): representation = container['representation'] representation_doc = io.find_one( { - "_id": io.ObjectId(representation), + "_id": ObjectId(representation), "type": "representation" }, projection={"parent": True} diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index cc7c90f4c8..b78a193d81 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -1,6 +1,8 @@ import os import json +from bson.objectid import ObjectId + import bpy import bpy_extras import bpy_extras.anim_utils @@ -140,7 +142,7 @@ class ExtractLayout(openpype.api.Extractor): blend = io.find_one( { "type": "representation", - "parent": io.ObjectId(parent), + "parent": ObjectId(parent), "name": "blend" }, projection={"_id": True}) @@ -151,7 +153,7 @@ class ExtractLayout(openpype.api.Extractor): fbx = io.find_one( { "type": "representation", - "parent": io.ObjectId(parent), + "parent": ObjectId(parent), "name": "fbx" }, projection={"_id": True}) @@ -162,7 +164,7 @@ class ExtractLayout(openpype.api.Extractor): abc = io.find_one( { "type": "representation", - "parent": io.ObjectId(parent), + "parent": ObjectId(parent), "name": "abc" }, projection={"_id": True}) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 2bb5ea8aae..f7a2360bfa 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -3,6 +3,7 @@ import sys import re import contextlib +from bson.objectid import ObjectId from Qt import QtGui from avalon import io @@ -92,7 +93,7 @@ def switch_item(container, # Collect any of current asset, subset and representation if not provided # so we can use the original name from those. if any(not x for x in [asset_name, subset_name, representation_name]): - _id = io.ObjectId(container["representation"]) + _id = ObjectId(container["representation"]) representation = io.find_one({"type": "representation", "_id": _id}) version, subset, asset, project = io.parenthood(representation) diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index cdc58a6f19..420e9720db 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -2,6 +2,7 @@ import os from pathlib import Path import logging +from bson.objectid import ObjectId import pyblish.api from avalon import io @@ -113,7 +114,7 @@ def check_inventory(): representation = container['representation'] representation_doc = io.find_one( { - "_id": io.ObjectId(representation), + "_id": ObjectId(representation), "type": "representation" }, projection={"parent": True} diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index a9467ae5a4..df3b24ff2c 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -8,7 +8,10 @@ import platform import ast import shutil import hiero + from Qt import QtWidgets +from bson.objectid import ObjectId + import avalon.api as avalon import avalon.io from openpype.api import (Logger, Anatomy, get_anatomy_settings) @@ -1006,7 +1009,7 @@ def check_inventory_versions(): # get representation from io representation = io.find_one({ "type": "representation", - "_id": io.ObjectId(container["representation"]) + "_id": ObjectId(container["representation"]) }) # Get start frame from version data diff --git a/openpype/hosts/maya/api/setdress.py b/openpype/hosts/maya/api/setdress.py index 96a9700b88..0b60564e5e 100644 --- a/openpype/hosts/maya/api/setdress.py +++ b/openpype/hosts/maya/api/setdress.py @@ -6,6 +6,8 @@ import contextlib import copy import six +from bson.objectid import ObjectId + from maya import cmds from avalon import io @@ -282,7 +284,7 @@ def update_package_version(container, version): # Versioning (from `core.maya.pipeline`) current_representation = io.find_one({ - "_id": io.ObjectId(container["representation"]) + "_id": ObjectId(container["representation"]) }) assert current_representation is not None, "This is a bug" @@ -327,7 +329,7 @@ def update_package(set_container, representation): # Load the original package data current_representation = io.find_one({ - "_id": io.ObjectId(set_container['representation']), + "_id": ObjectId(set_container['representation']), "type": "representation" }) @@ -478,10 +480,10 @@ def update_scene(set_container, containers, current_data, new_data, new_file): # They *must* use the same asset, subset and Loader for # `update_container` to make sense. old = io.find_one({ - "_id": io.ObjectId(representation_current) + "_id": ObjectId(representation_current) }) new = io.find_one({ - "_id": io.ObjectId(representation_new) + "_id": ObjectId(representation_new) }) is_valid = compare_representations(old=old, new=new) if not is_valid: diff --git a/openpype/hosts/maya/plugins/inventory/import_modelrender.py b/openpype/hosts/maya/plugins/inventory/import_modelrender.py index c5d3d0c8f4..50ee7a15fc 100644 --- a/openpype/hosts/maya/plugins/inventory/import_modelrender.py +++ b/openpype/hosts/maya/plugins/inventory/import_modelrender.py @@ -1,5 +1,6 @@ import json from avalon import api, io +from bson.objectid import ObjectId from openpype.pipeline import ( get_representation_context, get_representation_path_from_context, @@ -39,7 +40,7 @@ class ImportModelRender(api.InventoryAction): nodes.append(n) repr_doc = io.find_one({ - "_id": io.ObjectId(container["representation"]), + "_id": ObjectId(container["representation"]), }) version_id = repr_doc["parent"] diff --git a/openpype/hosts/maya/plugins/load/load_vrayproxy.py b/openpype/hosts/maya/plugins/load/load_vrayproxy.py index 5b79b1efb3..69d54df62b 100644 --- a/openpype/hosts/maya/plugins/load/load_vrayproxy.py +++ b/openpype/hosts/maya/plugins/load/load_vrayproxy.py @@ -7,6 +7,8 @@ loader will use them instead of native vray vrmesh format. """ import os +from bson.objectid import ObjectId + import maya.cmds as cmds from avalon import io @@ -186,7 +188,7 @@ class VRayProxyLoader(load.LoaderPlugin): abc_rep = io.find_one( { "type": "representation", - "parent": io.ObjectId(version_id), + "parent": ObjectId(version_id), "name": "abc" }) diff --git a/openpype/hosts/nuke/api/command.py b/openpype/hosts/nuke/api/command.py index 212d4757c6..6f74c08e97 100644 --- a/openpype/hosts/nuke/api/command.py +++ b/openpype/hosts/nuke/api/command.py @@ -1,6 +1,7 @@ import logging import contextlib import nuke +from bson.objectid import ObjectId from avalon import api, io @@ -70,10 +71,10 @@ def get_handles(asset): if "visualParent" in data: vp = data["visualParent"] if vp is not None: - parent_asset = io.find_one({"_id": io.ObjectId(vp)}) + parent_asset = io.find_one({"_id": ObjectId(vp)}) if parent_asset is None: - parent_asset = io.find_one({"_id": io.ObjectId(asset["parent"])}) + parent_asset = io.find_one({"_id": ObjectId(asset["parent"])}) if parent_asset is not None: return get_handles(parent_asset) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index dba7ec1b85..3c8ba3e77c 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -6,10 +6,11 @@ import contextlib from collections import OrderedDict import clique +from bson.objectid import ObjectId import nuke -from avalon import api, io, lib +from avalon import api, io from openpype.api import ( Logger, @@ -20,7 +21,6 @@ from openpype.api import ( get_workdir_data, get_asset, get_current_project_settings, - ApplicationManager ) from openpype.tools.utils import host_tools from openpype.lib.path_tools import HostDirmap @@ -570,7 +570,7 @@ def check_inventory_versions(): # get representation from io representation = io.find_one({ "type": "representation", - "_id": io.ObjectId(avalon_knob_data["representation"]) + "_id": ObjectId(avalon_knob_data["representation"]) }) # Failsafe for not finding the representation. diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index ac7f20ab59..c2ad0ac7b0 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -1,5 +1,6 @@ import os from Qt import QtWidgets +from bson.objectid import ObjectId import pyblish.api import avalon.api @@ -37,7 +38,7 @@ def check_inventory(): representation = container['representation'] representation_doc = io.find_one( { - "_id": io.ObjectId(representation), + "_id": ObjectId(representation), "type": "representation" }, projection={"parent": True} diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index 2d09b0e7bd..f34a47b89f 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -3,6 +3,8 @@ import os import json import math +from bson.objectid import ObjectId + import unreal from unreal import EditorLevelLibrary as ell from unreal import EditorAssetLibrary as eal @@ -62,7 +64,7 @@ class ExtractLayout(openpype.api.Extractor): blend = io.find_one( { "type": "representation", - "parent": io.ObjectId(parent), + "parent": ObjectId(parent), "name": "blend" }, projection={"_id": True}) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 26beba41ee..e16d14dd16 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -9,6 +9,8 @@ import collections import functools import getpass +from bson.objectid import ObjectId + from openpype.settings import ( get_project_settings, get_system_settings @@ -168,7 +170,7 @@ def any_outdated(): representation_doc = avalon.io.find_one( { - "_id": avalon.io.ObjectId(representation), + "_id": ObjectId(representation), "type": "representation" }, projection={"parent": True} diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 118f86a570..e48074ebb1 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -7,6 +7,7 @@ import inspect import numbers import six +from bson.objectid import ObjectId from avalon import io, schema from avalon.api import Session, registered_root @@ -67,7 +68,7 @@ def get_repres_contexts(representation_ids, dbcon=None): _representation_ids = [] for repre_id in representation_ids: if isinstance(repre_id, six.string_types): - repre_id = io.ObjectId(repre_id) + repre_id = ObjectId(repre_id) _representation_ids.append(repre_id) repre_docs = dbcon.find({ @@ -174,7 +175,7 @@ def get_subset_contexts(subset_ids, dbcon=None): _subset_ids = set() for subset_id in subset_ids: if isinstance(subset_id, six.string_types): - subset_id = io.ObjectId(subset_id) + subset_id = ObjectId(subset_id) _subset_ids.add(subset_id) subset_docs = dbcon.find({ @@ -217,7 +218,7 @@ def get_representation_context(representation): """Return parenthood context for representation. Args: - representation (str or io.ObjectId or dict): The representation id + representation (str or ObjectId or dict): The representation id or full representation as returned by the database. Returns: @@ -227,9 +228,9 @@ def get_representation_context(representation): assert representation is not None, "This is a bug" - if isinstance(representation, (six.string_types, io.ObjectId)): + if isinstance(representation, (six.string_types, ObjectId)): representation = io.find_one( - {"_id": io.ObjectId(str(representation))}) + {"_id": ObjectId(str(representation))}) version, subset, asset, project = io.parenthood(representation) @@ -340,7 +341,7 @@ def load_container( Args: Loader (Loader): The loader class to trigger. - representation (str or io.ObjectId or dict): The representation id + representation (str or ObjectId or dict): The representation id or full representation as returned by the database. namespace (str, Optional): The namespace to assign. Defaults to None. name (str, Optional): The name to assign. Defaults to subset name. @@ -404,7 +405,7 @@ def update_container(container, version=-1): # Compute the different version from 'representation' current_representation = io.find_one({ - "_id": io.ObjectId(container["representation"]) + "_id": ObjectId(container["representation"]) }) assert current_representation is not None, "This is a bug" diff --git a/openpype/plugins/publish/collect_scene_loaded_versions.py b/openpype/plugins/publish/collect_scene_loaded_versions.py index d8119846c6..6746757e5f 100644 --- a/openpype/plugins/publish/collect_scene_loaded_versions.py +++ b/openpype/plugins/publish/collect_scene_loaded_versions.py @@ -1,3 +1,4 @@ +from bson.objectid import ObjectId import pyblish.api from avalon import api, io @@ -35,7 +36,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): loaded_versions = [] _containers = list(host.ls()) - _repr_ids = [io.ObjectId(c["representation"]) for c in _containers] + _repr_ids = [ObjectId(c["representation"]) for c in _containers] version_by_repr = { str(doc["_id"]): doc["parent"] for doc in io.find({"_id": {"$in": _repr_ids}}, projection={"parent": 1}) @@ -46,7 +47,7 @@ class CollectSceneLoadedVersions(pyblish.api.ContextPlugin): # may have more then one representation that are same version version = { "subsetName": con["name"], - "representation": io.ObjectId(con["representation"]), + "representation": ObjectId(con["representation"]), "version": version_by_repr[con["representation"]], # _id } loaded_versions.append(version) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 60245314f4..466606d08b 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -4,6 +4,7 @@ import clique import errno import shutil +from bson.objectid import ObjectId from pymongo import InsertOne, ReplaceOne import pyblish.api from avalon import api, io, schema @@ -161,7 +162,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): if old_version: new_version_id = old_version["_id"] else: - new_version_id = io.ObjectId() + new_version_id = ObjectId() new_hero_version = { "_id": new_version_id, @@ -384,7 +385,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): # Create representation else: - repre["_id"] = io.ObjectId() + repre["_id"] = ObjectId() bulk_writes.append( InsertOne(repre) ) @@ -420,7 +421,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): else: repre["old_id"] = repre["_id"] - repre["_id"] = io.ObjectId() + repre["_id"] = ObjectId() repre["type"] = "archived_representation" bulk_writes.append( InsertOne(repre) diff --git a/openpype/plugins/publish/integrate_inputlinks.py b/openpype/plugins/publish/integrate_inputlinks.py index f973dfc963..11cffc4638 100644 --- a/openpype/plugins/publish/integrate_inputlinks.py +++ b/openpype/plugins/publish/integrate_inputlinks.py @@ -1,8 +1,10 @@ - from collections import OrderedDict -from avalon import io + +from bson.objectid import ObjectId import pyblish.api +from avalon import io + class IntegrateInputLinks(pyblish.api.ContextPlugin): """Connecting version level dependency links""" @@ -104,7 +106,7 @@ class IntegrateInputLinks(pyblish.api.ContextPlugin): # future. link = OrderedDict() link["type"] = link_type - link["id"] = io.ObjectId(input_id) + link["id"] = ObjectId(input_id) link["linkedBy"] = "publish" if "inputLinks" not in version_doc["data"]: diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index e8dab089af..c26d3559ec 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -9,6 +9,7 @@ import six import re import shutil +from bson.objectid import ObjectId from pymongo import DeleteOne, InsertOne import pyblish.api from avalon import io @@ -293,7 +294,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): bulk_writes.append(DeleteOne({"_id": repre_id})) repre["orig_id"] = repre_id - repre["_id"] = io.ObjectId() + repre["_id"] = ObjectId() repre["type"] = "archived_representation" bulk_writes.append(InsertOne(repre)) @@ -572,7 +573,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): # Create new id if existing representations does not match if repre_id is None: - repre_id = io.ObjectId() + repre_id = ObjectId() data = repre.get("data") or {} data.update({'path': dst, 'template': template}) @@ -781,7 +782,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): families = [instance.data["family"]] families.extend(instance.data.get("families", [])) io.update_many( - {"type": "subset", "_id": io.ObjectId(subset["_id"])}, + {"type": "subset", "_id": ObjectId(subset["_id"])}, {"$set": {"data.families": families}} ) @@ -806,7 +807,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if subset_group: io.update_many({ 'type': 'subset', - '_id': io.ObjectId(subset_id) + '_id': ObjectId(subset_id) }, {'$set': {'data.subsetGroup': subset_group}}) def _get_subset_group(self, instance): @@ -1052,7 +1053,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): sync_project_presets = None rec = { - "_id": io.ObjectId(), + "_id": ObjectId(), "path": path } if size: diff --git a/openpype/tools/mayalookassigner/commands.py b/openpype/tools/mayalookassigner/commands.py index df72e41354..78fd51c7a3 100644 --- a/openpype/tools/mayalookassigner/commands.py +++ b/openpype/tools/mayalookassigner/commands.py @@ -2,6 +2,7 @@ from collections import defaultdict import logging import os +from bson.objectid import ObjectId import maya.cmds as cmds from avalon import io, api @@ -157,7 +158,7 @@ def create_items_from_nodes(nodes): return asset_view_items for _id, id_nodes in id_hashes.items(): - asset = io.find_one({"_id": io.ObjectId(_id)}, + asset = io.find_one({"_id": ObjectId(_id)}, projection={"name": True}) # Skip if asset id is not found diff --git a/openpype/tools/mayalookassigner/vray_proxies.py b/openpype/tools/mayalookassigner/vray_proxies.py index 6a9347449a..25621fc652 100644 --- a/openpype/tools/mayalookassigner/vray_proxies.py +++ b/openpype/tools/mayalookassigner/vray_proxies.py @@ -6,6 +6,7 @@ import logging import json import six +from bson.objectid import ObjectId import alembic.Abc from maya import cmds @@ -231,7 +232,7 @@ def get_latest_version(asset_id, subset): """ subset = io.find_one({"name": subset, - "parent": io.ObjectId(asset_id), + "parent": ObjectId(asset_id), "type": "subset"}) if not subset: raise RuntimeError("Subset does not exist: %s" % subset) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 7173ae751e..091d6ca925 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -5,6 +5,7 @@ from collections import defaultdict from Qt import QtCore, QtGui import qtawesome +from bson.objectid import ObjectId from avalon import api, io, schema from openpype.pipeline import HeroVersionType @@ -299,7 +300,7 @@ class InventoryModel(TreeModel): for repre_id, group_dict in sorted(grouped.items()): group_items = group_dict["items"] # Get parenthood per group - representation = io.find_one({"_id": io.ObjectId(repre_id)}) + representation = io.find_one({"_id": ObjectId(repre_id)}) if not representation: not_found["representation"].append(group_items) not_found_ids.append(repre_id) diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index 0e7b1b759a..252f5cde4c 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -2,6 +2,7 @@ import collections import logging from Qt import QtWidgets, QtCore import qtawesome +from bson.objectid import ObjectId from avalon import io, pipeline from openpype.pipeline import ( @@ -146,7 +147,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_ids = set() content_loaders = set() for item in self._items: - repre_ids.add(io.ObjectId(item["representation"])) + repre_ids.add(ObjectId(item["representation"])) content_loaders.add(item["loader"]) repres = list(io.find({ @@ -1306,7 +1307,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_docs_by_parent_id_by_name[parent_id][name] = repre_doc for container in self._items: - container_repre_id = io.ObjectId(container["representation"]) + container_repre_id = ObjectId(container["representation"]) container_repre = self.content_repres[container_repre_id] container_repre_name = container_repre["name"] diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py index c38390c614..76103b83a9 100644 --- a/openpype/tools/sceneinventory/view.py +++ b/openpype/tools/sceneinventory/view.py @@ -4,6 +4,7 @@ from functools import partial from Qt import QtWidgets, QtCore import qtawesome +from bson.objectid import ObjectId from avalon import io, api @@ -78,7 +79,7 @@ class SceneInventoryView(QtWidgets.QTreeView): repre_ids = [] for item in items: - item_id = io.ObjectId(item["representation"]) + item_id = ObjectId(item["representation"]) if item_id not in repre_ids: repre_ids.append(item_id) @@ -145,7 +146,7 @@ class SceneInventoryView(QtWidgets.QTreeView): def _on_switch_to_versioned(items): repre_ids = [] for item in items: - item_id = io.ObjectId(item["representation"]) + item_id = ObjectId(item["representation"]) if item_id not in repre_ids: repre_ids.append(item_id) @@ -195,7 +196,7 @@ class SceneInventoryView(QtWidgets.QTreeView): version_doc["name"] for item in items: - repre_id = io.ObjectId(item["representation"]) + repre_id = ObjectId(item["representation"]) version_id = version_id_by_repre_id.get(repre_id) version_name = version_name_by_id.get(version_id) if version_name is not None: @@ -658,7 +659,7 @@ class SceneInventoryView(QtWidgets.QTreeView): active = items[-1] # Get available versions for active representation - representation_id = io.ObjectId(active["representation"]) + representation_id = ObjectId(active["representation"]) representation = io.find_one({"_id": representation_id}) version = io.find_one({ "_id": representation["parent"] From 20e2bae01c06d009048c3386ce5145d6194c09a4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 18 Mar 2022 17:39:56 +0100 Subject: [PATCH 119/854] flame: wirtetap accepts also path like color policy --- openpype/hosts/flame/api/scripts/wiretap_com.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index ee906c2608..a85a85ae25 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -422,7 +422,7 @@ class WireTapCom(object): color_policy = color_policy or "Legacy" # check if the colour policy in custom dir - if not os.path.exists(color_policy): + if "/" not in color_policy: color_policy = "/syncolor/policies/Autodesk/{}".format( color_policy) From c6735927dcc62b2060bbac7c3d5bd52aee740783 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 18 Mar 2022 17:41:23 +0100 Subject: [PATCH 120/854] flame: fixing head and tail could be string value --- openpype/hosts/flame/api/lib.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 74d9e7607a..aa2cfcb96d 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -18,6 +18,7 @@ log = Logger.get_logger(__name__) FRAME_PATTERN = re.compile(r"[\._](\d+)[\.]") + class CTX: # singleton used for passing data between api modules app_framework = None @@ -538,9 +539,17 @@ def get_segment_attributes(segment): # head and tail with forward compatibility if segment.head: - clip_data["segment_head"] = int(segment.head) + # `infinite` can be also returned + if isinstance(segment.head, str): + clip_data["segment_head"] = 0 + else: + clip_data["segment_head"] = int(segment.head) if segment.tail: - clip_data["segment_tail"] = int(segment.tail) + # `infinite` can be also returned + if isinstance(segment.tail, str): + clip_data["segment_tail"] = 0 + else: + clip_data["segment_tail"] = int(segment.tail) # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ From 7825da773636eeaa96a2ac377db90836c0f32727 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 18 Mar 2022 17:43:04 +0100 Subject: [PATCH 121/854] flame: selected segments collected only once --- .../publish/collect_timeline_instances.py | 173 +++++++++--------- .../plugins/publish/collect_timeline_otio.py | 41 +++-- 2 files changed, 110 insertions(+), 104 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 70340ad7a2..390c55837c 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -34,119 +34,124 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): def process(self, context): project = context.data["flameProject"] sequence = context.data["flameSequence"] + selected_segments = context.data["flameSelectedSegments"] + self.log.debug("__ selected_segments: {}".format(selected_segments)) + self.otio_timeline = context.data["otioTimeline"] self.clips_in_reels = opfapi.get_clips_in_reels(project) self.fps = context.data["fps"] # process all sellected - with opfapi.maintained_segment_selection(sequence) as segments: - for segment in segments: - comment_attributes = self._get_comment_attributes(segment) - self.log.debug("_ comment_attributes: {}".format( - pformat(comment_attributes))) + for segment in selected_segments: + comment_attributes = self._get_comment_attributes(segment) + self.log.debug("__ segment.name: {}".format( + segment.name + )) + self.log.debug("_ comment_attributes: {}".format( + pformat(comment_attributes))) - clip_data = opfapi.get_segment_attributes(segment) - clip_name = clip_data["segment_name"] - self.log.debug("clip_name: {}".format(clip_name)) + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) - # get openpype tag data - marker_data = opfapi.get_segment_data_marker(segment) - self.log.debug("__ marker_data: {}".format( - pformat(marker_data))) + # get openpype tag data + marker_data = opfapi.get_segment_data_marker(segment) + self.log.debug("__ marker_data: {}".format( + pformat(marker_data))) - if not marker_data: - continue + if not marker_data: + continue - if marker_data.get("id") != "pyblish.avalon.instance": - continue + if marker_data.get("id") != "pyblish.avalon.instance": + continue - # get file path - file_path = clip_data["fpath"] + # get file path + file_path = clip_data["fpath"] - # get source clip - source_clip = self._get_reel_clip(file_path) + # get source clip + source_clip = self._get_reel_clip(file_path) - first_frame = opfapi.get_frame_from_filename(file_path) or 0 + first_frame = opfapi.get_frame_from_filename(file_path) or 0 - head, tail = self._get_head_tail(clip_data, first_frame) + head, tail = self._get_head_tail(clip_data, first_frame) - # solve handles length - marker_data["handleStart"] = min( - marker_data["handleStart"], head) - marker_data["handleEnd"] = min( - marker_data["handleEnd"], tail) + # solve handles length + marker_data["handleStart"] = min( + marker_data["handleStart"], head) + marker_data["handleEnd"] = min( + marker_data["handleEnd"], tail) - with_audio = bool(marker_data.pop("audio")) + with_audio = bool(marker_data.pop("audio")) - # add marker data to instance data - inst_data = dict(marker_data.items()) + # add marker data to instance data + inst_data = dict(marker_data.items()) - asset = marker_data["asset"] - subset = marker_data["subset"] + asset = marker_data["asset"] + subset = marker_data["subset"] - # insert family into families - family = marker_data["family"] - families = [str(f) for f in marker_data["families"]] - families.insert(0, str(family)) + # insert family into families + family = marker_data["family"] + families = [str(f) for f in marker_data["families"]] + families.insert(0, str(family)) - # form label - label = asset - if asset != clip_name: - label += " ({})".format(clip_name) - label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") + # form label + label = asset + if asset != clip_name: + label += " ({})".format(clip_name) + label += " {}".format(subset) + label += " {}".format("[" + ", ".join(families) + "]") - inst_data.update({ - "name": "{}_{}".format(asset, subset), - "label": label, - "asset": asset, - "item": segment, - "families": families, - "publish": marker_data["publish"], - "fps": self.fps, - "flameSourceClip": source_clip, - "sourceFirstFrame": int(first_frame), - "path": file_path - }) + inst_data.update({ + "name": "{}_{}".format(asset, subset), + "label": label, + "asset": asset, + "item": segment, + "families": families, + "publish": marker_data["publish"], + "fps": self.fps, + "flameSourceClip": source_clip, + "sourceFirstFrame": int(first_frame), + "path": file_path + }) - # get otio clip data - otio_data = self._get_otio_clip_instance_data(clip_data) or {} - self.log.debug("__ otio_data: {}".format(pformat(otio_data))) + # get otio clip data + otio_data = self._get_otio_clip_instance_data(clip_data) or {} + self.log.debug("__ otio_data: {}".format(pformat(otio_data))) - # add to instance data - inst_data.update(otio_data) - self.log.debug("__ inst_data: {}".format(pformat(inst_data))) + # add to instance data + inst_data.update(otio_data) + self.log.debug("__ inst_data: {}".format(pformat(inst_data))) - # add resolution - self._get_resolution_to_data(inst_data, context) + # add resolution + self._get_resolution_to_data(inst_data, context) - # add comment attributes if any - inst_data.update(comment_attributes) + # add comment attributes if any + inst_data.update(comment_attributes) - # create instance - instance = context.create_instance(**inst_data) + # create instance + instance = context.create_instance(**inst_data) - # add colorspace data - instance.data.update({ - "versionData": { - "colorspace": clip_data["colour_space"], - } - }) + # add colorspace data + instance.data.update({ + "versionData": { + "colorspace": clip_data["colour_space"], + } + }) - # create shot instance for shot attributes create/update - self._create_shot_instance(context, clip_name, **inst_data) + # create shot instance for shot attributes create/update + self._create_shot_instance(context, clip_name, **inst_data) - self.log.info("Creating instance: {}".format(instance)) - self.log.info( - "_ instance.data: {}".format(pformat(instance.data))) + self.log.info("Creating instance: {}".format(instance)) + self.log.info( + "_ instance.data: {}".format(pformat(instance.data))) - if not with_audio: - continue + if not with_audio: + continue - # add audioReview attribute to plate instance data - # if reviewTrack is on - if marker_data.get("reviewTrack") is not None: - instance.data["reviewAudio"] = True + # add audioReview attribute to plate instance data + # if reviewTrack is on + if marker_data.get("reviewTrack") is not None: + instance.data["reviewAudio"] = True def _get_comment_attributes(self, segment): comment = segment.comment.get_value() @@ -188,7 +193,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): # get pattern defined by type pattern = TXT_PATERN - if a_type in ("number" , "float"): + if a_type in ("number", "float"): pattern = NUM_PATERN res_goup = pattern.findall(value) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py index faa5be9d68..c6aeae7730 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_otio.py @@ -31,27 +31,28 @@ class CollecTimelineOTIO(pyblish.api.ContextPlugin): ) # adding otio timeline to context - with opfapi.maintained_segment_selection(sequence): + with opfapi.maintained_segment_selection(sequence) as selected_seg: otio_timeline = flame_export.create_otio_timeline(sequence) - instance_data = { - "name": subset_name, - "asset": asset_doc["name"], - "subset": subset_name, - "family": "workfile" - } + instance_data = { + "name": subset_name, + "asset": asset_doc["name"], + "subset": subset_name, + "family": "workfile" + } - # create instance with workfile - instance = context.create_instance(**instance_data) - self.log.info("Creating instance: {}".format(instance)) + # create instance with workfile + instance = context.create_instance(**instance_data) + self.log.info("Creating instance: {}".format(instance)) - # update context with main project attributes - context.data.update({ - "flameProject": project, - "flameSequence": sequence, - "otioTimeline": otio_timeline, - "currentFile": "Flame/{}/{}".format( - project.name, sequence.name - ), - "fps": float(str(sequence.frame_rate)[:-4]) - }) + # update context with main project attributes + context.data.update({ + "flameProject": project, + "flameSequence": sequence, + "otioTimeline": otio_timeline, + "currentFile": "Flame/{}/{}".format( + project.name, sequence.name + ), + "flameSelectedSegments": selected_seg, + "fps": float(str(sequence.frame_rate)[:-4]) + }) From 1b5ca6a86ef977f45f501d8a3571c4398915b740 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 19 Mar 2022 03:35:01 +0000 Subject: [PATCH 122/854] [Automated] Bump version --- CHANGELOG.md | 27 +++++++++++++++++++++++---- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c7820d8f..f20276cbd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,28 @@ # Changelog -## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-17) +## [3.9.2-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.0...3.9.1) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...HEAD) + +**πŸš€ Enhancements** + +- CI: change the version bump logic [\#2919](https://github.com/pypeclub/OpenPype/pull/2919) +- Deadline: Add headless argument [\#2916](https://github.com/pypeclub/OpenPype/pull/2916) +- Ftrack: Fill workfile in custom attribute [\#2906](https://github.com/pypeclub/OpenPype/pull/2906) +- Settings UI: Add simple tooltips for settings entities [\#2901](https://github.com/pypeclub/OpenPype/pull/2901) + +**πŸ› Bug fixes** + +- Ftrack: Missing Ftrack id after editorial publish [\#2905](https://github.com/pypeclub/OpenPype/pull/2905) +- AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) + +**πŸ”€ Refactored code** + +- General: Move formatting and workfile functions [\#2914](https://github.com/pypeclub/OpenPype/pull/2914) + +## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.1-nightly.3...3.9.1) **πŸš€ Enhancements** @@ -22,7 +42,6 @@ - General: Remove forgotten use of avalon Creator [\#2885](https://github.com/pypeclub/OpenPype/pull/2885) - General: Avoid circular import [\#2884](https://github.com/pypeclub/OpenPype/pull/2884) - Fixes for attaching loaded containers \(\#2837\) [\#2874](https://github.com/pypeclub/OpenPype/pull/2874) -- Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826) **πŸ”€ Refactored code** @@ -75,6 +94,7 @@ - Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) - Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828) - Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827) +- Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826) - Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) - Deadline: more detailed temp file name for environment json [\#2824](https://github.com/pypeclub/OpenPype/pull/2824) - General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821) @@ -92,7 +112,6 @@ - General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) - Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) - Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) -- General: Extract template formatting from anatomy [\#2766](https://github.com/pypeclub/OpenPype/pull/2766) ## [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 1ef25e3f48..2390309e76 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.1" +__version__ = "3.9.2-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index 7c09495a99..90e264d456 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.1" # OpenPype +version = "3.9.2-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 225698d006ab8492ece9dcdea45a50be363fcab0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sun, 20 Mar 2022 11:18:06 +0100 Subject: [PATCH 123/854] don't add default_modules dir if does not exists --- openpype/modules/base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 175957ae39..5a8d33aa6e 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -146,9 +146,16 @@ class _LoadCache: def get_default_modules_dir(): """Path to default OpenPype modules.""" + current_dir = os.path.abspath(os.path.dirname(__file__)) - return os.path.join(current_dir, "default_modules") + output = [] + for folder_name in ("default_modules", ): + path = os.path.join(current_dir, folder_name) + if os.path.exists(path) and os.path.isdir(path): + output.append(path) + + return output def get_dynamic_modules_dirs(): @@ -186,7 +193,7 @@ def get_dynamic_modules_dirs(): def get_module_dirs(): """List of paths where OpenPype modules can be found.""" _dirpaths = [] - _dirpaths.append(get_default_modules_dir()) + _dirpaths.extend(get_default_modules_dir()) _dirpaths.extend(get_dynamic_modules_dirs()) dirpaths = [] From 0bf019a00afe11087558163d856ee400ba365d0e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sun, 20 Mar 2022 11:18:31 +0100 Subject: [PATCH 124/854] default modules are loaded dynamically by skipping known files --- openpype/modules/base.py | 68 +++++++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 5a8d33aa6e..5cdeb86087 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -28,26 +28,15 @@ from openpype.settings.lib import ( ) from openpype.lib import PypeLogger - -DEFAULT_OPENPYPE_MODULES = ( - "avalon_apps", - "clockify", - "log_viewer", - "deadline", - "muster", - "royalrender", - "python_console_interpreter", - "ftrack", - "slack", - "webserver", - "launcher_action", - "project_manager_action", - "settings_action", - "standalonepublish_action", - "traypublish_action", - "job_queue", - "timers_manager", - "sync_server", +# Files that will be always ignored on modules import +IGNORED_FILENAMES = ( + "__pycache__", +) +# Files ignored on modules import from "./openpype/modules" +IGNORED_DEFAULT_FILENAMES = ( + "__init__.py", + "base.py", + "interfaces.py", ) @@ -299,25 +288,45 @@ def _load_modules(): log = PypeLogger.get_logger("ModulesLoader") + current_dir = os.path.abspath(os.path.dirname(__file__)) + processed_paths = set() + processed_paths.add(current_dir) # Import default modules imported from 'openpype.modules' - for default_module_name in DEFAULT_OPENPYPE_MODULES: + for filename in os.listdir(current_dir): + # Ignore filenames + if ( + filename in IGNORED_FILENAMES + or filename in IGNORED_DEFAULT_FILENAMES + ): + continue + + fullpath = os.path.join(current_dir, filename) + basename, ext = os.path.splitext(filename) + + if not os.path.isdir(fullpath) and ext not in (".py", ): + continue + try: - import_str = "openpype.modules.{}".format(default_module_name) - new_import_str = "{}.{}".format(modules_key, default_module_name) + import_str = "openpype.modules.{}".format(basename) + new_import_str = "{}.{}".format(modules_key, basename) default_module = __import__(import_str, fromlist=("", )) sys.modules[new_import_str] = default_module - setattr(openpype_modules, default_module_name, default_module) + setattr(openpype_modules, basename, default_module) except Exception: msg = ( "Failed to import default module '{}'." - ).format(default_module_name) + ).format(basename) log.error(msg, exc_info=True) # Look for OpenPype modules in paths defined with `get_module_dirs` # - dynamically imported OpenPype modules and addons - dirpaths = get_module_dirs() - for dirpath in dirpaths: + for dirpath in get_module_dirs(): + # Skip already processed paths + if dirpath in processed_paths: + continue + processed_paths.add(dirpath) + if not os.path.exists(dirpath): log.warning(( "Could not find path when loading OpenPype modules \"{}\"" @@ -326,12 +335,15 @@ def _load_modules(): for filename in os.listdir(dirpath): # Ignore filenames - if filename in ("__pycache__", ): + if filename in IGNORED_FILENAMES: continue fullpath = os.path.join(dirpath, filename) basename, ext = os.path.splitext(filename) + if not os.path.isdir(fullpath) and ext not in (".py", ): + continue + # TODO add more logic how to define if folder is module or not # - check manifest and content of manifest try: From 9d98d5ea2e579c704a92b5c68c0f07edd49005d7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 09:50:34 +0100 Subject: [PATCH 125/854] fix import of 'register_event_callback' --- openpype/hosts/hiero/api/events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/hiero/api/events.py b/openpype/hosts/hiero/api/events.py index 9439199933..7fab3edfc8 100644 --- a/openpype/hosts/hiero/api/events.py +++ b/openpype/hosts/hiero/api/events.py @@ -1,12 +1,12 @@ import os import hiero.core.events from openpype.api import Logger +from openpype.lib import register_event_callback from .lib import ( sync_avalon_data_to_workfile, launch_workfiles_app, selection_changed_timeline, before_project_save, - register_event_callback ) from .tags import add_tags_to_workfile from .menu import update_menu_task_label From d292b122227dc81a1acf914e7938ac21c9f2dfb8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 10:20:10 +0100 Subject: [PATCH 126/854] moved save as dialog logic into separated file --- openpype/tools/workfiles/app.py | 460 +------------------- openpype/tools/workfiles/save_as_dialog.py | 468 +++++++++++++++++++++ 2 files changed, 470 insertions(+), 458 deletions(-) create mode 100644 openpype/tools/workfiles/save_as_dialog.py diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 713992bc4b..1452c8ff54 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -1,7 +1,5 @@ import sys import os -import re -import copy import shutil import logging import datetime @@ -26,14 +24,13 @@ from openpype.lib import ( save_workfile_data_to_doc, get_workfile_template_key, create_workdir_extra_folders, - get_workdir_data, - get_last_workfile_with_version ) from openpype.lib.avalon_context import ( update_current_task, compute_session_changes ) from .model import FilesModel +from .save_as_dialog import SaveAsDialog from .view import FilesView log = logging.getLogger(__name__) @@ -42,459 +39,6 @@ module = sys.modules[__name__] module.window = None -def build_workfile_data(session): - """Get the data required for workfile formatting from avalon `session`""" - - # Set work file data for template formatting - asset_name = session["AVALON_ASSET"] - task_name = session["AVALON_TASK"] - host_name = session["AVALON_APP"] - project_doc = io.find_one( - {"type": "project"}, - { - "name": True, - "data.code": True, - "config.tasks": True, - } - ) - - asset_doc = io.find_one( - { - "type": "asset", - "name": asset_name - }, - { - "name": True, - "data.tasks": True, - "data.parents": True - } - ) - data = get_workdir_data(project_doc, asset_doc, task_name, host_name) - data.update({ - "version": 1, - "comment": "", - "ext": None - }) - - return data - - -class CommentMatcher(object): - """Use anatomy and work file data to parse comments from filenames""" - def __init__(self, anatomy, template_key, data): - - self.fname_regex = None - - template = anatomy.templates[template_key]["file"] - if "{comment}" not in template: - # Don't look for comment if template doesn't allow it - return - - # Create a regex group for extensions - extensions = api.registered_host().file_extensions() - any_extension = "(?:{})".format( - "|".join(re.escape(ext[1:]) for ext in extensions) - ) - - # Use placeholders that will never be in the filename - temp_data = copy.deepcopy(data) - temp_data["comment"] = "<>" - temp_data["version"] = "<>" - temp_data["ext"] = "<>" - - formatted = anatomy.format(temp_data) - fname_pattern = formatted[template_key]["file"] - fname_pattern = re.escape(fname_pattern) - - # Replace comment and version with something we can match with regex - replacements = { - "<>": "(.+)", - "<>": "[0-9]+", - "<>": any_extension, - } - for src, dest in replacements.items(): - fname_pattern = fname_pattern.replace(re.escape(src), dest) - - # Match from beginning to end of string to be safe - fname_pattern = "^{}$".format(fname_pattern) - - self.fname_regex = re.compile(fname_pattern) - - def parse_comment(self, filepath): - """Parse the {comment} part from a filename""" - if not self.fname_regex: - return - - fname = os.path.basename(filepath) - match = self.fname_regex.match(fname) - if match: - return match.group(1) - - -class SubversionLineEdit(QtWidgets.QWidget): - """QLineEdit with QPushButton for drop down selection of list of strings""" - def __init__(self, parent=None): - super(SubversionLineEdit, self).__init__(parent=parent) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(3) - - self._input = PlaceholderLineEdit() - self._button = QtWidgets.QPushButton("") - self._button.setFixedWidth(18) - self._menu = QtWidgets.QMenu(self) - self._button.setMenu(self._menu) - - layout.addWidget(self._input) - layout.addWidget(self._button) - - @property - def input(self): - return self._input - - def set_values(self, values): - self._update(values) - - def _on_button_clicked(self): - self._menu.exec_() - - def _on_action_clicked(self, action): - self._input.setText(action.text()) - - def _update(self, values): - """Create optional predefined subset names - - Args: - default_names(list): all predefined names - - Returns: - None - """ - - menu = self._menu - button = self._button - - state = any(values) - button.setEnabled(state) - if state is False: - return - - # Include an empty string - values = [""] + sorted(values) - - # Get and destroy the action group - group = button.findChild(QtWidgets.QActionGroup) - if group: - group.deleteLater() - - # Build new action group - group = QtWidgets.QActionGroup(button) - for name in values: - action = group.addAction(name) - menu.addAction(action) - - group.triggered.connect(self._on_action_clicked) - - -class NameWindow(QtWidgets.QDialog): - """Name Window to define a unique filename inside a root folder - - The filename will be based on the "workfile" template defined in the - project["config"]["template"]. - - """ - - def __init__(self, parent, root, anatomy, template_key, session=None): - super(NameWindow, self).__init__(parent=parent) - self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) - - self.result = None - self.host = api.registered_host() - self.root = root - self.work_file = None - - if not session: - # Fallback to active session - session = api.Session - - self.data = build_workfile_data(session) - - # Store project anatomy - self.anatomy = anatomy - self.template = anatomy.templates[template_key]["file"] - self.template_key = template_key - - # Btns widget - btns_widget = QtWidgets.QWidget(self) - - btn_ok = QtWidgets.QPushButton("Ok", btns_widget) - btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget) - - btns_layout = QtWidgets.QHBoxLayout(btns_widget) - btns_layout.addWidget(btn_ok) - btns_layout.addWidget(btn_cancel) - - # Inputs widget - inputs_widget = QtWidgets.QWidget(self) - - # Version widget - version_widget = QtWidgets.QWidget(inputs_widget) - - # Version number input - version_input = QtWidgets.QSpinBox(version_widget) - version_input.setMinimum(1) - version_input.setMaximum(9999) - - # Last version checkbox - last_version_check = QtWidgets.QCheckBox( - "Next Available Version", version_widget - ) - last_version_check.setChecked(True) - - version_layout = QtWidgets.QHBoxLayout(version_widget) - version_layout.setContentsMargins(0, 0, 0, 0) - version_layout.addWidget(version_input) - version_layout.addWidget(last_version_check) - - # Preview widget - preview_label = QtWidgets.QLabel("Preview filename", inputs_widget) - - # Subversion input - subversion = SubversionLineEdit(inputs_widget) - subversion.input.setPlaceholderText("Will be part of filename.") - - # Extensions combobox - ext_combo = QtWidgets.QComboBox(inputs_widget) - # Add styled delegate to use stylesheets - ext_delegate = QtWidgets.QStyledItemDelegate() - ext_combo.setItemDelegate(ext_delegate) - ext_combo.addItems(self.host.file_extensions()) - - # Build inputs - inputs_layout = QtWidgets.QFormLayout(inputs_widget) - # Add version only if template contains version key - # - since the version can be padded with "{version:0>4}" we only search - # for "{version". - if "{version" in self.template: - inputs_layout.addRow("Version:", version_widget) - else: - version_widget.setVisible(False) - - # Add subversion only if template contains `{comment}` - if "{comment}" in self.template: - inputs_layout.addRow("Subversion:", subversion) - - # Detect whether a {comment} is in the current filename - if so, - # preserve it by default and set it in the comment/subversion field - current_filepath = self.host.current_file() - if current_filepath: - # We match the current filename against the current session - # instead of the session where the user is saving to. - current_data = build_workfile_data(api.Session) - matcher = CommentMatcher(anatomy, template_key, current_data) - comment = matcher.parse_comment(current_filepath) - if comment: - log.info("Detected subversion comment: {}".format(comment)) - self.data["comment"] = comment - subversion.input.setText(comment) - - existing_comments = self.get_existing_comments() - subversion.set_values(existing_comments) - - else: - subversion.setVisible(False) - inputs_layout.addRow("Extension:", ext_combo) - inputs_layout.addRow("Preview:", preview_label) - - # Build layout - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(inputs_widget) - main_layout.addWidget(btns_widget) - - # Signal callback registration - version_input.valueChanged.connect(self.on_version_spinbox_changed) - last_version_check.stateChanged.connect( - self.on_version_checkbox_changed - ) - - subversion.input.textChanged.connect(self.on_comment_changed) - ext_combo.currentIndexChanged.connect(self.on_extension_changed) - - btn_ok.pressed.connect(self.on_ok_pressed) - btn_cancel.pressed.connect(self.on_cancel_pressed) - - # Allow "Enter" key to accept the save. - btn_ok.setDefault(True) - - # Force default focus to comment, some hosts didn't automatically - # apply focus to this line edit (e.g. Houdini) - subversion.input.setFocus() - - # Store widgets - self.btn_ok = btn_ok - - self.version_widget = version_widget - - self.version_input = version_input - self.last_version_check = last_version_check - - self.preview_label = preview_label - self.subversion = subversion - self.ext_combo = ext_combo - self._ext_delegate = ext_delegate - - self.refresh() - - def get_existing_comments(self): - - matcher = CommentMatcher(self.anatomy, self.template_key, self.data) - host_extensions = set(self.host.file_extensions()) - comments = set() - if os.path.isdir(self.root): - for fname in os.listdir(self.root): - if not os.path.isfile(os.path.join(self.root, fname)): - continue - - ext = os.path.splitext(fname)[-1] - if ext not in host_extensions: - continue - - comment = matcher.parse_comment(fname) - if comment: - comments.add(comment) - - return list(comments) - - def on_version_spinbox_changed(self, value): - self.data["version"] = value - self.refresh() - - def on_version_checkbox_changed(self, _value): - self.refresh() - - def on_comment_changed(self, text): - self.data["comment"] = text - self.refresh() - - def on_extension_changed(self): - ext = self.ext_combo.currentText() - if ext == self.data["ext"]: - return - self.data["ext"] = ext - self.refresh() - - def on_ok_pressed(self): - self.result = self.work_file - self.close() - - def on_cancel_pressed(self): - self.close() - - def get_result(self): - return self.result - - def get_work_file(self): - data = copy.deepcopy(self.data) - if not data["comment"]: - data.pop("comment", None) - - data["ext"] = data["ext"][1:] - - anatomy_filled = self.anatomy.format(data) - return anatomy_filled[self.template_key]["file"] - - def refresh(self): - extensions = self.host.file_extensions() - extension = self.data["ext"] - if extension is None: - # Define saving file extension - current_file = self.host.current_file() - if current_file: - # Match the extension of current file - _, extension = os.path.splitext(current_file) - else: - extension = extensions[0] - - if extension != self.data["ext"]: - self.data["ext"] = extension - index = self.ext_combo.findText( - extension, QtCore.Qt.MatchFixedString - ) - if index >= 0: - self.ext_combo.setCurrentIndex(index) - - if not self.last_version_check.isChecked(): - self.version_input.setEnabled(True) - self.data["version"] = self.version_input.value() - - work_file = self.get_work_file() - - else: - self.version_input.setEnabled(False) - - data = copy.deepcopy(self.data) - template = str(self.template) - - if not data["comment"]: - data.pop("comment", None) - - data["ext"] = data["ext"][1:] - - version = get_last_workfile_with_version( - self.root, template, data, extensions - )[1] - - if version is None: - version = 1 - else: - version += 1 - - found_valid_version = False - # Check if next version is valid version and give a chance to try - # next 100 versions - for idx in range(100): - # Store version to data - self.data["version"] = version - - work_file = self.get_work_file() - # Safety check - path = os.path.join(self.root, work_file) - if not os.path.exists(path): - found_valid_version = True - break - - # Try next version - version += 1 - # Log warning - if idx == 0: - log.warning(( - "BUG: Function `get_last_workfile_with_version` " - "didn't return last version." - )) - # Raise exception if even 100 version fallback didn't help - if not found_valid_version: - raise AssertionError( - "This is a bug. Couldn't find valid version!" - ) - - self.work_file = work_file - - path_exists = os.path.exists(os.path.join(self.root, work_file)) - - self.btn_ok.setEnabled(not path_exists) - - if path_exists: - self.preview_label.setText( - "Cannot create \"{0}\" because file exists!" - "".format(work_file) - ) - else: - self.preview_label.setText( - "{0}".format(work_file) - ) - - class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" file_selected = QtCore.Signal(str) @@ -735,7 +279,7 @@ class FilesWidget(QtWidgets.QWidget): """ session = self._get_session() - window = NameWindow( + window = SaveAsDialog( parent=self, root=self._workfiles_root, anatomy=self.anatomy, diff --git a/openpype/tools/workfiles/save_as_dialog.py b/openpype/tools/workfiles/save_as_dialog.py new file mode 100644 index 0000000000..399d54bd54 --- /dev/null +++ b/openpype/tools/workfiles/save_as_dialog.py @@ -0,0 +1,468 @@ +import os +import re +import copy +import logging + +from Qt import QtWidgets, QtCore + +from avalon import api, io + +from openpype.lib import ( + get_last_workfile_with_version, + get_workdir_data, +) +from openpype.tools.utils import PlaceholderLineEdit + +log = logging.getLogger(__name__) + + +def build_workfile_data(session): + """Get the data required for workfile formatting from avalon `session`""" + + # Set work file data for template formatting + asset_name = session["AVALON_ASSET"] + task_name = session["AVALON_TASK"] + host_name = session["AVALON_APP"] + project_doc = io.find_one( + {"type": "project"}, + { + "name": True, + "data.code": True, + "config.tasks": True, + } + ) + + asset_doc = io.find_one( + { + "type": "asset", + "name": asset_name + }, + { + "name": True, + "data.tasks": True, + "data.parents": True + } + ) + data = get_workdir_data(project_doc, asset_doc, task_name, host_name) + data.update({ + "version": 1, + "comment": "", + "ext": None + }) + + return data + + +class CommentMatcher(object): + """Use anatomy and work file data to parse comments from filenames""" + def __init__(self, anatomy, template_key, data): + + self.fname_regex = None + + template = anatomy.templates[template_key]["file"] + if "{comment}" not in template: + # Don't look for comment if template doesn't allow it + return + + # Create a regex group for extensions + extensions = api.registered_host().file_extensions() + any_extension = "(?:{})".format( + "|".join(re.escape(ext[1:]) for ext in extensions) + ) + + # Use placeholders that will never be in the filename + temp_data = copy.deepcopy(data) + temp_data["comment"] = "<>" + temp_data["version"] = "<>" + temp_data["ext"] = "<>" + + formatted = anatomy.format(temp_data) + fname_pattern = formatted[template_key]["file"] + fname_pattern = re.escape(fname_pattern) + + # Replace comment and version with something we can match with regex + replacements = { + "<>": "(.+)", + "<>": "[0-9]+", + "<>": any_extension, + } + for src, dest in replacements.items(): + fname_pattern = fname_pattern.replace(re.escape(src), dest) + + # Match from beginning to end of string to be safe + fname_pattern = "^{}$".format(fname_pattern) + + self.fname_regex = re.compile(fname_pattern) + + def parse_comment(self, filepath): + """Parse the {comment} part from a filename""" + if not self.fname_regex: + return + + fname = os.path.basename(filepath) + match = self.fname_regex.match(fname) + if match: + return match.group(1) + + +class SubversionLineEdit(QtWidgets.QWidget): + """QLineEdit with QPushButton for drop down selection of list of strings""" + def __init__(self, parent=None): + super(SubversionLineEdit, self).__init__(parent=parent) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(3) + + self._input = PlaceholderLineEdit() + self._button = QtWidgets.QPushButton("") + self._button.setFixedWidth(18) + self._menu = QtWidgets.QMenu(self) + self._button.setMenu(self._menu) + + layout.addWidget(self._input) + layout.addWidget(self._button) + + @property + def input(self): + return self._input + + def set_values(self, values): + self._update(values) + + def _on_button_clicked(self): + self._menu.exec_() + + def _on_action_clicked(self, action): + self._input.setText(action.text()) + + def _update(self, values): + """Create optional predefined subset names + + Args: + default_names(list): all predefined names + + Returns: + None + """ + + menu = self._menu + button = self._button + + state = any(values) + button.setEnabled(state) + if state is False: + return + + # Include an empty string + values = [""] + sorted(values) + + # Get and destroy the action group + group = button.findChild(QtWidgets.QActionGroup) + if group: + group.deleteLater() + + # Build new action group + group = QtWidgets.QActionGroup(button) + for name in values: + action = group.addAction(name) + menu.addAction(action) + + group.triggered.connect(self._on_action_clicked) + + +class SaveAsDialog(QtWidgets.QDialog): + """Name Window to define a unique filename inside a root folder + + The filename will be based on the "workfile" template defined in the + project["config"]["template"]. + + """ + + def __init__(self, parent, root, anatomy, template_key, session=None): + super(SaveAsDialog, self).__init__(parent=parent) + self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) + + self.result = None + self.host = api.registered_host() + self.root = root + self.work_file = None + + if not session: + # Fallback to active session + session = api.Session + + self.data = build_workfile_data(session) + + # Store project anatomy + self.anatomy = anatomy + self.template = anatomy.templates[template_key]["file"] + self.template_key = template_key + + # Btns widget + btns_widget = QtWidgets.QWidget(self) + + btn_ok = QtWidgets.QPushButton("Ok", btns_widget) + btn_cancel = QtWidgets.QPushButton("Cancel", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addWidget(btn_ok) + btns_layout.addWidget(btn_cancel) + + # Inputs widget + inputs_widget = QtWidgets.QWidget(self) + + # Version widget + version_widget = QtWidgets.QWidget(inputs_widget) + + # Version number input + version_input = QtWidgets.QSpinBox(version_widget) + version_input.setMinimum(1) + version_input.setMaximum(9999) + + # Last version checkbox + last_version_check = QtWidgets.QCheckBox( + "Next Available Version", version_widget + ) + last_version_check.setChecked(True) + + version_layout = QtWidgets.QHBoxLayout(version_widget) + version_layout.setContentsMargins(0, 0, 0, 0) + version_layout.addWidget(version_input) + version_layout.addWidget(last_version_check) + + # Preview widget + preview_label = QtWidgets.QLabel("Preview filename", inputs_widget) + + # Subversion input + subversion = SubversionLineEdit(inputs_widget) + subversion.input.setPlaceholderText("Will be part of filename.") + + # Extensions combobox + ext_combo = QtWidgets.QComboBox(inputs_widget) + # Add styled delegate to use stylesheets + ext_delegate = QtWidgets.QStyledItemDelegate() + ext_combo.setItemDelegate(ext_delegate) + ext_combo.addItems(self.host.file_extensions()) + + # Build inputs + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + # Add version only if template contains version key + # - since the version can be padded with "{version:0>4}" we only search + # for "{version". + if "{version" in self.template: + inputs_layout.addRow("Version:", version_widget) + else: + version_widget.setVisible(False) + + # Add subversion only if template contains `{comment}` + if "{comment}" in self.template: + inputs_layout.addRow("Subversion:", subversion) + + # Detect whether a {comment} is in the current filename - if so, + # preserve it by default and set it in the comment/subversion field + current_filepath = self.host.current_file() + if current_filepath: + # We match the current filename against the current session + # instead of the session where the user is saving to. + current_data = build_workfile_data(api.Session) + matcher = CommentMatcher(anatomy, template_key, current_data) + comment = matcher.parse_comment(current_filepath) + if comment: + log.info("Detected subversion comment: {}".format(comment)) + self.data["comment"] = comment + subversion.input.setText(comment) + + existing_comments = self.get_existing_comments() + subversion.set_values(existing_comments) + + else: + subversion.setVisible(False) + inputs_layout.addRow("Extension:", ext_combo) + inputs_layout.addRow("Preview:", preview_label) + + # Build layout + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget) + main_layout.addWidget(btns_widget) + + # Signal callback registration + version_input.valueChanged.connect(self.on_version_spinbox_changed) + last_version_check.stateChanged.connect( + self.on_version_checkbox_changed + ) + + subversion.input.textChanged.connect(self.on_comment_changed) + ext_combo.currentIndexChanged.connect(self.on_extension_changed) + + btn_ok.pressed.connect(self.on_ok_pressed) + btn_cancel.pressed.connect(self.on_cancel_pressed) + + # Allow "Enter" key to accept the save. + btn_ok.setDefault(True) + + # Force default focus to comment, some hosts didn't automatically + # apply focus to this line edit (e.g. Houdini) + subversion.input.setFocus() + + # Store widgets + self.btn_ok = btn_ok + + self.version_widget = version_widget + + self.version_input = version_input + self.last_version_check = last_version_check + + self.preview_label = preview_label + self.subversion = subversion + self.ext_combo = ext_combo + self._ext_delegate = ext_delegate + + self.refresh() + + def get_existing_comments(self): + matcher = CommentMatcher(self.anatomy, self.template_key, self.data) + host_extensions = set(self.host.file_extensions()) + comments = set() + if os.path.isdir(self.root): + for fname in os.listdir(self.root): + if not os.path.isfile(os.path.join(self.root, fname)): + continue + + ext = os.path.splitext(fname)[-1] + if ext not in host_extensions: + continue + + comment = matcher.parse_comment(fname) + if comment: + comments.add(comment) + + return list(comments) + + def on_version_spinbox_changed(self, value): + self.data["version"] = value + self.refresh() + + def on_version_checkbox_changed(self, _value): + self.refresh() + + def on_comment_changed(self, text): + self.data["comment"] = text + self.refresh() + + def on_extension_changed(self): + ext = self.ext_combo.currentText() + if ext == self.data["ext"]: + return + self.data["ext"] = ext + self.refresh() + + def on_ok_pressed(self): + self.result = self.work_file + self.close() + + def on_cancel_pressed(self): + self.close() + + def get_result(self): + return self.result + + def get_work_file(self): + data = copy.deepcopy(self.data) + if not data["comment"]: + data.pop("comment", None) + + data["ext"] = data["ext"][1:] + + anatomy_filled = self.anatomy.format(data) + return anatomy_filled[self.template_key]["file"] + + def refresh(self): + extensions = self.host.file_extensions() + extension = self.data["ext"] + if extension is None: + # Define saving file extension + current_file = self.host.current_file() + if current_file: + # Match the extension of current file + _, extension = os.path.splitext(current_file) + else: + extension = extensions[0] + + if extension != self.data["ext"]: + self.data["ext"] = extension + index = self.ext_combo.findText( + extension, QtCore.Qt.MatchFixedString + ) + if index >= 0: + self.ext_combo.setCurrentIndex(index) + + if not self.last_version_check.isChecked(): + self.version_input.setEnabled(True) + self.data["version"] = self.version_input.value() + + work_file = self.get_work_file() + + else: + self.version_input.setEnabled(False) + + data = copy.deepcopy(self.data) + template = str(self.template) + + if not data["comment"]: + data.pop("comment", None) + + data["ext"] = data["ext"][1:] + + version = get_last_workfile_with_version( + self.root, template, data, extensions + )[1] + + if version is None: + version = 1 + else: + version += 1 + + found_valid_version = False + # Check if next version is valid version and give a chance to try + # next 100 versions + for idx in range(100): + # Store version to data + self.data["version"] = version + + work_file = self.get_work_file() + # Safety check + path = os.path.join(self.root, work_file) + if not os.path.exists(path): + found_valid_version = True + break + + # Try next version + version += 1 + # Log warning + if idx == 0: + log.warning(( + "BUG: Function `get_last_workfile_with_version` " + "didn't return last version." + )) + # Raise exception if even 100 version fallback didn't help + if not found_valid_version: + raise AssertionError( + "This is a bug. Couldn't find valid version!" + ) + + self.work_file = work_file + + path_exists = os.path.exists(os.path.join(self.root, work_file)) + + self.btn_ok.setEnabled(not path_exists) + + if path_exists: + self.preview_label.setText( + "Cannot create \"{0}\" because file exists!" + "".format(work_file) + ) + else: + self.preview_label.setText( + "{0}".format(work_file) + ) From 30fe1b30a22a00decf2493ddfcebe6a8ae012754 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 10:29:22 +0100 Subject: [PATCH 127/854] use standard item model --- openpype/tools/workfiles/app.py | 26 ++-- openpype/tools/workfiles/model.py | 219 ++++++++++++++++-------------- 2 files changed, 128 insertions(+), 117 deletions(-) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 1452c8ff54..d6e5aa9ec1 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -29,7 +29,11 @@ from openpype.lib.avalon_context import ( update_current_task, compute_session_changes ) -from .model import FilesModel +from .model import ( + WorkAreaFilesModel, + FILEPATH_ROLE, + DATE_MODIFIED_ROLE, +) from .save_as_dialog import SaveAsDialog from .view import FilesView @@ -76,7 +80,7 @@ class FilesWidget(QtWidgets.QWidget): # Create the Files model extensions = set(self.host.file_extensions()) - files_model = FilesModel(file_extensions=extensions) + files_model = WorkAreaFilesModel(extensions) # Create proxy model for files to be able sort and filter proxy_model = QtCore.QSortFilterProxyModel() @@ -167,10 +171,10 @@ class FilesWidget(QtWidgets.QWidget): self.files_model.set_root(None) # Disable/Enable buttons based on available files in model - has_filenames = self.files_model.has_filenames() - self.btn_browse.setEnabled(has_filenames) - self.btn_open.setEnabled(has_filenames) - if not has_filenames: + has_valid_items = self.files_model.has_valid_items() + self.btn_browse.setEnabled(has_valid_items) + self.btn_open.setEnabled(has_valid_items) + if not has_valid_items: # Manually trigger file selection self.on_file_select() @@ -310,7 +314,7 @@ class FilesWidget(QtWidgets.QWidget): if not index.isValid(): return - return index.data(self.files_model.FilePathRole) + return index.data(FILEPATH_ROLE) def on_open_pressed(self): path = self._get_selected_filepath() @@ -398,12 +402,11 @@ class FilesWidget(QtWidgets.QWidget): self._select_last_modified_file() def on_context_menu(self, point): - index = self.files_view.indexAt(point) + index = self._workarea_files_view.indexAt(point) if not index.isValid(): return - is_enabled = index.data(FilesModel.IsEnabled) - if not is_enabled: + if not index.flags() & QtCore.Qt.ItemIsEnabled: return menu = QtWidgets.QMenu(self) @@ -424,7 +427,6 @@ class FilesWidget(QtWidgets.QWidget): def _select_last_modified_file(self): """Utility function to select the file with latest date modified""" - role = self.files_model.DateModifiedRole model = self.files_view.model() highest_index = None @@ -434,7 +436,7 @@ class FilesWidget(QtWidgets.QWidget): if not index.isValid(): continue - modified = index.data(role) + modified = index.data(DATE_MODIFIED_ROLE) if modified is not None and modified > highest: highest_index = index highest = modified diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index e9184842fc..fa450f0a8a 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -1,7 +1,7 @@ import os import logging -from Qt import QtCore +from Qt import QtCore, QtGui import qtawesome from openpype.style import ( @@ -9,145 +9,152 @@ from openpype.style import ( get_disabled_entity_icon_color, ) -from openpype.tools.utils.models import TreeModel, Item log = logging.getLogger(__name__) +FILEPATH_ROLE = QtCore.Qt.UserRole + 2 +DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 +ITEM_ID_ROLE = QtCore.Qt.UserRole + 4 -class FilesModel(TreeModel): - """Model listing files with specified extensions in a root folder""" - Columns = ["filename", "date"] - FileNameRole = QtCore.Qt.UserRole + 2 - DateModifiedRole = QtCore.Qt.UserRole + 3 - FilePathRole = QtCore.Qt.UserRole + 4 - IsEnabled = QtCore.Qt.UserRole + 5 +class WorkAreaFilesModel(QtGui.QStandardItemModel): + def __init__(self, extensions, *args, **kwargs): + super(WorkAreaFilesModel, self).__init__(*args, **kwargs) - def __init__(self, file_extensions, parent=None): - super(FilesModel, self).__init__(parent=parent) + self.setColumnCount(2) self._root = None - self._file_extensions = file_extensions - self._icons = { - "file": qtawesome.icon( - "fa.file-o", - color=get_default_entity_icon_color() + self._file_extensions = extensions + self._invalid_path_item = None + self._empty_root_item = None + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._invalid_item_visible = False + self._items_by_filename = {} + + def _get_invalid_path_item(self): + if self._invalid_path_item is None: + message = "Work Area does not exist. Use Save As to create it." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() ) - } + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._invalid_path_item = item + return self._invalid_path_item + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Work Area does not exist. Use Save As to create it." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item def set_root(self, root): self._root = root + if root and not os.path.exists(root): + log.debug("Work Area does not exist: {}".format(root)) self.refresh() - def _add_empty(self): - item = Item() - item.update({ - # Put a display message in 'filename' - "filename": "No files found.", - # Not-selectable - "enabled": False, - "date": None, - "filepath": None - }) - - self.add_child(item) + def _clear(self): + root_item = self.invisibleRootItem() + rows = root_item.rowCount() + if rows > 0: + if self._invalid_item_visible: + for row in range(rows): + root_item.takeRow(row) + else: + root_item.removeRows(0, rows) + self._items_by_filename = {} def refresh(self): - self.clear() - self.beginResetModel() - - root = self._root - - if not root: - self.endResetModel() - return - - if not os.path.exists(root): + root_item = self.invisibleRootItem() + if not self._root or not os.path.exists(self._root): + self._clear() # Add Work Area does not exist placeholder - log.debug("Work Area does not exist: %s", root) - message = "Work Area does not exist. Use Save As to create it." - item = Item({ - "filename": message, - "date": None, - "filepath": None, - "enabled": False, - "icon": qtawesome.icon( - "fa.times", - color=get_disabled_entity_icon_color() - ) - }) - self.add_child(item) - self.endResetModel() + item = self._get_invalid_path_item() + root_item.appendRow(item) + self._invalid_item_visible = True return - extensions = self._file_extensions + if self._invalid_item_visible: + self._clear() - for filename in os.listdir(root): - path = os.path.join(root, filename) - if os.path.isdir(path): + new_items = [] + items_to_remove = set(self._items_by_filename.keys()) + for filename in os.listdir(self._root): + filepath = os.path.join(self._root, filename) + if os.path.isdir(filepath): continue ext = os.path.splitext(filename)[1] - if extensions and ext not in extensions: + if ext not in self._file_extensions: continue - modified = os.path.getmtime(path) + modified = os.path.getmtime(filepath) - item = Item({ - "filename": filename, - "date": modified, - "filepath": path - }) + if filename in items_to_remove: + items_to_remove.remove(filename) + item = self._items_by_filename[filename] + else: + item = QtGui.QStandardItem(filename) + item.setColumnCount(self.columnCount()) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + new_items.append(item) + self._items_by_filename[filename] = item + item.setData(filepath, FILEPATH_ROLE) + item.setData(modified, DATE_MODIFIED_ROLE) - self.add_child(item) + if new_items: + root_item.appendRows(new_items) - if self.rowCount() == 0: - self._add_empty() + for filename in items_to_remove: + item = self._items_by_filename.pop(filename) + root_item.removeRow(item.row()) - self.endResetModel() - - def has_filenames(self): - for item in self._root_item.children(): - if item.get("enabled", True): - return True - return False - - def rowCount(self, parent=None): - if parent is None or not parent.isValid(): - parent_item = self._root_item + if root_item.rowCount() > 0: + self._invalid_item_visible = False else: - parent_item = parent.internalPointer() - return parent_item.childCount() + self._invalid_item_visible = True + item = self._get_empty_root_item() + root_item.appendRow(item) - def data(self, index, role): - if not index.isValid(): - return + def has_valid_items(self): + return not self._invalid_item_visible - if role == QtCore.Qt.DecorationRole: - # Add icon to filename column - item = index.internalPointer() - if index.column() == 0: - if item["filepath"]: - return self._icons["file"] - return item.get("icon", None) + def flags(self, index): + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(WorkAreaFilesModel, self).flags(index) - if role == self.FileNameRole: - item = index.internalPointer() - return item["filename"] + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole - if role == self.DateModifiedRole: - item = index.internalPointer() - return item["date"] + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None - if role == self.FilePathRole: - item = index.internalPointer() - return item["filepath"] + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) - if role == self.IsEnabled: - item = index.internalPointer() - return item.get("enabled", True) - - return super(FilesModel, self).data(index, role) + return super(WorkAreaFilesModel, self).data(index, role) def headerData(self, section, orientation, role): # Show nice labels in the header @@ -160,4 +167,6 @@ class FilesModel(TreeModel): elif section == 1: return "Date modified" - return super(FilesModel, self).headerData(section, orientation, role) + return super(WorkAreaFilesModel, self).headerData( + section, orientation, role + ) From 6dbb48d4e6dd07ff11266584935dfdc2f4f941a5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 11:03:33 +0100 Subject: [PATCH 128/854] separated files widget and Window into separated files --- openpype/tools/workfiles/__init__.py | 7 +- openpype/tools/workfiles/app.py | 757 +---------------------- openpype/tools/workfiles/files_widget.py | 445 +++++++++++++ openpype/tools/workfiles/view.py | 15 - openpype/tools/workfiles/window.py | 334 ++++++++++ 5 files changed, 787 insertions(+), 771 deletions(-) create mode 100644 openpype/tools/workfiles/files_widget.py delete mode 100644 openpype/tools/workfiles/view.py create mode 100644 openpype/tools/workfiles/window.py diff --git a/openpype/tools/workfiles/__init__.py b/openpype/tools/workfiles/__init__.py index cde7293931..5fbc71797d 100644 --- a/openpype/tools/workfiles/__init__.py +++ b/openpype/tools/workfiles/__init__.py @@ -1,9 +1,12 @@ +from .window import Window from .app import ( show, - Window + validate_host_requirements, ) __all__ = [ + "Window", + "show", - "Window" + "validate_host_requirements", ] diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index d6e5aa9ec1..ccf80ee98b 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -1,41 +1,10 @@ import sys -import os -import shutil import logging -import datetime -import Qt -from Qt import QtWidgets, QtCore -from avalon import io, api +from avalon import api -from openpype import style -from openpype.tools.utils.lib import ( - qt_app_context -) -from openpype.tools.utils import PlaceholderLineEdit -from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget -from openpype.tools.utils.tasks_widget import TasksWidget -from openpype.tools.utils.delegates import PrettyTimeDelegate -from openpype.lib import ( - emit_event, - Anatomy, - get_workfile_doc, - create_workfile_doc, - save_workfile_data_to_doc, - get_workfile_template_key, - create_workdir_extra_folders, -) -from openpype.lib.avalon_context import ( - update_current_task, - compute_session_changes -) -from .model import ( - WorkAreaFilesModel, - FILEPATH_ROLE, - DATE_MODIFIED_ROLE, -) -from .save_as_dialog import SaveAsDialog -from .view import FilesView +from openpype.tools.utils import qt_app_context +from .window import Window log = logging.getLogger(__name__) @@ -43,726 +12,6 @@ module = sys.modules[__name__] module.window = None -class FilesWidget(QtWidgets.QWidget): - """A widget displaying files that allows to save and open files.""" - file_selected = QtCore.Signal(str) - workfile_created = QtCore.Signal(str) - file_opened = QtCore.Signal() - - def __init__(self, parent=None): - super(FilesWidget, self).__init__(parent=parent) - - # Setup - self._asset_id = None - self._asset_doc = None - self._task_name = None - self._task_type = None - - # Pype's anatomy object for current project - self.anatomy = Anatomy(io.Session["AVALON_PROJECT"]) - # Template key used to get work template from anatomy templates - self.template_key = "work" - - # This is not root but workfile directory - self._workfiles_root = None - self._workdir_path = None - self.host = api.registered_host() - - # Whether to automatically select the latest modified - # file on a refresh of the files model. - self.auto_select_latest_modified = True - - # Avoid crash in Blender and store the message box - # (setting parent doesn't work as it hides the message box) - self._messagebox = None - - files_view = FilesView(self) - - # Create the Files model - extensions = set(self.host.file_extensions()) - files_model = WorkAreaFilesModel(extensions) - - # Create proxy model for files to be able sort and filter - proxy_model = QtCore.QSortFilterProxyModel() - proxy_model.setSourceModel(files_model) - proxy_model.setDynamicSortFilter(True) - proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) - - # Set up the file list tree view - files_view.setModel(proxy_model) - files_view.setSortingEnabled(True) - files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - - # Date modified delegate - time_delegate = PrettyTimeDelegate() - files_view.setItemDelegateForColumn(1, time_delegate) - files_view.setIndentation(3) # smaller indentation - - # Default to a wider first filename column it is what we mostly care - # about and the date modified is relatively small anyway. - files_view.setColumnWidth(0, 330) - - # Filtering input - filter_input = PlaceholderLineEdit(self) - filter_input.setPlaceholderText("Filter files..") - filter_input.textChanged.connect(proxy_model.setFilterFixedString) - - # Home Page - # Build buttons widget for files widget - btns_widget = QtWidgets.QWidget(self) - btn_save = QtWidgets.QPushButton("Save As", btns_widget) - btn_browse = QtWidgets.QPushButton("Browse", btns_widget) - btn_open = QtWidgets.QPushButton("Open", btns_widget) - - btns_layout = QtWidgets.QHBoxLayout(btns_widget) - btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addWidget(btn_open) - btns_layout.addWidget(btn_browse) - btns_layout.addWidget(btn_save) - - # Build files widgets for home page - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(filter_input) - main_layout.addWidget(files_view) - main_layout.addWidget(btns_widget) - - # Register signal callbacks - files_view.doubleClickedLeft.connect(self.on_open_pressed) - files_view.customContextMenuRequested.connect(self.on_context_menu) - files_view.selectionModel().selectionChanged.connect( - self.on_file_select - ) - - btn_open.pressed.connect(self.on_open_pressed) - btn_browse.pressed.connect(self.on_browse_pressed) - btn_save.pressed.connect(self.on_save_as_pressed) - - # Store attributes - self.time_delegate = time_delegate - - self.filter_input = filter_input - - self.files_view = files_view - self.files_model = files_model - - self.btns_widget = btns_widget - self.btn_open = btn_open - self.btn_browse = btn_browse - self.btn_save = btn_save - - def set_asset_task(self, asset_id, task_name, task_type): - if asset_id != self._asset_id: - self._asset_doc = None - self._asset_id = asset_id - self._task_name = task_name - self._task_type = task_type - - # Define a custom session so we can query the work root - # for a "Work area" that is not our current Session. - # This way we can browse it even before we enter it. - if self._asset_id and self._task_name and self._task_type: - session = self._get_session() - self._workdir_path = session["AVALON_WORKDIR"] - self._workfiles_root = self.host.work_root(session) - self.files_model.set_root(self._workfiles_root) - - else: - self.files_model.set_root(None) - - # Disable/Enable buttons based on available files in model - has_valid_items = self.files_model.has_valid_items() - self.btn_browse.setEnabled(has_valid_items) - self.btn_open.setEnabled(has_valid_items) - if not has_valid_items: - # Manually trigger file selection - self.on_file_select() - - def _get_asset_doc(self): - if self._asset_id is None: - return None - - if self._asset_doc is None: - self._asset_doc = io.find_one({"_id": self._asset_id}) - return self._asset_doc - - def _get_session(self): - """Return a modified session for the current asset and task""" - - session = api.Session.copy() - self.template_key = get_workfile_template_key( - self._task_type, - session["AVALON_APP"], - project_name=session["AVALON_PROJECT"] - ) - changes = compute_session_changes( - session, - asset=self._get_asset_doc(), - task=self._task_name, - template_key=self.template_key - ) - session.update(changes) - - return session - - def _enter_session(self): - """Enter the asset and task session currently selected""" - - session = api.Session.copy() - changes = compute_session_changes( - session, - asset=self._get_asset_doc(), - task=self._task_name, - template_key=self.template_key - ) - if not changes: - # Return early if we're already in the right Session context - # to avoid any unwanted Task Changed callbacks to be triggered. - return - - update_current_task( - asset=self._get_asset_doc(), - task=self._task_name, - template_key=self.template_key - ) - - def open_file(self, filepath): - host = self.host - if host.has_unsaved_changes(): - result = self.save_changes_prompt() - if result is None: - # Cancel operation - return False - - # Save first if has changes - if result: - current_file = host.current_file() - if not current_file: - # If the user requested to save the current scene - # we can't actually automatically do so if the current - # file has not been saved with a name yet. So we'll have - # to opt out. - log.error("Can't save scene with no filename. Please " - "first save your work file using 'Save As'.") - return - - # Save current scene, continue to open file - host.save_file(current_file) - - self._enter_session() - host.open_file(filepath) - self.file_opened.emit() - - def save_changes_prompt(self): - self._messagebox = messagebox = QtWidgets.QMessageBox(parent=self) - messagebox.setWindowFlags(messagebox.windowFlags() | - QtCore.Qt.FramelessWindowHint) - messagebox.setIcon(messagebox.Warning) - messagebox.setWindowTitle("Unsaved Changes!") - messagebox.setText( - "There are unsaved changes to the current file." - "\nDo you want to save the changes?" - ) - messagebox.setStandardButtons( - messagebox.Yes | messagebox.No | messagebox.Cancel - ) - - result = messagebox.exec_() - if result == messagebox.Yes: - return True - if result == messagebox.No: - return False - return None - - def get_filename(self): - """Show save dialog to define filename for save or duplicate - - Returns: - str: The filename to create. - - """ - session = self._get_session() - - window = SaveAsDialog( - parent=self, - root=self._workfiles_root, - anatomy=self.anatomy, - template_key=self.template_key, - session=session - ) - window.exec_() - - return window.get_result() - - def on_duplicate_pressed(self): - work_file = self.get_filename() - if not work_file: - return - - src = self._get_selected_filepath() - dst = os.path.join(self._workfiles_root, work_file) - shutil.copy(src, dst) - - self.workfile_created.emit(dst) - - self.refresh() - - def _get_selected_filepath(self): - """Return current filepath selected in view""" - selection = self.files_view.selectionModel() - index = selection.currentIndex() - if not index.isValid(): - return - - return index.data(FILEPATH_ROLE) - - def on_open_pressed(self): - path = self._get_selected_filepath() - if not path: - print("No file selected to open..") - return - - self.open_file(path) - - def on_browse_pressed(self): - ext_filter = "Work File (*{0})".format( - " *".join(self.host.file_extensions()) - ) - kwargs = { - "caption": "Work Files", - "filter": ext_filter - } - if Qt.__binding__ in ("PySide", "PySide2"): - kwargs["dir"] = self._workfiles_root - else: - kwargs["directory"] = self._workfiles_root - - work_file = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] - if work_file: - self.open_file(work_file) - - def on_save_as_pressed(self): - work_filename = self.get_filename() - if not work_filename: - return - - # Trigger before save event - emit_event( - "workfile.save.before", - {"filename": work_filename, "workdir_path": self._workdir_path}, - source="workfiles.tool" - ) - - # Make sure workfiles root is updated - # - this triggers 'workio.work_root(...)' which may change value of - # '_workfiles_root' - self.set_asset_task( - self._asset_id, self._task_name, self._task_type - ) - - # Create workfiles root folder - if not os.path.exists(self._workfiles_root): - log.debug("Initializing Work Directory: %s", self._workfiles_root) - os.makedirs(self._workfiles_root) - - # Update session if context has changed - self._enter_session() - # Prepare full path to workfile and save it - filepath = os.path.join( - os.path.normpath(self._workfiles_root), work_filename - ) - self.host.save_file(filepath) - # Create extra folders - create_workdir_extra_folders( - self._workdir_path, - api.Session["AVALON_APP"], - self._task_type, - self._task_name, - api.Session["AVALON_PROJECT"] - ) - # Trigger after save events - emit_event( - "workfile.save.after", - {"filename": work_filename, "workdir_path": self._workdir_path}, - source="workfiles.tool" - ) - - self.workfile_created.emit(filepath) - # Refresh files model - self.refresh() - - def on_file_select(self): - self.file_selected.emit(self._get_selected_filepath()) - - def refresh(self): - """Refresh listed files for current selection in the interface""" - self.files_model.refresh() - - if self.auto_select_latest_modified: - self._select_last_modified_file() - - def on_context_menu(self, point): - index = self._workarea_files_view.indexAt(point) - if not index.isValid(): - return - - if not index.flags() & QtCore.Qt.ItemIsEnabled: - return - - menu = QtWidgets.QMenu(self) - - # Duplicate - action = QtWidgets.QAction("Duplicate", menu) - tip = "Duplicate selected file." - action.setToolTip(tip) - action.setStatusTip(tip) - action.triggered.connect(self.on_duplicate_pressed) - menu.addAction(action) - - # Show the context action menu - global_point = self.files_view.mapToGlobal(point) - action = menu.exec_(global_point) - if not action: - return - - def _select_last_modified_file(self): - """Utility function to select the file with latest date modified""" - model = self.files_view.model() - - highest_index = None - highest = 0 - for row in range(model.rowCount()): - index = model.index(row, 0, parent=QtCore.QModelIndex()) - if not index.isValid(): - continue - - modified = index.data(DATE_MODIFIED_ROLE) - if modified is not None and modified > highest: - highest_index = index - highest = modified - - if highest_index: - self.files_view.setCurrentIndex(highest_index) - - -class SidePanelWidget(QtWidgets.QWidget): - save_clicked = QtCore.Signal() - - def __init__(self, parent=None): - super(SidePanelWidget, self).__init__(parent) - - details_label = QtWidgets.QLabel("Details", self) - details_input = QtWidgets.QPlainTextEdit(self) - details_input.setReadOnly(True) - - note_label = QtWidgets.QLabel("Artist note", self) - note_input = QtWidgets.QPlainTextEdit(self) - btn_note_save = QtWidgets.QPushButton("Save note", self) - - main_layout = QtWidgets.QVBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(details_label, 0) - main_layout.addWidget(details_input, 0) - main_layout.addWidget(note_label, 0) - main_layout.addWidget(note_input, 1) - main_layout.addWidget(btn_note_save, alignment=QtCore.Qt.AlignRight) - - note_input.textChanged.connect(self.on_note_change) - btn_note_save.clicked.connect(self.on_save_click) - - self.details_input = details_input - self.note_input = note_input - self.btn_note_save = btn_note_save - - self._orig_note = "" - self._workfile_doc = None - - def on_note_change(self): - text = self.note_input.toPlainText() - self.btn_note_save.setEnabled(self._orig_note != text) - - def on_save_click(self): - self._orig_note = self.note_input.toPlainText() - self.on_note_change() - self.save_clicked.emit() - - def set_context(self, asset_id, task_name, filepath, workfile_doc): - # Check if asset, task and file are selected - # NOTE workfile document is not requirement - enabled = bool(asset_id) and bool(task_name) and bool(filepath) - - self.details_input.setEnabled(enabled) - self.note_input.setEnabled(enabled) - self.btn_note_save.setEnabled(enabled) - - # Make sure workfile doc is overridden - self._workfile_doc = workfile_doc - # Disable inputs and remove texts if any required arguments are missing - if not enabled: - self._orig_note = "" - self.details_input.setPlainText("") - self.note_input.setPlainText("") - return - - orig_note = "" - if workfile_doc: - orig_note = workfile_doc["data"].get("note") or orig_note - - self._orig_note = orig_note - self.note_input.setPlainText(orig_note) - # Set as empty string - self.details_input.setPlainText("") - - filestat = os.stat(filepath) - size_ending_mapping = { - "KB": 1024 ** 1, - "MB": 1024 ** 2, - "GB": 1024 ** 3 - } - size = filestat.st_size - ending = "B" - for _ending, _size in size_ending_mapping.items(): - if filestat.st_size < _size: - break - size = filestat.st_size / _size - ending = _ending - - # Append html string - datetime_format = "%b %d %Y %H:%M:%S" - creation_time = datetime.datetime.fromtimestamp(filestat.st_ctime) - modification_time = datetime.datetime.fromtimestamp(filestat.st_mtime) - lines = ( - "Size:", - "{:.2f} {}".format(size, ending), - "Created:", - creation_time.strftime(datetime_format), - "Modified:", - modification_time.strftime(datetime_format) - ) - self.details_input.appendHtml("
".join(lines)) - - def get_workfile_data(self): - data = { - "note": self.note_input.toPlainText() - } - return self._workfile_doc, data - - -class Window(QtWidgets.QMainWindow): - """Work Files Window""" - title = "Work Files" - - def __init__(self, parent=None): - super(Window, self).__init__(parent=parent) - self.setWindowTitle(self.title) - window_flags = QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint - if not parent: - window_flags |= QtCore.Qt.WindowStaysOnTopHint - self.setWindowFlags(window_flags) - - # Create pages widget and set it as central widget - pages_widget = QtWidgets.QStackedWidget(self) - self.setCentralWidget(pages_widget) - - home_page_widget = QtWidgets.QWidget(pages_widget) - home_body_widget = QtWidgets.QWidget(home_page_widget) - - assets_widget = SingleSelectAssetsWidget(io, parent=home_body_widget) - assets_widget.set_current_asset_btn_visibility(True) - - tasks_widget = TasksWidget(io, home_body_widget) - files_widget = FilesWidget(home_body_widget) - side_panel = SidePanelWidget(home_body_widget) - - pages_widget.addWidget(home_page_widget) - - # Build home - home_page_layout = QtWidgets.QVBoxLayout(home_page_widget) - home_page_layout.addWidget(home_body_widget) - - # Build home - body - body_layout = QtWidgets.QVBoxLayout(home_body_widget) - split_widget = QtWidgets.QSplitter(home_body_widget) - split_widget.addWidget(assets_widget) - split_widget.addWidget(tasks_widget) - split_widget.addWidget(files_widget) - split_widget.addWidget(side_panel) - split_widget.setSizes([255, 160, 455, 175]) - - body_layout.addWidget(split_widget) - - # Add top margin for tasks to align it visually with files as - # the files widget has a filter field which tasks does not. - tasks_widget.setContentsMargins(0, 32, 0, 0) - - # Set context after asset widget is refreshed - # - to do so it is necessary to wait until refresh is done - set_context_timer = QtCore.QTimer() - set_context_timer.setInterval(100) - - # Connect signals - set_context_timer.timeout.connect(self._on_context_set_timeout) - assets_widget.selection_changed.connect(self._on_asset_changed) - tasks_widget.task_changed.connect(self._on_task_changed) - files_widget.file_selected.connect(self.on_file_select) - files_widget.workfile_created.connect(self.on_workfile_create) - files_widget.file_opened.connect(self._on_file_opened) - side_panel.save_clicked.connect(self.on_side_panel_save) - - self._set_context_timer = set_context_timer - self.home_page_widget = home_page_widget - self.pages_widget = pages_widget - self.home_body_widget = home_body_widget - self.split_widget = split_widget - - self.assets_widget = assets_widget - self.tasks_widget = tasks_widget - self.files_widget = files_widget - self.side_panel = side_panel - - # Force focus on the open button by default, required for Houdini. - files_widget.btn_open.setFocus() - - self.resize(1200, 600) - - self._first_show = True - self._context_to_set = None - - def showEvent(self, event): - super(Window, self).showEvent(event) - if self._first_show: - self._first_show = False - self.refresh() - self.setStyleSheet(style.load_stylesheet()) - - def keyPressEvent(self, event): - """Custom keyPressEvent. - - Override keyPressEvent to do nothing so that Maya's panels won't - take focus when pressing "SHIFT" whilst mouse is over viewport or - outliner. This way users don't accidentally perform Maya commands - whilst trying to name an instance. - - """ - - def set_save_enabled(self, enabled): - self.files_widget.btn_save.setEnabled(enabled) - - def on_file_select(self, filepath): - asset_id = self.assets_widget.get_selected_asset_id() - task_name = self.tasks_widget.get_selected_task_name() - - workfile_doc = None - if asset_id and task_name and filepath: - filename = os.path.split(filepath)[1] - workfile_doc = get_workfile_doc( - asset_id, task_name, filename, io - ) - self.side_panel.set_context( - asset_id, task_name, filepath, workfile_doc - ) - - def on_workfile_create(self, filepath): - self._create_workfile_doc(filepath) - - def _on_file_opened(self): - self.close() - - def on_side_panel_save(self): - workfile_doc, data = self.side_panel.get_workfile_data() - if not workfile_doc: - filepath = self.files_widget._get_selected_filepath() - self._create_workfile_doc(filepath, force=True) - workfile_doc = self._get_current_workfile_doc() - - save_workfile_data_to_doc(workfile_doc, data, io) - - def _get_current_workfile_doc(self, filepath=None): - if filepath is None: - filepath = self.files_widget._get_selected_filepath() - task_name = self.tasks_widget.get_selected_task_name() - asset_id = self.assets_widget.get_selected_asset_id() - if not task_name or not asset_id or not filepath: - return - - filename = os.path.split(filepath)[1] - return get_workfile_doc( - asset_id, task_name, filename, io - ) - - def _create_workfile_doc(self, filepath, force=False): - workfile_doc = None - if not force: - workfile_doc = self._get_current_workfile_doc(filepath) - - if not workfile_doc: - workdir, filename = os.path.split(filepath) - asset_id = self.assets_widget.get_selected_asset_id() - asset_doc = io.find_one({"_id": asset_id}) - task_name = self.tasks_widget.get_selected_task_name() - create_workfile_doc(asset_doc, task_name, filename, workdir, io) - - def refresh(self): - # Refresh asset widget - self.assets_widget.refresh() - - self._on_task_changed() - - def set_context(self, context): - self._context_to_set = context - self._set_context_timer.start() - - def _on_context_set_timeout(self): - if self._context_to_set is None: - self._set_context_timer.stop() - return - - if self.assets_widget.refreshing: - return - - self._context_to_set, context = None, self._context_to_set - if "asset" in context: - asset_doc = io.find_one( - { - "name": context["asset"], - "type": "asset" - }, - {"_id": 1} - ) or {} - asset_id = asset_doc.get("_id") - # Select the asset - self.assets_widget.select_asset(asset_id) - self.tasks_widget.set_asset_id(asset_id) - - if "task" in context: - self.tasks_widget.select_task_name(context["task"]) - self._on_task_changed() - - def _on_asset_changed(self): - asset_id = self.assets_widget.get_selected_asset_id() - if asset_id: - self.tasks_widget.setEnabled(True) - else: - # Force disable the other widgets if no - # active selection - self.tasks_widget.setEnabled(False) - self.files_widget.setEnabled(False) - - self.tasks_widget.set_asset_id(asset_id) - - def _on_task_changed(self): - asset_id = self.assets_widget.get_selected_asset_id() - task_name = self.tasks_widget.get_selected_task_name() - task_type = self.tasks_widget.get_selected_task_type() - - asset_is_valid = asset_id is not None - self.tasks_widget.setEnabled(asset_is_valid) - - self.files_widget.setEnabled(bool(task_name) and asset_is_valid) - self.files_widget.set_asset_task(asset_id, task_name, task_type) - self.files_widget.refresh() - - def validate_host_requirements(host): if host is None: raise RuntimeError("No registered host.") diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py new file mode 100644 index 0000000000..d602ad3c1b --- /dev/null +++ b/openpype/tools/workfiles/files_widget.py @@ -0,0 +1,445 @@ +import os +import logging +import shutil + +import Qt +from Qt import QtWidgets, QtCore +from avalon import io, api + +from openpype.tools.utils import PlaceholderLineEdit +from openpype.tools.utils.delegates import PrettyTimeDelegate +from openpype.lib import ( + emit_event, + Anatomy, + get_workfile_template_key, + create_workdir_extra_folders, +) +from openpype.lib.avalon_context import ( + update_current_task, + compute_session_changes +) +from .model import ( + WorkAreaFilesModel, + + FILEPATH_ROLE, + DATE_MODIFIED_ROLE, +) +from .save_as_dialog import SaveAsDialog + +log = logging.getLogger(__name__) + + +class FilesView(QtWidgets.QTreeView): + doubleClickedLeft = QtCore.Signal() + doubleClickedRight = QtCore.Signal() + + def mouseDoubleClickEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self.doubleClickedLeft.emit() + + elif event.button() == QtCore.Qt.RightButton: + self.doubleClickedRight.emit() + + return super(FilesView, self).mouseDoubleClickEvent(event) + + +class FilesWidget(QtWidgets.QWidget): + """A widget displaying files that allows to save and open files.""" + file_selected = QtCore.Signal(str) + workfile_created = QtCore.Signal(str) + file_opened = QtCore.Signal() + + def __init__(self, parent=None): + super(FilesWidget, self).__init__(parent=parent) + + # Setup + self._asset_id = None + self._asset_doc = None + self._task_name = None + self._task_type = None + + # Pype's anatomy object for current project + self.anatomy = Anatomy(io.Session["AVALON_PROJECT"]) + # Template key used to get work template from anatomy templates + self.template_key = "work" + + # This is not root but workfile directory + self._workfiles_root = None + self._workdir_path = None + self.host = api.registered_host() + + # Whether to automatically select the latest modified + # file on a refresh of the files model. + self.auto_select_latest_modified = True + + # Avoid crash in Blender and store the message box + # (setting parent doesn't work as it hides the message box) + self._messagebox = None + + files_view = FilesView(self) + + # Create the Files model + extensions = set(self.host.file_extensions()) + files_model = WorkAreaFilesModel(extensions) + + # Create proxy model for files to be able sort and filter + proxy_model = QtCore.QSortFilterProxyModel() + proxy_model.setSourceModel(files_model) + proxy_model.setDynamicSortFilter(True) + proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + # Set up the file list tree view + files_view.setModel(proxy_model) + files_view.setSortingEnabled(True) + files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + # Date modified delegate + time_delegate = PrettyTimeDelegate() + files_view.setItemDelegateForColumn(1, time_delegate) + files_view.setIndentation(3) # smaller indentation + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + files_view.setColumnWidth(0, 330) + + # Filtering input + filter_input = PlaceholderLineEdit(self) + filter_input.setPlaceholderText("Filter files..") + filter_input.textChanged.connect(proxy_model.setFilterFixedString) + + # Home Page + # Build buttons widget for files widget + btns_widget = QtWidgets.QWidget(self) + btn_save = QtWidgets.QPushButton("Save As", btns_widget) + btn_browse = QtWidgets.QPushButton("Browse", btns_widget) + btn_open = QtWidgets.QPushButton("Open", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(btn_open) + btns_layout.addWidget(btn_browse) + btns_layout.addWidget(btn_save) + + # Build files widgets for home page + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(filter_input) + main_layout.addWidget(files_view) + main_layout.addWidget(btns_widget) + + # Register signal callbacks + files_view.doubleClickedLeft.connect(self.on_open_pressed) + files_view.customContextMenuRequested.connect(self.on_context_menu) + files_view.selectionModel().selectionChanged.connect( + self.on_file_select + ) + + btn_open.pressed.connect(self.on_open_pressed) + btn_browse.pressed.connect(self.on_browse_pressed) + btn_save.pressed.connect(self.on_save_as_pressed) + + # Store attributes + self.time_delegate = time_delegate + + self.filter_input = filter_input + + self.files_view = files_view + self.files_model = files_model + + self.btns_widget = btns_widget + self.btn_open = btn_open + self.btn_browse = btn_browse + self.btn_save = btn_save + + def set_asset_task(self, asset_id, task_name, task_type): + if asset_id != self._asset_id: + self._asset_doc = None + self._asset_id = asset_id + self._task_name = task_name + self._task_type = task_type + + # Define a custom session so we can query the work root + # for a "Work area" that is not our current Session. + # This way we can browse it even before we enter it. + if self._asset_id and self._task_name and self._task_type: + session = self._get_session() + self._workdir_path = session["AVALON_WORKDIR"] + self._workfiles_root = self.host.work_root(session) + self.files_model.set_root(self._workfiles_root) + + else: + self.files_model.set_root(None) + + # Disable/Enable buttons based on available files in model + has_valid_items = self.files_model.has_valid_items() + self.btn_browse.setEnabled(has_valid_items) + self.btn_open.setEnabled(has_valid_items) + if not has_valid_items: + # Manually trigger file selection + self.on_file_select() + + def _get_asset_doc(self): + if self._asset_id is None: + return None + + if self._asset_doc is None: + self._asset_doc = io.find_one({"_id": self._asset_id}) + return self._asset_doc + + def _get_session(self): + """Return a modified session for the current asset and task""" + + session = api.Session.copy() + self.template_key = get_workfile_template_key( + self._task_type, + session["AVALON_APP"], + project_name=session["AVALON_PROJECT"] + ) + changes = compute_session_changes( + session, + asset=self._get_asset_doc(), + task=self._task_name, + template_key=self.template_key + ) + session.update(changes) + + return session + + def _enter_session(self): + """Enter the asset and task session currently selected""" + + session = api.Session.copy() + changes = compute_session_changes( + session, + asset=self._get_asset_doc(), + task=self._task_name, + template_key=self.template_key + ) + if not changes: + # Return early if we're already in the right Session context + # to avoid any unwanted Task Changed callbacks to be triggered. + return + + update_current_task( + asset=self._get_asset_doc(), + task=self._task_name, + template_key=self.template_key + ) + + def open_file(self, filepath): + host = self.host + if host.has_unsaved_changes(): + result = self.save_changes_prompt() + if result is None: + # Cancel operation + return False + + # Save first if has changes + if result: + current_file = host.current_file() + if not current_file: + # If the user requested to save the current scene + # we can't actually automatically do so if the current + # file has not been saved with a name yet. So we'll have + # to opt out. + log.error("Can't save scene with no filename. Please " + "first save your work file using 'Save As'.") + return + + # Save current scene, continue to open file + host.save_file(current_file) + + self._enter_session() + host.open_file(filepath) + self.file_opened.emit() + + def save_changes_prompt(self): + self._messagebox = messagebox = QtWidgets.QMessageBox(parent=self) + messagebox.setWindowFlags(messagebox.windowFlags() | + QtCore.Qt.FramelessWindowHint) + messagebox.setIcon(messagebox.Warning) + messagebox.setWindowTitle("Unsaved Changes!") + messagebox.setText( + "There are unsaved changes to the current file." + "\nDo you want to save the changes?" + ) + messagebox.setStandardButtons( + messagebox.Yes | messagebox.No | messagebox.Cancel + ) + + result = messagebox.exec_() + if result == messagebox.Yes: + return True + if result == messagebox.No: + return False + return None + + def get_filename(self): + """Show save dialog to define filename for save or duplicate + + Returns: + str: The filename to create. + + """ + session = self._get_session() + + window = SaveAsDialog( + parent=self, + root=self._workfiles_root, + anatomy=self.anatomy, + template_key=self.template_key, + session=session + ) + window.exec_() + + return window.get_result() + + def on_duplicate_pressed(self): + work_file = self.get_filename() + if not work_file: + return + + src = self._get_selected_filepath() + dst = os.path.join(self._workfiles_root, work_file) + shutil.copy(src, dst) + + self.workfile_created.emit(dst) + + self.refresh() + + def _get_selected_filepath(self): + """Return current filepath selected in view""" + selection = self.files_view.selectionModel() + index = selection.currentIndex() + if not index.isValid(): + return + + return index.data(FILEPATH_ROLE) + + def on_open_pressed(self): + path = self._get_selected_filepath() + if not path: + print("No file selected to open..") + return + + self.open_file(path) + + def on_browse_pressed(self): + ext_filter = "Work File (*{0})".format( + " *".join(self.host.file_extensions()) + ) + kwargs = { + "caption": "Work Files", + "filter": ext_filter + } + if Qt.__binding__ in ("PySide", "PySide2"): + kwargs["dir"] = self._workfiles_root + else: + kwargs["directory"] = self._workfiles_root + + work_file = QtWidgets.QFileDialog.getOpenFileName(**kwargs)[0] + if work_file: + self.open_file(work_file) + + def on_save_as_pressed(self): + work_filename = self.get_filename() + if not work_filename: + return + + # Trigger before save event + emit_event( + "workfile.save.before", + {"filename": work_filename, "workdir_path": self._workdir_path}, + source="workfiles.tool" + ) + + # Make sure workfiles root is updated + # - this triggers 'workio.work_root(...)' which may change value of + # '_workfiles_root' + self.set_asset_task( + self._asset_id, self._task_name, self._task_type + ) + + # Create workfiles root folder + if not os.path.exists(self._workfiles_root): + log.debug("Initializing Work Directory: %s", self._workfiles_root) + os.makedirs(self._workfiles_root) + + # Update session if context has changed + self._enter_session() + # Prepare full path to workfile and save it + filepath = os.path.join( + os.path.normpath(self._workfiles_root), work_filename + ) + self.host.save_file(filepath) + # Create extra folders + create_workdir_extra_folders( + self._workdir_path, + api.Session["AVALON_APP"], + self._task_type, + self._task_name, + api.Session["AVALON_PROJECT"] + ) + # Trigger after save events + emit_event( + "workfile.save.after", + {"filename": work_filename, "workdir_path": self._workdir_path}, + source="workfiles.tool" + ) + + self.workfile_created.emit(filepath) + # Refresh files model + self.refresh() + + def on_file_select(self): + self.file_selected.emit(self._get_selected_filepath()) + + def refresh(self): + """Refresh listed files for current selection in the interface""" + self.files_model.refresh() + + if self.auto_select_latest_modified: + self._select_last_modified_file() + + def on_context_menu(self, point): + index = self._workarea_files_view.indexAt(point) + if not index.isValid(): + return + + if not index.flags() & QtCore.Qt.ItemIsEnabled: + return + + menu = QtWidgets.QMenu(self) + + # Duplicate + action = QtWidgets.QAction("Duplicate", menu) + tip = "Duplicate selected file." + action.setToolTip(tip) + action.setStatusTip(tip) + action.triggered.connect(self.on_duplicate_pressed) + menu.addAction(action) + + # Show the context action menu + global_point = self.files_view.mapToGlobal(point) + action = menu.exec_(global_point) + if not action: + return + + def _select_last_modified_file(self): + """Utility function to select the file with latest date modified""" + model = self.files_view.model() + + highest_index = None + highest = 0 + for row in range(model.rowCount()): + index = model.index(row, 0, parent=QtCore.QModelIndex()) + if not index.isValid(): + continue + + modified = index.data(DATE_MODIFIED_ROLE) + if modified is not None and modified > highest: + highest_index = index + highest = modified + + if highest_index: + self.files_view.setCurrentIndex(highest_index) diff --git a/openpype/tools/workfiles/view.py b/openpype/tools/workfiles/view.py deleted file mode 100644 index 8e3993e4c7..0000000000 --- a/openpype/tools/workfiles/view.py +++ /dev/null @@ -1,15 +0,0 @@ -from Qt import QtWidgets, QtCore - - -class FilesView(QtWidgets.QTreeView): - doubleClickedLeft = QtCore.Signal() - doubleClickedRight = QtCore.Signal() - - def mouseDoubleClickEvent(self, event): - if event.button() == QtCore.Qt.LeftButton: - self.doubleClickedLeft.emit() - - elif event.button() == QtCore.Qt.RightButton: - self.doubleClickedRight.emit() - - return super(FilesView, self).mouseDoubleClickEvent(event) diff --git a/openpype/tools/workfiles/window.py b/openpype/tools/workfiles/window.py new file mode 100644 index 0000000000..f68b721872 --- /dev/null +++ b/openpype/tools/workfiles/window.py @@ -0,0 +1,334 @@ +import os +import datetime +from Qt import QtCore, QtWidgets + +from avalon import io + +from openpype import style +from openpype.lib import ( + get_workfile_doc, + create_workfile_doc, + save_workfile_data_to_doc, +) +from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget +from openpype.tools.utils.tasks_widget import TasksWidget + +from .files_widget import FilesWidget + + +class SidePanelWidget(QtWidgets.QWidget): + save_clicked = QtCore.Signal() + + def __init__(self, parent=None): + super(SidePanelWidget, self).__init__(parent) + + details_label = QtWidgets.QLabel("Details", self) + details_input = QtWidgets.QPlainTextEdit(self) + details_input.setReadOnly(True) + + note_label = QtWidgets.QLabel("Artist note", self) + note_input = QtWidgets.QPlainTextEdit(self) + btn_note_save = QtWidgets.QPushButton("Save note", self) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(details_label, 0) + main_layout.addWidget(details_input, 0) + main_layout.addWidget(note_label, 0) + main_layout.addWidget(note_input, 1) + main_layout.addWidget(btn_note_save, alignment=QtCore.Qt.AlignRight) + + note_input.textChanged.connect(self.on_note_change) + btn_note_save.clicked.connect(self.on_save_click) + + self.details_input = details_input + self.note_input = note_input + self.btn_note_save = btn_note_save + + self._orig_note = "" + self._workfile_doc = None + + def on_note_change(self): + text = self.note_input.toPlainText() + self.btn_note_save.setEnabled(self._orig_note != text) + + def on_save_click(self): + self._orig_note = self.note_input.toPlainText() + self.on_note_change() + self.save_clicked.emit() + + def set_context(self, asset_id, task_name, filepath, workfile_doc): + # Check if asset, task and file are selected + # NOTE workfile document is not requirement + enabled = bool(asset_id) and bool(task_name) and bool(filepath) + + self.details_input.setEnabled(enabled) + self.note_input.setEnabled(enabled) + self.btn_note_save.setEnabled(enabled) + + # Make sure workfile doc is overridden + self._workfile_doc = workfile_doc + # Disable inputs and remove texts if any required arguments are missing + if not enabled: + self._orig_note = "" + self.details_input.setPlainText("") + self.note_input.setPlainText("") + return + + orig_note = "" + if workfile_doc: + orig_note = workfile_doc["data"].get("note") or orig_note + + self._orig_note = orig_note + self.note_input.setPlainText(orig_note) + # Set as empty string + self.details_input.setPlainText("") + + filestat = os.stat(filepath) + size_ending_mapping = { + "KB": 1024 ** 1, + "MB": 1024 ** 2, + "GB": 1024 ** 3 + } + size = filestat.st_size + ending = "B" + for _ending, _size in size_ending_mapping.items(): + if filestat.st_size < _size: + break + size = filestat.st_size / _size + ending = _ending + + # Append html string + datetime_format = "%b %d %Y %H:%M:%S" + creation_time = datetime.datetime.fromtimestamp(filestat.st_ctime) + modification_time = datetime.datetime.fromtimestamp(filestat.st_mtime) + lines = ( + "Size:", + "{:.2f} {}".format(size, ending), + "Created:", + creation_time.strftime(datetime_format), + "Modified:", + modification_time.strftime(datetime_format) + ) + self.details_input.appendHtml("
".join(lines)) + + def get_workfile_data(self): + data = { + "note": self.note_input.toPlainText() + } + return self._workfile_doc, data + + +class Window(QtWidgets.QMainWindow): + """Work Files Window""" + title = "Work Files" + + def __init__(self, parent=None): + super(Window, self).__init__(parent=parent) + self.setWindowTitle(self.title) + window_flags = QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint + if not parent: + window_flags |= QtCore.Qt.WindowStaysOnTopHint + self.setWindowFlags(window_flags) + + # Create pages widget and set it as central widget + pages_widget = QtWidgets.QStackedWidget(self) + self.setCentralWidget(pages_widget) + + home_page_widget = QtWidgets.QWidget(pages_widget) + home_body_widget = QtWidgets.QWidget(home_page_widget) + + assets_widget = SingleSelectAssetsWidget(io, parent=home_body_widget) + assets_widget.set_current_asset_btn_visibility(True) + + tasks_widget = TasksWidget(io, home_body_widget) + files_widget = FilesWidget(home_body_widget) + side_panel = SidePanelWidget(home_body_widget) + + pages_widget.addWidget(home_page_widget) + + # Build home + home_page_layout = QtWidgets.QVBoxLayout(home_page_widget) + home_page_layout.addWidget(home_body_widget) + + # Build home - body + body_layout = QtWidgets.QVBoxLayout(home_body_widget) + split_widget = QtWidgets.QSplitter(home_body_widget) + split_widget.addWidget(assets_widget) + split_widget.addWidget(tasks_widget) + split_widget.addWidget(files_widget) + split_widget.addWidget(side_panel) + split_widget.setSizes([255, 160, 455, 175]) + + body_layout.addWidget(split_widget) + + # Add top margin for tasks to align it visually with files as + # the files widget has a filter field which tasks does not. + tasks_widget.setContentsMargins(0, 32, 0, 0) + + # Set context after asset widget is refreshed + # - to do so it is necessary to wait until refresh is done + set_context_timer = QtCore.QTimer() + set_context_timer.setInterval(100) + + # Connect signals + set_context_timer.timeout.connect(self._on_context_set_timeout) + assets_widget.selection_changed.connect(self._on_asset_changed) + tasks_widget.task_changed.connect(self._on_task_changed) + files_widget.file_selected.connect(self.on_file_select) + files_widget.workfile_created.connect(self.on_workfile_create) + files_widget.file_opened.connect(self._on_file_opened) + side_panel.save_clicked.connect(self.on_side_panel_save) + + self._set_context_timer = set_context_timer + self.home_page_widget = home_page_widget + self.pages_widget = pages_widget + self.home_body_widget = home_body_widget + self.split_widget = split_widget + + self.assets_widget = assets_widget + self.tasks_widget = tasks_widget + self.files_widget = files_widget + self.side_panel = side_panel + + # Force focus on the open button by default, required for Houdini. + files_widget.btn_open.setFocus() + + self.resize(1200, 600) + + self._first_show = True + self._context_to_set = None + + def showEvent(self, event): + super(Window, self).showEvent(event) + if self._first_show: + self._first_show = False + self.refresh() + self.setStyleSheet(style.load_stylesheet()) + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidentally perform Maya commands + whilst trying to name an instance. + + """ + + def set_save_enabled(self, enabled): + self.files_widget.btn_save.setEnabled(enabled) + + def on_file_select(self, filepath): + asset_id = self.assets_widget.get_selected_asset_id() + task_name = self.tasks_widget.get_selected_task_name() + + workfile_doc = None + if asset_id and task_name and filepath: + filename = os.path.split(filepath)[1] + workfile_doc = get_workfile_doc( + asset_id, task_name, filename, io + ) + self.side_panel.set_context( + asset_id, task_name, filepath, workfile_doc + ) + + def on_workfile_create(self, filepath): + self._create_workfile_doc(filepath) + + def _on_file_opened(self): + self.close() + + def on_side_panel_save(self): + workfile_doc, data = self.side_panel.get_workfile_data() + if not workfile_doc: + filepath = self.files_widget._get_selected_filepath() + self._create_workfile_doc(filepath, force=True) + workfile_doc = self._get_current_workfile_doc() + + save_workfile_data_to_doc(workfile_doc, data, io) + + def _get_current_workfile_doc(self, filepath=None): + if filepath is None: + filepath = self.files_widget._get_selected_filepath() + task_name = self.tasks_widget.get_selected_task_name() + asset_id = self.assets_widget.get_selected_asset_id() + if not task_name or not asset_id or not filepath: + return + + filename = os.path.split(filepath)[1] + return get_workfile_doc( + asset_id, task_name, filename, io + ) + + def _create_workfile_doc(self, filepath, force=False): + workfile_doc = None + if not force: + workfile_doc = self._get_current_workfile_doc(filepath) + + if not workfile_doc: + workdir, filename = os.path.split(filepath) + asset_id = self.assets_widget.get_selected_asset_id() + asset_doc = io.find_one({"_id": asset_id}) + task_name = self.tasks_widget.get_selected_task_name() + create_workfile_doc(asset_doc, task_name, filename, workdir, io) + + def refresh(self): + # Refresh asset widget + self.assets_widget.refresh() + + self._on_task_changed() + + def set_context(self, context): + self._context_to_set = context + self._set_context_timer.start() + + def _on_context_set_timeout(self): + if self._context_to_set is None: + self._set_context_timer.stop() + return + + if self.assets_widget.refreshing: + return + + self._context_to_set, context = None, self._context_to_set + if "asset" in context: + asset_doc = io.find_one( + { + "name": context["asset"], + "type": "asset" + }, + {"_id": 1} + ) or {} + asset_id = asset_doc.get("_id") + # Select the asset + self.assets_widget.select_asset(asset_id) + self.tasks_widget.set_asset_id(asset_id) + + if "task" in context: + self.tasks_widget.select_task_name(context["task"]) + self._on_task_changed() + + def _on_asset_changed(self): + asset_id = self.assets_widget.get_selected_asset_id() + if asset_id: + self.tasks_widget.setEnabled(True) + else: + # Force disable the other widgets if no + # active selection + self.tasks_widget.setEnabled(False) + self.files_widget.setEnabled(False) + + self.tasks_widget.set_asset_id(asset_id) + + def _on_task_changed(self): + asset_id = self.assets_widget.get_selected_asset_id() + task_name = self.tasks_widget.get_selected_task_name() + task_type = self.tasks_widget.get_selected_task_type() + + asset_is_valid = asset_id is not None + self.tasks_widget.setEnabled(asset_is_valid) + + self.files_widget.setEnabled(bool(task_name) and asset_is_valid) + self.files_widget.set_asset_task(asset_id, task_name, task_type) + self.files_widget.refresh() From fa764a12823147ae7bd22d01e2dd09ba2083c3bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 11:05:38 +0100 Subject: [PATCH 129/854] base implementation of published files model --- openpype/tools/workfiles/model.py | 209 +++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index fa450f0a8a..f38c80b190 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -8,10 +8,11 @@ from openpype.style import ( get_default_entity_icon_color, get_disabled_entity_icon_color, ) - +from openpype.pipeline import get_representation_path log = logging.getLogger(__name__) + FILEPATH_ROLE = QtCore.Qt.UserRole + 2 DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 ITEM_ID_ROLE = QtCore.Qt.UserRole + 4 @@ -170,3 +171,209 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): return super(WorkAreaFilesModel, self).headerData( section, orientation, role ) + + +class PublishFilesModel(QtGui.QStandardItemModel): + def __init__(self, extensions, dbcon, anatomy, *args, **kwargs): + super(PublishFilesModel, self).__init__(*args, **kwargs) + + self.setColumnCount(2) + + self._dbcon = dbcon + self._anatomy = anatomy + self._file_extensions = extensions + + self._invalid_context_item = None + self._empty_root_item = None + self._file_icon = qtawesome.icon( + "fa.file-o", + color=get_default_entity_icon_color() + ) + self._invalid_item_visible = False + + self._items_by_id = {} + + self._asset_id = None + self._task_name = None + + def _get_invalid_context_item(self): + if self._invalid_context_item is None: + message = "Selected context is not vald." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._invalid_context_item = item + return self._invalid_context_item + + def _get_empty_root_item(self): + if self._empty_root_item is None: + message = "Didn't find any published workfiles." + item = QtGui.QStandardItem(message) + icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + item.setColumnCount(self.columnCount()) + self._empty_root_item = item + return self._empty_root_item + + def set_context(self, asset_id, task_name): + self._asset_id = asset_id + self._task_name = task_name + self.refresh() + + def _clear(self): + root_item = self.invisibleRootItem() + rows = root_item.rowCount() + if rows > 0: + if self._invalid_item_visible: + for row in range(rows): + root_item.takeRow(row) + else: + root_item.removeRows(0, rows) + self._items_by_id = {} + + def _get_workfie_representations(self): + output = [] + subset_docs = self._dbcon.find({ + "type": "subset", + "parent": self._asset_id + }) + filtered_subsets = [] + for subset_doc in subset_docs: + data = subset_doc.get("data") or {} + families = data.get("families") or [] + if "workfile" in families: + filtered_subsets.append(subset_doc) + + subset_ids = [subset_doc["_id"] for subset_doc in filtered_subsets] + if not subset_ids: + return output + + version_docs = self._dbcon.find({ + "type": "version", + "parent": {"$in": subset_ids} + }) + version_ids = [version_doc["_id"] for version_doc in version_docs] + if not version_ids: + return output + + extensions = [ext.replace(".", "") for ext in self._file_extensions] + repre_docs = self._dbcon.find( + { + "type": "representation", + "parent": {"$in": version_ids}, + "context.ext": {"$in": extensions} + } + ) + for repre_doc in repre_docs: + task_info = repre_doc["context"].get("task") + if not task_info: + print("Not task info") + continue + + if isinstance(task_info, dict): + task_name = task_info.get("name") + else: + task_name = task_info + + if task_name == self._task_name: + path = get_representation_path( + repre_doc, root=self._anatomy.roots + ) + output.append((path, repre_doc["_id"])) + return output + + def refresh(self): + root_item = self.invisibleRootItem() + if not self._asset_id or not self._task_name: + self._clear() + # Add Work Area does not exist placeholder + item = self._get_invalid_path_item() + root_item.appendRow(item) + self._invalid_item_visible = True + return + + if self._invalid_item_visible: + self._clear() + + new_items = [] + items_to_remove = set(self._items_by_id.keys()) + for item in self._get_workfie_representations(): + filepath, repre_id = item + modified = os.path.getmtime(filepath) + filename = os.path.basename(filepath) + + if repre_id in items_to_remove: + items_to_remove.remove(repre_id) + item = self._items_by_id[repre_id] + else: + item = QtGui.QStandardItem(filename) + item.setColumnCount(self.columnCount()) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + new_items.append(item) + self._items_by_id[repre_id] = item + item.setData(filepath, FILEPATH_ROLE) + item.setData(modified, DATE_MODIFIED_ROLE) + item.setData(repre_id, ITEM_ID_ROLE) + + if new_items: + root_item.appendRows(new_items) + + for filename in items_to_remove: + item = self._items_by_id.pop(filename) + root_item.removeRow(item.row()) + + if root_item.rowCount() > 0: + self._invalid_item_visible = False + else: + self._invalid_item_visible = True + item = self._get_empty_root_item() + root_item.appendRow(item) + + def has_valid_items(self): + return not self._invalid_item_visible + + def flags(self, index): + if index.column() != 0: + index = self.index(index.row(), 0, index.parent()) + return super(PublishFilesModel, self).flags(index) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + if index.column() == 1: + if role == QtCore.Qt.DecorationRole: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = DATE_MODIFIED_ROLE + index = self.index(index.row(), 0, index.parent()) + + return super(PublishFilesModel, self).data(index, role) + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Name" + elif section == 1: + return "Date modified" + + return super(PublishFilesModel, self).headerData( + section, orientation, role + ) From 02e4f239a97a559fab0cff88f20427c434737334 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 11:06:23 +0100 Subject: [PATCH 130/854] modified files widget to have view for workarea and published files --- openpype/tools/workfiles/files_widget.py | 195 +++++++++++++++++------ 1 file changed, 142 insertions(+), 53 deletions(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index d602ad3c1b..2c569064d4 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -20,6 +20,7 @@ from openpype.lib.avalon_context import ( ) from .model import ( WorkAreaFilesModel, + PublishFilesModel, FILEPATH_ROLE, DATE_MODIFIED_ROLE, @@ -76,36 +77,77 @@ class FilesWidget(QtWidgets.QWidget): # (setting parent doesn't work as it hides the message box) self._messagebox = None - files_view = FilesView(self) + # Filtering input + filter_widget = QtWidgets.QWidget(self) - # Create the Files model + published_checkbox = QtWidgets.QCheckBox("Published", filter_widget) + + filter_input = PlaceholderLineEdit(filter_widget) + filter_input.setPlaceholderText("Filter files..") + + filter_layout = QtWidgets.QHBoxLayout(filter_widget) + filter_layout.setContentsMargins(0, 0, 0, 0) + filter_layout.addWidget(published_checkbox, 0) + filter_layout.addWidget(filter_input, 1) + + # Create the Files models extensions = set(self.host.file_extensions()) - files_model = WorkAreaFilesModel(extensions) + + views_widget = QtWidgets.QWidget(self) + # Workarea view + workarea_files_model = WorkAreaFilesModel(extensions) # Create proxy model for files to be able sort and filter - proxy_model = QtCore.QSortFilterProxyModel() - proxy_model.setSourceModel(files_model) - proxy_model.setDynamicSortFilter(True) - proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + workarea_proxy_model = QtCore.QSortFilterProxyModel() + workarea_proxy_model.setSourceModel(workarea_files_model) + workarea_proxy_model.setDynamicSortFilter(True) + workarea_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) # Set up the file list tree view - files_view.setModel(proxy_model) - files_view.setSortingEnabled(True) - files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + workarea_files_view = FilesView(views_widget) + workarea_files_view.setModel(workarea_proxy_model) + workarea_files_view.setSortingEnabled(True) + workarea_files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) # Date modified delegate - time_delegate = PrettyTimeDelegate() - files_view.setItemDelegateForColumn(1, time_delegate) - files_view.setIndentation(3) # smaller indentation + workarea_time_delegate = PrettyTimeDelegate() + workarea_files_view.setItemDelegateForColumn(1, workarea_time_delegate) + workarea_files_view.setIndentation(3) # smaller indentation # Default to a wider first filename column it is what we mostly care # about and the date modified is relatively small anyway. - files_view.setColumnWidth(0, 330) + workarea_files_view.setColumnWidth(0, 330) - # Filtering input - filter_input = PlaceholderLineEdit(self) - filter_input.setPlaceholderText("Filter files..") - filter_input.textChanged.connect(proxy_model.setFilterFixedString) + # Publish files view + publish_files_model = PublishFilesModel(extensions, io, self.anatomy) + + publish_proxy_model = QtCore.QSortFilterProxyModel() + publish_proxy_model.setSourceModel(publish_files_model) + publish_proxy_model.setDynamicSortFilter(True) + publish_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive) + + publish_files_view = FilesView(views_widget) + publish_files_view.setModel(publish_proxy_model) + + publish_files_view.setSortingEnabled(True) + publish_files_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + # Date modified delegate + publish_time_delegate = PrettyTimeDelegate() + publish_files_view.setItemDelegateForColumn(1, publish_time_delegate) + publish_files_view.setIndentation(3) # smaller indentation + + # Default to a wider first filename column it is what we mostly care + # about and the date modified is relatively small anyway. + publish_files_view.setColumnWidth(0, 330) + + # Hide publish view first + publish_files_view.setVisible(False) + + views_layout = QtWidgets.QHBoxLayout(views_widget) + views_layout.setContentsMargins(0, 0, 0, 0) + views_layout.addWidget(workarea_files_view, 1) + views_layout.addWidget(publish_files_view, 1) # Home Page # Build buttons widget for files widget @@ -123,60 +165,103 @@ class FilesWidget(QtWidgets.QWidget): # Build files widgets for home page main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget(filter_input) - main_layout.addWidget(files_view) - main_layout.addWidget(btns_widget) + main_layout.addWidget(filter_widget, 0) + main_layout.addWidget(views_widget, 1) + main_layout.addWidget(btns_widget, 0) # Register signal callbacks - files_view.doubleClickedLeft.connect(self.on_open_pressed) - files_view.customContextMenuRequested.connect(self.on_context_menu) - files_view.selectionModel().selectionChanged.connect( + published_checkbox.stateChanged.connect(self._on_published_change) + filter_input.textChanged.connect(self._on_filter_text_change) + + workarea_files_view.doubleClickedLeft.connect( + self._on_workarea_open_pressed + ) + workarea_files_view.customContextMenuRequested.connect( + self._on_workarea_context_menu + ) + workarea_files_view.selectionModel().selectionChanged.connect( self.on_file_select ) - btn_open.pressed.connect(self.on_open_pressed) + btn_open.pressed.connect(self._on_workarea_open_pressed) btn_browse.pressed.connect(self.on_browse_pressed) btn_save.pressed.connect(self.on_save_as_pressed) # Store attributes - self.time_delegate = time_delegate + self._published_checkbox = published_checkbox + self._filter_input = filter_input - self.filter_input = filter_input + self._workarea_time_delegate = workarea_time_delegate + self._workarea_files_view = workarea_files_view + self._workarea_files_model = workarea_files_model + self._workarea_proxy_model = workarea_proxy_model - self.files_view = files_view - self.files_model = files_model + self._publish_time_delegate = publish_time_delegate + self._publish_files_view = publish_files_view + self._publish_files_model = publish_files_model + self._publish_proxy_model = publish_proxy_model self.btns_widget = btns_widget self.btn_open = btn_open self.btn_browse = btn_browse self.btn_save = btn_save + self._workarea_visible = True + + def _on_published_change(self): + workarea_visible = not self._published_checkbox.isChecked() + + self._workarea_files_view.setVisible(workarea_visible) + self._publish_files_view.setVisible(not workarea_visible) + + self._workarea_visible = workarea_visible + self._update_filtering() + self._update_asset_task() + + def _on_filter_text_change(self): + self._update_filtering() + + def _update_filtering(self): + text = self._filter_input.text() + if self._workarea_visible: + self._workarea_proxy_model.setFilterFixedString(text) + else: + self._publish_proxy_model.setFilterFixedString(text) + def set_asset_task(self, asset_id, task_name, task_type): if asset_id != self._asset_id: self._asset_doc = None self._asset_id = asset_id self._task_name = task_name self._task_type = task_type + self._update_asset_task() - # Define a custom session so we can query the work root - # for a "Work area" that is not our current Session. - # This way we can browse it even before we enter it. - if self._asset_id and self._task_name and self._task_type: - session = self._get_session() - self._workdir_path = session["AVALON_WORKDIR"] - self._workfiles_root = self.host.work_root(session) - self.files_model.set_root(self._workfiles_root) + def _update_asset_task(self): + if self._workarea_visible: + # Define a custom session so we can query the work root + # for a "Work area" that is not our current Session. + # This way we can browse it even before we enter it. + if self._asset_id and self._task_name and self._task_type: + session = self._get_session() + self._workdir_path = session["AVALON_WORKDIR"] + self._workfiles_root = self.host.work_root(session) + self._workarea_files_model.set_root(self._workfiles_root) + else: + self._workarea_files_model.set_root(None) + + # Disable/Enable buttons based on available files in model + has_valid_items = self._workarea_files_model.has_valid_items() + self.btn_browse.setEnabled(has_valid_items) + self.btn_open.setEnabled(has_valid_items) + if not has_valid_items: + # Manually trigger file selection + self.on_file_select() else: - self.files_model.set_root(None) - - # Disable/Enable buttons based on available files in model - has_valid_items = self.files_model.has_valid_items() - self.btn_browse.setEnabled(has_valid_items) - self.btn_open.setEnabled(has_valid_items) - if not has_valid_items: - # Manually trigger file selection - self.on_file_select() + self._publish_files_model.set_context( + self._asset_id, self._task_name + ) + has_valid_items = self._publish_files_model.has_valid_items() def _get_asset_doc(self): if self._asset_id is None: @@ -309,14 +394,18 @@ class FilesWidget(QtWidgets.QWidget): def _get_selected_filepath(self): """Return current filepath selected in view""" - selection = self.files_view.selectionModel() + if self._workarea_visible: + source_view = self._workarea_files_view + else: + source_view = self._publish_files_view + selection = source_view.selectionModel() index = selection.currentIndex() if not index.isValid(): return return index.data(FILEPATH_ROLE) - def on_open_pressed(self): + def _on_workarea_open_pressed(self): path = self._get_selected_filepath() if not path: print("No file selected to open..") @@ -396,12 +485,12 @@ class FilesWidget(QtWidgets.QWidget): def refresh(self): """Refresh listed files for current selection in the interface""" - self.files_model.refresh() + self._workarea_files_model.refresh() if self.auto_select_latest_modified: self._select_last_modified_file() - def on_context_menu(self, point): + def _on_workarea_context_menu(self, point): index = self._workarea_files_view.indexAt(point) if not index.isValid(): return @@ -420,14 +509,14 @@ class FilesWidget(QtWidgets.QWidget): menu.addAction(action) # Show the context action menu - global_point = self.files_view.mapToGlobal(point) + global_point = self._workarea_files_view.mapToGlobal(point) action = menu.exec_(global_point) if not action: return def _select_last_modified_file(self): """Utility function to select the file with latest date modified""" - model = self.files_view.model() + model = self._workarea_files_view.model() highest_index = None highest = 0 @@ -442,4 +531,4 @@ class FilesWidget(QtWidgets.QWidget): highest = modified if highest_index: - self.files_view.setCurrentIndex(highest_index) + self._workarea_files_view.setCurrentIndex(highest_index) From 2a9a49010506c4c083e82ee5ac7a1ddc864d76fa Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 11:21:45 +0100 Subject: [PATCH 131/854] simplified pretty time delegate --- openpype/tools/utils/delegates.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index d3718b1734..41de7cce60 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -287,9 +287,6 @@ class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate): """ def displayText(self, value, locale): - - if value is None: - # Ignore None value - return - - return pretty_timestamp(value) + if value is not None: + return pretty_timestamp(value) + return None From 76bef560a6650fcb4efa8a46c79ec8dda42ea4ff Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 21 Mar 2022 12:14:16 +0100 Subject: [PATCH 132/854] nuke: PR comments --- .../nuke/plugins/publish/extract_review_data_mov.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 6f6e07fc28..31a8ff18ee 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -24,8 +24,10 @@ class ExtractReviewDataMov(openpype.api.Extractor): outputs = {} def process(self, instance): - families = instance.data["families"] - families.append(instance.data["family"]) + families = set(instance.data["families"]) + + # add main family to make sure all families are compared + families.add(instance.data["family"]) task_type = instance.context.data["taskType"] subset = instance.data["subset"] @@ -67,13 +69,11 @@ class ExtractReviewDataMov(openpype.api.Extractor): # test if family found in context # using intersection to make sure all defined # families are present in combination - if f_families and not any( - set(families).intersection(f_families)): + if f_families and not families.intersection(f_families): continue # test task types from filter - if f_task_types and not bool( - task_type in f_task_types): + if f_task_types and task_type not in f_task_types: continue # test subsets from filter From ea9fb6c841bafe695d9618e8dc6f05d08fd7980e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 12:18:52 +0100 Subject: [PATCH 133/854] small tweaks and changes --- openpype/tools/workfiles/files_widget.py | 109 +++++++++++++++-------- openpype/tools/workfiles/model.py | 35 +++++--- openpype/tools/workfiles/window.py | 16 +++- 3 files changed, 109 insertions(+), 51 deletions(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 2c569064d4..fb36efea33 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -47,11 +47,12 @@ class FilesView(QtWidgets.QTreeView): class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" file_selected = QtCore.Signal(str) - workfile_created = QtCore.Signal(str) file_opened = QtCore.Signal() + workfile_created = QtCore.Signal(str) + published_visible_changed = QtCore.Signal(bool) - def __init__(self, parent=None): - super(FilesWidget, self).__init__(parent=parent) + def __init__(self, parent): + super(FilesWidget, self).__init__(parent) # Setup self._asset_id = None @@ -141,9 +142,6 @@ class FilesWidget(QtWidgets.QWidget): # about and the date modified is relatively small anyway. publish_files_view.setColumnWidth(0, 330) - # Hide publish view first - publish_files_view.setVisible(False) - views_layout = QtWidgets.QHBoxLayout(views_widget) views_layout.setContentsMargins(0, 0, 0, 0) views_layout.addWidget(workarea_files_view, 1) @@ -156,11 +154,14 @@ class FilesWidget(QtWidgets.QWidget): btn_browse = QtWidgets.QPushButton("Browse", btns_widget) btn_open = QtWidgets.QPushButton("Open", btns_widget) + btn_view_published = QtWidgets.QPushButton("View", btns_widget) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addWidget(btn_open) - btns_layout.addWidget(btn_browse) - btns_layout.addWidget(btn_save) + btns_layout.addWidget(btn_open, 1) + btns_layout.addWidget(btn_browse, 1) + btns_layout.addWidget(btn_save, 1) + btns_layout.addWidget(btn_view_published, 1) # Build files widgets for home page main_layout = QtWidgets.QVBoxLayout(self) @@ -186,6 +187,7 @@ class FilesWidget(QtWidgets.QWidget): btn_open.pressed.connect(self._on_workarea_open_pressed) btn_browse.pressed.connect(self.on_browse_pressed) btn_save.pressed.connect(self.on_save_as_pressed) + btn_view_published.pressed.connect(self._on_view_published_pressed) # Store attributes self._published_checkbox = published_checkbox @@ -201,32 +203,51 @@ class FilesWidget(QtWidgets.QWidget): self._publish_files_model = publish_files_model self._publish_proxy_model = publish_proxy_model - self.btns_widget = btns_widget - self.btn_open = btn_open - self.btn_browse = btn_browse - self.btn_save = btn_save + self._btns_widget = btns_widget + self._btn_open = btn_open + self._btn_browse = btn_browse + self._btn_save = btn_save + self._btn_view_published = btn_view_published - self._workarea_visible = True + # Create a proxy widget for files widget + self.setFocusProxy(btn_open) + + # Hide publish files widgets + publish_files_view.setVisible(False) + btn_view_published.setVisible(False) + + @property + def published_enabled(self): + return self._published_checkbox.isChecked() def _on_published_change(self): - workarea_visible = not self._published_checkbox.isChecked() + published_enabled = self.published_enabled - self._workarea_files_view.setVisible(workarea_visible) - self._publish_files_view.setVisible(not workarea_visible) + self._workarea_files_view.setVisible(not published_enabled) + self._btn_open.setVisible(not published_enabled) + self._btn_browse.setVisible(not published_enabled) + self._btn_save.setVisible(not published_enabled) + + self._publish_files_view.setVisible(published_enabled) + self._btn_view_published.setVisible(published_enabled) - self._workarea_visible = workarea_visible self._update_filtering() self._update_asset_task() + self.published_visible_changed.emit(published_enabled) + def _on_filter_text_change(self): self._update_filtering() def _update_filtering(self): text = self._filter_input.text() - if self._workarea_visible: - self._workarea_proxy_model.setFilterFixedString(text) - else: + if self.published_enabled: self._publish_proxy_model.setFilterFixedString(text) + else: + self._workarea_proxy_model.setFilterFixedString(text) + + def set_save_enabled(self, enabled): + self._btn_save.setEnabled(enabled) def set_asset_task(self, asset_id, task_name, task_type): if asset_id != self._asset_id: @@ -237,7 +258,13 @@ class FilesWidget(QtWidgets.QWidget): self._update_asset_task() def _update_asset_task(self): - if self._workarea_visible: + if self.published_enabled: + self._publish_files_model.set_context( + self._asset_id, self._task_name + ) + has_valid_items = self._publish_files_model.has_valid_items() + self._btn_view_published.setEnabled(has_valid_items) + else: # Define a custom session so we can query the work root # for a "Work area" that is not our current Session. # This way we can browse it even before we enter it. @@ -252,16 +279,11 @@ class FilesWidget(QtWidgets.QWidget): # Disable/Enable buttons based on available files in model has_valid_items = self._workarea_files_model.has_valid_items() - self.btn_browse.setEnabled(has_valid_items) - self.btn_open.setEnabled(has_valid_items) - if not has_valid_items: - # Manually trigger file selection - self.on_file_select() - else: - self._publish_files_model.set_context( - self._asset_id, self._task_name - ) - has_valid_items = self._publish_files_model.has_valid_items() + self._btn_browse.setEnabled(has_valid_items) + self._btn_open.setEnabled(has_valid_items) + # Manually trigger file selection + if not has_valid_items: + self.on_file_select() def _get_asset_doc(self): if self._asset_id is None: @@ -394,10 +416,10 @@ class FilesWidget(QtWidgets.QWidget): def _get_selected_filepath(self): """Return current filepath selected in view""" - if self._workarea_visible: - source_view = self._workarea_files_view - else: + if self.published_enabled: source_view = self._publish_files_view + else: + source_view = self._workarea_files_view selection = source_view.selectionModel() index = selection.currentIndex() if not index.isValid(): @@ -480,12 +502,19 @@ class FilesWidget(QtWidgets.QWidget): # Refresh files model self.refresh() + def _on_view_published_pressed(self): + print("View of published workfile triggered") + def on_file_select(self): self.file_selected.emit(self._get_selected_filepath()) def refresh(self): """Refresh listed files for current selection in the interface""" - self._workarea_files_model.refresh() + if self.published_enabled: + self._publish_files_model.refresh() + else: + self._workarea_files_model.refresh() + if self.auto_select_latest_modified: self._select_last_modified_file() @@ -516,7 +545,11 @@ class FilesWidget(QtWidgets.QWidget): def _select_last_modified_file(self): """Utility function to select the file with latest date modified""" - model = self._workarea_files_view.model() + if self.published_enabled: + source_view = self._publish_files_view + else: + source_view = self._workarea_files_view + model = source_view.model() highest_index = None highest = 0 @@ -531,4 +564,4 @@ class FilesWidget(QtWidgets.QWidget): highest = modified if highest_index: - self._workarea_files_view.setCurrentIndex(highest_index) + source_view.setCurrentIndex(highest_index) diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index f38c80b190..fa0dddc2bc 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -51,7 +51,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): def _get_empty_root_item(self): if self._empty_root_item is None: - message = "Work Area does not exist. Use Save As to create it." + message = "Work Area is empty." item = QtGui.QStandardItem(message) icon = qtawesome.icon( "fa.times", @@ -198,7 +198,7 @@ class PublishFilesModel(QtGui.QStandardItemModel): def _get_invalid_context_item(self): if self._invalid_context_item is None: - message = "Selected context is not vald." + message = "Selected context is not valid." item = QtGui.QStandardItem(message) icon = qtawesome.icon( "fa.times", @@ -242,10 +242,17 @@ class PublishFilesModel(QtGui.QStandardItemModel): def _get_workfie_representations(self): output = [] - subset_docs = self._dbcon.find({ - "type": "subset", - "parent": self._asset_id - }) + subset_docs = self._dbcon.find( + { + "type": "subset", + "parent": self._asset_id + }, + { + "_id": True, + "data.families": True, + "name": True + } + ) filtered_subsets = [] for subset_doc in subset_docs: data = subset_doc.get("data") or {} @@ -257,10 +264,16 @@ class PublishFilesModel(QtGui.QStandardItemModel): if not subset_ids: return output - version_docs = self._dbcon.find({ - "type": "version", - "parent": {"$in": subset_ids} - }) + version_docs = self._dbcon.find( + { + "type": "version", + "parent": {"$in": subset_ids} + }, + { + "_id": True, + "parent": True + } + ) version_ids = [version_doc["_id"] for version_doc in version_docs] if not version_ids: return output @@ -296,7 +309,7 @@ class PublishFilesModel(QtGui.QStandardItemModel): if not self._asset_id or not self._task_name: self._clear() # Add Work Area does not exist placeholder - item = self._get_invalid_path_item() + item = self._get_invalid_context_item() root_item.appendRow(item) self._invalid_item_visible = True return diff --git a/openpype/tools/workfiles/window.py b/openpype/tools/workfiles/window.py index f68b721872..c90edc079c 100644 --- a/openpype/tools/workfiles/window.py +++ b/openpype/tools/workfiles/window.py @@ -42,12 +42,18 @@ class SidePanelWidget(QtWidgets.QWidget): btn_note_save.clicked.connect(self.on_save_click) self.details_input = details_input + self.note_label = note_label self.note_input = note_input self.btn_note_save = btn_note_save self._orig_note = "" self._workfile_doc = None + def set_published_visible(self, published_visible): + self.note_label.setVisible(not published_visible) + self.note_input.setVisible(not published_visible) + self.btn_note_save.setVisible(not published_visible) + def on_note_change(self): text = self.note_input.toPlainText() self.btn_note_save.setEnabled(self._orig_note != text) @@ -178,6 +184,9 @@ class Window(QtWidgets.QMainWindow): files_widget.file_selected.connect(self.on_file_select) files_widget.workfile_created.connect(self.on_workfile_create) files_widget.file_opened.connect(self._on_file_opened) + files_widget.published_visible_changed.connect( + self._on_published_change + ) side_panel.save_clicked.connect(self.on_side_panel_save) self._set_context_timer = set_context_timer @@ -192,7 +201,7 @@ class Window(QtWidgets.QMainWindow): self.side_panel = side_panel # Force focus on the open button by default, required for Houdini. - files_widget.btn_open.setFocus() + files_widget.setFocus() self.resize(1200, 600) @@ -217,7 +226,7 @@ class Window(QtWidgets.QMainWindow): """ def set_save_enabled(self, enabled): - self.files_widget.btn_save.setEnabled(enabled) + self.files_widget.set_save_enabled(enabled) def on_file_select(self, filepath): asset_id = self.assets_widget.get_selected_asset_id() @@ -239,6 +248,9 @@ class Window(QtWidgets.QMainWindow): def _on_file_opened(self): self.close() + def _on_published_change(self, visible): + self.side_panel.set_published_visible(visible) + def on_side_panel_save(self): workfile_doc, data = self.side_panel.get_workfile_data() if not workfile_doc: From b59100025e22cdc211615e2a868e902dbf8ad832 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Mon, 21 Mar 2022 14:31:58 +0300 Subject: [PATCH 134/854] Fix hound comment warning line length for comments adjusted --- .../modules/deadline/plugins/publish/submit_publish_job.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 2c8bcdf4fc..2ad1dcd691 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -449,8 +449,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if app in self.aov_filter.keys(): for aov_pattern in self.aov_filter[app]: # Matching against the AOV pattern in the render files - # In order to match the AOV name, we must compare against the render filename string - # We are grabbing the render filename string from the collection that we have grabbed from expected files (exp_files) + # In order to match the AOV name + # we must compare against the render filename string + # We are grabbing the render filename string + # from the collection that we have grabbed from expected files (exp_files) render_file_name = os.path.basename(col[0]) if re.match(aov_pattern, render_file_name): preview = True From 15b1c86e88bd055b815d663047856f019c54f779 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Mon, 21 Mar 2022 14:31:58 +0300 Subject: [PATCH 135/854] Fix hound comment warning line length for comments adjusted --- .../modules/deadline/plugins/publish/submit_publish_job.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 2c8bcdf4fc..2ad1dcd691 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -449,8 +449,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if app in self.aov_filter.keys(): for aov_pattern in self.aov_filter[app]: # Matching against the AOV pattern in the render files - # In order to match the AOV name, we must compare against the render filename string - # We are grabbing the render filename string from the collection that we have grabbed from expected files (exp_files) + # In order to match the AOV name + # we must compare against the render filename string + # We are grabbing the render filename string + # from the collection that we have grabbed from expected files (exp_files) render_file_name = os.path.basename(col[0]) if re.match(aov_pattern, render_file_name): preview = True From b517fa0ce068ac9ec9f5d52f1430e31e2a69ea63 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Mon, 21 Mar 2022 15:13:40 +0300 Subject: [PATCH 136/854] Fix hound warning, again --- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 2ad1dcd691..5dd1be1f54 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -452,7 +452,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # In order to match the AOV name # we must compare against the render filename string # We are grabbing the render filename string - # from the collection that we have grabbed from expected files (exp_files) + # from the collection that we have grabbed from exp_files render_file_name = os.path.basename(col[0]) if re.match(aov_pattern, render_file_name): preview = True From 8efa09c6d2d4fc2558ed6e814a00d76981f78a42 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Mon, 21 Mar 2022 15:18:21 +0300 Subject: [PATCH 137/854] Simplify regex for "beauty" pass Simplifies the "regex" used to sift for the reviewable pass for thumbnail and burnin --- openpype/settings/defaults/project_settings/deadline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 1859b480a1..b2104a04eb 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -80,7 +80,7 @@ "skip_integration_repre_list": [], "aov_filter": { "maya": [ - ".*(?:[\\._-])*([Bb]eauty)(?:[\\.|_])*.*" + ".*([Bb]eauty).*" ], "nuke": [ ".*" From 80e08d43ce3617d57280a04ae426c0d42a595709 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Mon, 21 Mar 2022 15:53:39 +0300 Subject: [PATCH 138/854] Test if col[0] is remainder or list for file_name --- .../modules/deadline/plugins/publish/submit_publish_job.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 5dd1be1f54..b1f6f9a485 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -453,7 +453,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # we must compare against the render filename string # We are grabbing the render filename string # from the collection that we have grabbed from exp_files - render_file_name = os.path.basename(col[0]) + if isinstance(col, list): + render_file_name = os.path.basename(col[0]) + else: + render_file_name = os.path.basename(col) if re.match(aov_pattern, render_file_name): preview = True break From 5a81596bd86b0440cc34937d3342904ef6fe905b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 14:17:56 +0100 Subject: [PATCH 139/854] added basic system of temping workfiles on user's side --- openpype/tools/workfiles/files_widget.py | 141 ++++++++++++++++++++++- 1 file changed, 139 insertions(+), 2 deletions(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index fb36efea33..9f133fd47d 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -1,11 +1,16 @@ import os import logging import shutil +import json +import time +import uuid import Qt from Qt import QtWidgets, QtCore from avalon import io, api +import appdirs + from openpype.tools.utils import PlaceholderLineEdit from openpype.tools.utils.delegates import PrettyTimeDelegate from openpype.lib import ( @@ -30,6 +35,134 @@ from .save_as_dialog import SaveAsDialog log = logging.getLogger(__name__) +class TempPublishFilesItem(object): + """Object representing on subfolder in app temp files. + + Args: + item_id (str): Id of item used as subfolder. + data (dict): Metadata about temp files. + directory (str): Path to directory where files are copied to. + """ + + def __init__(self, item_id, data, directory): + self._id = item_id + self._directory = directory + self._filepath = os.path.join(directory, data["filename"]) + + @property + def directory(self): + return self._directory + + @property + def filepath(self): + return self._filepath + + @property + def id(self): + return self._id + + +class TempPublishFiles(object): + """Directory where """ + minute_in_seconds = 60 + hour_in_seconds = 60 * minute_in_seconds + day_in_seconds = 24 * hour_in_seconds + + def __init__(self): + root_dir = appdirs.user_data_dir( + "published_workfiles_temp", "openpype" + ) + if not os.path.exists(root_dir): + os.makedirs(root_dir) + + metadata_path = os.path.join(root_dir, "metadata.json") + + self._root_dir = root_dir + self._metadata_path = metadata_path + + if not os.path.exists(metadata_path): + self._store_data({}) + + @property + def life_time(self): + return int(self.hour_in_seconds) + + def add_file(self, src_path): + filename = os.path.basename(src_path) + + item_id = str(uuid.uuid4()) + dst_dirpath = os.path.join(self._root_dir, item_id) + if not os.path.exists(dst_dirpath): + os.makedirs(dst_dirpath) + + dst_path = os.path.join(dst_dirpath, filename) + shutil.copy(src_path, dst_path) + + now = time.time() + item_data = { + "filename": filename, + "expiration": now + self.life_time, + "created": now + } + data = self._get_data() + data[item_id] = item_data + self._store_data(data) + return TempPublishFilesItem(item_id, item_data, dst_dirpath) + + def _store_data(self, data): + with open(self._metadata_path, "w") as stream: + json.dump(data, stream) + + def _get_data(self): + if not os.path.exists(self._metadata_path): + return {} + + with open(self._metadata_path, "r") as stream: + output = json.load(stream) + return output + + def cleanup(self, check_expiration=True): + data = self._get_data() + now = time.time() + remove_ids = set() + for item_id, item_data in data.items(): + if check_expiration and now < item_data["expiration"]: + continue + + remove_ids.add(item_id) + + for item_id in remove_ids: + try: + self.remove_id(item_id) + except Exception: + log.warning( + "Failed to remove temp publish item \"{}\"".format( + item_id + ), + exc_info=True + ) + + def clear(self): + self.cleanup(False) + + def get_items(self): + output = [] + for item_id, item_data in self._get_data(): + item_path = os.path.join(self._root_dir, item_id) + output.append(TempPublishFiles(item_id, item_data, item_path)) + return output + + def remove_id(self, item_id): + filepath = os.path.join(self._root_dir, item_id) + if os.path.exists(filepath): + shutil.rmtree(filepath) + + data = self._get_data() + if item_id in data: + data.pop(item_id) + self._store_data(data) + + class FilesView(QtWidgets.QTreeView): doubleClickedLeft = QtCore.Signal() doubleClickedRight = QtCore.Signal() @@ -69,6 +202,9 @@ class FilesWidget(QtWidgets.QWidget): self._workfiles_root = None self._workdir_path = None self.host = api.registered_host() + temp_publish_files = TempPublishFiles() + temp_publish_files.cleanup() + self._temp_publish_files = temp_publish_files # Whether to automatically select the latest modified # file on a refresh of the files model. @@ -503,7 +639,9 @@ class FilesWidget(QtWidgets.QWidget): self.refresh() def _on_view_published_pressed(self): - print("View of published workfile triggered") + filepath = self._get_selected_filepath() + item = self._temp_publish_files.add_file(filepath) + self.host.open_file(item.filepath) def on_file_select(self): self.file_selected.emit(self._get_selected_filepath()) @@ -515,7 +653,6 @@ class FilesWidget(QtWidgets.QWidget): else: self._workarea_files_model.refresh() - if self.auto_select_latest_modified: self._select_last_modified_file() From 46b4f6f544b3e26295903893351d95ba82cc6b1d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 17:03:29 +0100 Subject: [PATCH 140/854] added ability to clear cached files --- openpype/tools/workfiles/files_widget.py | 138 +-------------- openpype/tools/workfiles/lib.py | 195 +++++++++++++++++++++ openpype/tools/workfiles/save_as_dialog.py | 50 ++++-- openpype/tools/workfiles/window.py | 135 +++++++++----- 4 files changed, 323 insertions(+), 195 deletions(-) create mode 100644 openpype/tools/workfiles/lib.py diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 9f133fd47d..071be4ec1c 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -1,16 +1,11 @@ import os import logging import shutil -import json -import time -import uuid import Qt from Qt import QtWidgets, QtCore from avalon import io, api -import appdirs - from openpype.tools.utils import PlaceholderLineEdit from openpype.tools.utils.delegates import PrettyTimeDelegate from openpype.lib import ( @@ -31,138 +26,11 @@ from .model import ( DATE_MODIFIED_ROLE, ) from .save_as_dialog import SaveAsDialog +from .lib import TempPublishFiles log = logging.getLogger(__name__) -class TempPublishFilesItem(object): - """Object representing on subfolder in app temp files. - - Args: - item_id (str): Id of item used as subfolder. - data (dict): Metadata about temp files. - directory (str): Path to directory where files are copied to. - """ - - def __init__(self, item_id, data, directory): - self._id = item_id - self._directory = directory - self._filepath = os.path.join(directory, data["filename"]) - - @property - def directory(self): - return self._directory - - @property - def filepath(self): - return self._filepath - - @property - def id(self): - return self._id - - -class TempPublishFiles(object): - """Directory where """ - minute_in_seconds = 60 - hour_in_seconds = 60 * minute_in_seconds - day_in_seconds = 24 * hour_in_seconds - - def __init__(self): - root_dir = appdirs.user_data_dir( - "published_workfiles_temp", "openpype" - ) - if not os.path.exists(root_dir): - os.makedirs(root_dir) - - metadata_path = os.path.join(root_dir, "metadata.json") - - self._root_dir = root_dir - self._metadata_path = metadata_path - - if not os.path.exists(metadata_path): - self._store_data({}) - - @property - def life_time(self): - return int(self.hour_in_seconds) - - def add_file(self, src_path): - filename = os.path.basename(src_path) - - item_id = str(uuid.uuid4()) - dst_dirpath = os.path.join(self._root_dir, item_id) - if not os.path.exists(dst_dirpath): - os.makedirs(dst_dirpath) - - dst_path = os.path.join(dst_dirpath, filename) - shutil.copy(src_path, dst_path) - - now = time.time() - item_data = { - "filename": filename, - "expiration": now + self.life_time, - "created": now - } - data = self._get_data() - data[item_id] = item_data - self._store_data(data) - return TempPublishFilesItem(item_id, item_data, dst_dirpath) - - def _store_data(self, data): - with open(self._metadata_path, "w") as stream: - json.dump(data, stream) - - def _get_data(self): - if not os.path.exists(self._metadata_path): - return {} - - with open(self._metadata_path, "r") as stream: - output = json.load(stream) - return output - - def cleanup(self, check_expiration=True): - data = self._get_data() - now = time.time() - remove_ids = set() - for item_id, item_data in data.items(): - if check_expiration and now < item_data["expiration"]: - continue - - remove_ids.add(item_id) - - for item_id in remove_ids: - try: - self.remove_id(item_id) - except Exception: - log.warning( - "Failed to remove temp publish item \"{}\"".format( - item_id - ), - exc_info=True - ) - - def clear(self): - self.cleanup(False) - - def get_items(self): - output = [] - for item_id, item_data in self._get_data(): - item_path = os.path.join(self._root_dir, item_id) - output.append(TempPublishFiles(item_id, item_data, item_path)) - return output - - def remove_id(self, item_id): - filepath = os.path.join(self._root_dir, item_id) - if os.path.exists(filepath): - shutil.rmtree(filepath) - - data = self._get_data() - if item_id in data: - data.pop(item_id) - self._store_data(data) - - class FilesView(QtWidgets.QTreeView): doubleClickedLeft = QtCore.Signal() doubleClickedRight = QtCore.Signal() @@ -181,6 +49,7 @@ class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" file_selected = QtCore.Signal(str) file_opened = QtCore.Signal() + publish_file_viewed = QtCore.Signal() workfile_created = QtCore.Signal(str) published_visible_changed = QtCore.Signal(bool) @@ -372,6 +241,8 @@ class FilesWidget(QtWidgets.QWidget): self.published_visible_changed.emit(published_enabled) + self._select_last_modified_file() + def _on_filter_text_change(self): self._update_filtering() @@ -642,6 +513,7 @@ class FilesWidget(QtWidgets.QWidget): filepath = self._get_selected_filepath() item = self._temp_publish_files.add_file(filepath) self.host.open_file(item.filepath) + self.publish_file_viewed.emit() def on_file_select(self): self.file_selected.emit(self._get_selected_filepath()) diff --git a/openpype/tools/workfiles/lib.py b/openpype/tools/workfiles/lib.py new file mode 100644 index 0000000000..c181e634d6 --- /dev/null +++ b/openpype/tools/workfiles/lib.py @@ -0,0 +1,195 @@ +import os +import shutil +import uuid +import time +import json +import logging +import contextlib + +import appdirs + + +class TempPublishFilesItem(object): + """Object representing on subfolder in app temp files. + + Args: + item_id (str): Id of item used as subfolder. + data (dict): Metadata about temp files. + directory (str): Path to directory where files are copied to. + """ + + def __init__(self, item_id, data, directory): + self._id = item_id + self._directory = directory + self._filepath = os.path.join(directory, data["filename"]) + + @property + def directory(self): + return self._directory + + @property + def filepath(self): + return self._filepath + + @property + def id(self): + return self._id + + @property + def size(self): + if os.path.exists(self.filepath): + s = os.stat(self.filepath) + return s.st_size + return 0 + + +class TempPublishFiles(object): + """Directory where """ + minute_in_seconds = 60 + hour_in_seconds = 60 * minute_in_seconds + day_in_seconds = 24 * hour_in_seconds + + def __init__(self): + root_dir = appdirs.user_data_dir( + "published_workfiles_temp", "openpype" + ) + if not os.path.exists(root_dir): + os.makedirs(root_dir) + + metadata_path = os.path.join(root_dir, "metadata.json") + lock_path = os.path.join(root_dir, "lock.json") + + self._root_dir = root_dir + self._metadata_path = metadata_path + self._lock_path = lock_path + self._log = None + + @property + def log(self): + if self._log is None: + self._log = logging.getLogger(self.__class__.__name__) + return self._log + + @property + def life_time(self): + return int(self.hour_in_seconds) + + @property + def size(self): + size = 0 + for item in self.get_items(): + size += item.size + return size + + def add_file(self, src_path): + filename = os.path.basename(src_path) + + item_id = str(uuid.uuid4()) + dst_dirpath = os.path.join(self._root_dir, item_id) + if not os.path.exists(dst_dirpath): + os.makedirs(dst_dirpath) + + dst_path = os.path.join(dst_dirpath, filename) + shutil.copy(src_path, dst_path) + + now = time.time() + item_data = { + "filename": filename, + "expiration": now + self.life_time, + "created": now + } + with self._modify_data() as data: + data[item_id] = item_data + + return TempPublishFilesItem(item_id, item_data, dst_dirpath) + + @contextlib.contextmanager + def _modify_data(self): + start_time = time.time() + timeout = 3 + while os.path.exists(self._lock_path): + time.sleep(0.01) + if start_time > timeout: + self.log.warning(( + "Waited for {} seconds to free lock file. Overriding lock." + ).format(timeout)) + + with open(self._lock_path, "w") as stream: + json.dump({"pid": os.getpid()}, stream) + + try: + data = self._get_data() + yield data + with open(self._metadata_path, "w") as stream: + json.dump(data, stream) + + finally: + os.remove(self._lock_path) + + def _get_data(self): + output = {} + if not os.path.exists(self._metadata_path): + return output + + try: + with open(self._metadata_path, "r") as stream: + output = json.load(stream) + except Exception: + self.log.warning("Failed to read metadata file.", exc_info=True) + return output + + def cleanup(self, check_expiration=True): + data = self._get_data() + now = time.time() + remove_ids = set() + for item_id, item_data in data.items(): + if check_expiration and now < item_data["expiration"]: + continue + + remove_ids.add(item_id) + + for item_id in remove_ids: + try: + self.remove_id(item_id) + except Exception: + self.log.warning( + "Failed to remove temp publish item \"{}\"".format( + item_id + ), + exc_info=True + ) + + def clear(self): + self.cleanup(False) + + def get_items(self): + output = [] + data = self._get_data() + for item_id, item_data in data.items(): + item_path = os.path.join(self._root_dir, item_id) + output.append(TempPublishFilesItem(item_id, item_data, item_path)) + return output + + def remove_id(self, item_id): + filepath = os.path.join(self._root_dir, item_id) + if os.path.exists(filepath): + shutil.rmtree(filepath) + + with self._modify_data() as data: + data.pop(item_id, None) + + +def file_size_to_string(file_size): + size = 0 + size_ending_mapping = { + "KB": 1024 ** 1, + "MB": 1024 ** 2, + "GB": 1024 ** 3 + } + ending = "B" + for _ending, _size in size_ending_mapping.items(): + if file_size < _size: + break + size = file_size / _size + ending = _ending + return "{:.2f} {}".format(size, ending) diff --git a/openpype/tools/workfiles/save_as_dialog.py b/openpype/tools/workfiles/save_as_dialog.py index 399d54bd54..e616a325cc 100644 --- a/openpype/tools/workfiles/save_as_dialog.py +++ b/openpype/tools/workfiles/save_as_dialog.py @@ -107,25 +107,39 @@ class CommentMatcher(object): class SubversionLineEdit(QtWidgets.QWidget): """QLineEdit with QPushButton for drop down selection of list of strings""" - def __init__(self, parent=None): - super(SubversionLineEdit, self).__init__(parent=parent) + + text_changed = QtCore.Signal(str) + + def __init__(self, *args, **kwargs): + super(SubversionLineEdit, self).__init__(*args, **kwargs) + + input_field = PlaceholderLineEdit(self) + menu_btn = QtWidgets.QPushButton(self) + menu_btn.setFixedWidth(18) + + menu = QtWidgets.QMenu(self) + menu_btn.setMenu(menu) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(3) - self._input = PlaceholderLineEdit() - self._button = QtWidgets.QPushButton("") - self._button.setFixedWidth(18) - self._menu = QtWidgets.QMenu(self) - self._button.setMenu(self._menu) + layout.addWidget(input_field, 1) + layout.addWidget(menu_btn, 0) - layout.addWidget(self._input) - layout.addWidget(self._button) + input_field.textChanged.connect(self.text_changed) - @property - def input(self): - return self._input + self.setFocusProxy(input_field) + + self._input_field = input_field + self._menu_btn = menu_btn + self._menu = menu + + def set_placeholder(self, placeholder): + self._input_field.setPlaceholderText(placeholder) + + def set_text(self, text): + self._input_field.setText(text) def set_values(self, values): self._update(values) @@ -134,7 +148,7 @@ class SubversionLineEdit(QtWidgets.QWidget): self._menu.exec_() def _on_action_clicked(self, action): - self._input.setText(action.text()) + self._input_field.setText(action.text()) def _update(self, values): """Create optional predefined subset names @@ -147,7 +161,7 @@ class SubversionLineEdit(QtWidgets.QWidget): """ menu = self._menu - button = self._button + button = self._menu_btn state = any(values) button.setEnabled(state) @@ -236,7 +250,7 @@ class SaveAsDialog(QtWidgets.QDialog): # Subversion input subversion = SubversionLineEdit(inputs_widget) - subversion.input.setPlaceholderText("Will be part of filename.") + subversion.set_placeholder("Will be part of filename.") # Extensions combobox ext_combo = QtWidgets.QComboBox(inputs_widget) @@ -271,7 +285,7 @@ class SaveAsDialog(QtWidgets.QDialog): if comment: log.info("Detected subversion comment: {}".format(comment)) self.data["comment"] = comment - subversion.input.setText(comment) + subversion.set_text(comment) existing_comments = self.get_existing_comments() subversion.set_values(existing_comments) @@ -292,7 +306,7 @@ class SaveAsDialog(QtWidgets.QDialog): self.on_version_checkbox_changed ) - subversion.input.textChanged.connect(self.on_comment_changed) + subversion.text_changed.connect(self.on_comment_changed) ext_combo.currentIndexChanged.connect(self.on_extension_changed) btn_ok.pressed.connect(self.on_ok_pressed) @@ -303,7 +317,7 @@ class SaveAsDialog(QtWidgets.QDialog): # Force default focus to comment, some hosts didn't automatically # apply focus to this line edit (e.g. Houdini) - subversion.input.setFocus() + subversion.setFocus() # Store widgets self.btn_ok = btn_ok diff --git a/openpype/tools/workfiles/window.py b/openpype/tools/workfiles/window.py index c90edc079c..7f5bbd1ee7 100644 --- a/openpype/tools/workfiles/window.py +++ b/openpype/tools/workfiles/window.py @@ -14,10 +14,15 @@ from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget from openpype.tools.utils.tasks_widget import TasksWidget from .files_widget import FilesWidget +from .lib import TempPublishFiles, file_size_to_string class SidePanelWidget(QtWidgets.QWidget): save_clicked = QtCore.Signal() + published_workfile_message = ( + "INFO: Published workfiles you'll opened will be stored in" + " temp directory on your machine. Current temp size: {}." + ) def __init__(self, parent=None): super(SidePanelWidget, self).__init__(parent) @@ -26,41 +31,88 @@ class SidePanelWidget(QtWidgets.QWidget): details_input = QtWidgets.QPlainTextEdit(self) details_input.setReadOnly(True) - note_label = QtWidgets.QLabel("Artist note", self) - note_input = QtWidgets.QPlainTextEdit(self) - btn_note_save = QtWidgets.QPushButton("Save note", self) + artist_note_widget = QtWidgets.QWidget(self) + note_label = QtWidgets.QLabel("Artist note", artist_note_widget) + note_input = QtWidgets.QPlainTextEdit(artist_note_widget) + btn_note_save = QtWidgets.QPushButton("Save note", artist_note_widget) + + artist_note_layout = QtWidgets.QVBoxLayout(artist_note_widget) + artist_note_layout.setContentsMargins(0, 0, 0, 0) + artist_note_layout.addWidget(note_label, 0) + artist_note_layout.addWidget(note_input, 1) + artist_note_layout.addWidget( + btn_note_save, 0, alignment=QtCore.Qt.AlignRight + ) + + publish_temp_widget = QtWidgets.QWidget(self) + publish_temp_info_label = QtWidgets.QLabel( + self.published_workfile_message.format( + file_size_to_string(0) + ), + publish_temp_widget + ) + publish_temp_info_label.setWordWrap(True) + + btn_clear_temp = QtWidgets.QPushButton( + "Clear temp", publish_temp_widget + ) + + publish_temp_layout = QtWidgets.QVBoxLayout(publish_temp_widget) + publish_temp_layout.setContentsMargins(0, 0, 0, 0) + publish_temp_layout.addWidget(publish_temp_info_label, 0) + publish_temp_layout.addWidget( + btn_clear_temp, 0, alignment=QtCore.Qt.AlignRight + ) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(details_label, 0) - main_layout.addWidget(details_input, 0) - main_layout.addWidget(note_label, 0) - main_layout.addWidget(note_input, 1) - main_layout.addWidget(btn_note_save, alignment=QtCore.Qt.AlignRight) + main_layout.addWidget(details_input, 1) + main_layout.addWidget(artist_note_widget, 1) + main_layout.addWidget(publish_temp_widget, 0) - note_input.textChanged.connect(self.on_note_change) - btn_note_save.clicked.connect(self.on_save_click) + note_input.textChanged.connect(self._on_note_change) + btn_note_save.clicked.connect(self._on_save_click) + btn_clear_temp.clicked.connect(self._on_clear_temp_click) - self.details_input = details_input - self.note_label = note_label - self.note_input = note_input - self.btn_note_save = btn_note_save + self._details_input = details_input + self._artist_note_widget = artist_note_widget + self._note_input = note_input + self._btn_note_save = btn_note_save + + self._publish_temp_info_label = publish_temp_info_label + self._publish_temp_widget = publish_temp_widget self._orig_note = "" self._workfile_doc = None + publish_temp_widget.setVisible(False) + def set_published_visible(self, published_visible): - self.note_label.setVisible(not published_visible) - self.note_input.setVisible(not published_visible) - self.btn_note_save.setVisible(not published_visible) + self._artist_note_widget.setVisible(not published_visible) + self._publish_temp_widget.setVisible(published_visible) + if published_visible: + self.refresh_publish_temp_sizes() - def on_note_change(self): - text = self.note_input.toPlainText() - self.btn_note_save.setEnabled(self._orig_note != text) + def refresh_publish_temp_sizes(self): + temp_publish_files = TempPublishFiles() + text = self.published_workfile_message.format( + file_size_to_string(temp_publish_files.size) + ) + self.publish_temp_info_label.setText(text) - def on_save_click(self): - self._orig_note = self.note_input.toPlainText() - self.on_note_change() + def _on_clear_temp_click(self): + temp_publish_files = TempPublishFiles() + temp_publish_files.clear() + self.refresh_publish_temp_sizes() + + def _on_note_change(self): + text = self._note_input.toPlainText() + self._btn_note_save.setEnabled(self._orig_note != text) + + def _on_save_click(self): + self._orig_note = self._note_input.toPlainText() + self._on_note_change() self.save_clicked.emit() def set_context(self, asset_id, task_name, filepath, workfile_doc): @@ -68,17 +120,17 @@ class SidePanelWidget(QtWidgets.QWidget): # NOTE workfile document is not requirement enabled = bool(asset_id) and bool(task_name) and bool(filepath) - self.details_input.setEnabled(enabled) - self.note_input.setEnabled(enabled) - self.btn_note_save.setEnabled(enabled) + self._details_input.setEnabled(enabled) + self._note_input.setEnabled(enabled) + self._btn_note_save.setEnabled(enabled) # Make sure workfile doc is overridden self._workfile_doc = workfile_doc # Disable inputs and remove texts if any required arguments are missing if not enabled: self._orig_note = "" - self.details_input.setPlainText("") - self.note_input.setPlainText("") + self._details_input.setPlainText("") + self._note_input.setPlainText("") return orig_note = "" @@ -86,23 +138,12 @@ class SidePanelWidget(QtWidgets.QWidget): orig_note = workfile_doc["data"].get("note") or orig_note self._orig_note = orig_note - self.note_input.setPlainText(orig_note) + self._note_input.setPlainText(orig_note) # Set as empty string - self.details_input.setPlainText("") + self._details_input.setPlainText("") filestat = os.stat(filepath) - size_ending_mapping = { - "KB": 1024 ** 1, - "MB": 1024 ** 2, - "GB": 1024 ** 3 - } - size = filestat.st_size - ending = "B" - for _ending, _size in size_ending_mapping.items(): - if filestat.st_size < _size: - break - size = filestat.st_size / _size - ending = _ending + size_value = file_size_to_string(filestat.st_size) # Append html string datetime_format = "%b %d %Y %H:%M:%S" @@ -110,17 +151,17 @@ class SidePanelWidget(QtWidgets.QWidget): modification_time = datetime.datetime.fromtimestamp(filestat.st_mtime) lines = ( "Size:", - "{:.2f} {}".format(size, ending), + size_value, "Created:", creation_time.strftime(datetime_format), "Modified:", modification_time.strftime(datetime_format) ) - self.details_input.appendHtml("
".join(lines)) + self._details_input.appendHtml("
".join(lines)) def get_workfile_data(self): data = { - "note": self.note_input.toPlainText() + "note": self._note_input.toPlainText() } return self._workfile_doc, data @@ -184,6 +225,9 @@ class Window(QtWidgets.QMainWindow): files_widget.file_selected.connect(self.on_file_select) files_widget.workfile_created.connect(self.on_workfile_create) files_widget.file_opened.connect(self._on_file_opened) + files_widget.publish_file_viewed.connect( + self._on_publish_file_viewed + ) files_widget.published_visible_changed.connect( self._on_published_change ) @@ -248,6 +292,9 @@ class Window(QtWidgets.QMainWindow): def _on_file_opened(self): self.close() + def _on_publish_file_viewed(self): + self.side_panel.refresh_publish_temp_sizes() + def _on_published_change(self, visible): self.side_panel.set_published_visible(visible) From 8f28b96c7f3409dc426bca65491c34c10ad84f32 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 17:15:55 +0100 Subject: [PATCH 141/854] smaller fixes --- openpype/tools/workfiles/files_widget.py | 5 +++++ openpype/tools/workfiles/window.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 071be4ec1c..7f201d2cf3 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -188,6 +188,9 @@ class FilesWidget(QtWidgets.QWidget): workarea_files_view.selectionModel().selectionChanged.connect( self.on_file_select ) + publish_files_view.doubleClickedLeft.connect( + self._on_view_published_pressed + ) btn_open.pressed.connect(self._on_workarea_open_pressed) btn_browse.pressed.connect(self.on_browse_pressed) @@ -511,6 +514,8 @@ class FilesWidget(QtWidgets.QWidget): def _on_view_published_pressed(self): filepath = self._get_selected_filepath() + if not filepath or not os.path.exists(filepath): + return item = self._temp_publish_files.add_file(filepath) self.host.open_file(item.filepath) self.publish_file_viewed.emit() diff --git a/openpype/tools/workfiles/window.py b/openpype/tools/workfiles/window.py index 7f5bbd1ee7..c2a3f74a22 100644 --- a/openpype/tools/workfiles/window.py +++ b/openpype/tools/workfiles/window.py @@ -99,7 +99,7 @@ class SidePanelWidget(QtWidgets.QWidget): text = self.published_workfile_message.format( file_size_to_string(temp_publish_files.size) ) - self.publish_temp_info_label.setText(text) + self._publish_temp_info_label.setText(text) def _on_clear_temp_click(self): temp_publish_files = TempPublishFiles() From 2d86f0ee7c752180a0ded81f412194cb6083347b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 17:30:47 +0100 Subject: [PATCH 142/854] added check of unknown files in temp --- openpype/tools/workfiles/lib.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/tools/workfiles/lib.py b/openpype/tools/workfiles/lib.py index c181e634d6..84f2e76450 100644 --- a/openpype/tools/workfiles/lib.py +++ b/openpype/tools/workfiles/lib.py @@ -142,7 +142,9 @@ class TempPublishFiles(object): data = self._get_data() now = time.time() remove_ids = set() + all_ids = set() for item_id, item_data in data.items(): + all_ids.add(item_id) if check_expiration and now < item_data["expiration"]: continue @@ -159,6 +161,23 @@ class TempPublishFiles(object): exc_info=True ) + # Remove unknown folders/files + for filename in os.listdir(self._root_dir): + if filename in all_ids: + continue + + full_path = os.path.join(self._root_dir, filename) + if full_path in (self._metadata_path, self._lock_path): + continue + + try: + shutil.rmtree(full_path) + except Exception: + self.log.warning( + "Couldn't remove arbitrary path \"{}\"".format(full_path), + exc_info=True + ) + def clear(self): self.cleanup(False) From b75eafbeae0b1c63e4364789e9e8c45724103e5e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 17:53:51 +0100 Subject: [PATCH 143/854] uncheck published checkbox on open of published file --- openpype/tools/workfiles/files_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 7f201d2cf3..d2b8a76952 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -519,6 +519,8 @@ class FilesWidget(QtWidgets.QWidget): item = self._temp_publish_files.add_file(filepath) self.host.open_file(item.filepath) self.publish_file_viewed.emit() + # Change state back to workarea + self._published_checkbox.setChecked(False) def on_file_select(self): self.file_selected.emit(self._get_selected_filepath()) From 528b27b7ab9cbeb29aa298fe79ff29aa935a06ae Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 17:54:39 +0100 Subject: [PATCH 144/854] show all workfile representations if task is not selected in UI --- openpype/tools/workfiles/model.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index fa0dddc2bc..563a2fc558 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -286,7 +286,12 @@ class PublishFilesModel(QtGui.QStandardItemModel): "context.ext": {"$in": extensions} } ) + filtered_repre_docs = [] for repre_doc in repre_docs: + if self._task_name is None: + filtered_repre_docs.append(repre_doc) + continue + task_info = repre_doc["context"].get("task") if not task_info: print("Not task info") @@ -298,15 +303,18 @@ class PublishFilesModel(QtGui.QStandardItemModel): task_name = task_info if task_name == self._task_name: - path = get_representation_path( - repre_doc, root=self._anatomy.roots - ) - output.append((path, repre_doc["_id"])) + filtered_repre_docs.append(repre_doc) + + for repre_doc in filtered_repre_docs: + path = get_representation_path( + repre_doc, root=self._anatomy.roots + ) + output.append((path, repre_doc["_id"])) return output def refresh(self): root_item = self.invisibleRootItem() - if not self._asset_id or not self._task_name: + if not self._asset_id: self._clear() # Add Work Area does not exist placeholder item = self._get_invalid_context_item() From 8b572a0d3ca27b8fb52856b5c5a00f71e499115f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 21 Mar 2022 18:59:34 +0100 Subject: [PATCH 145/854] just handle error when is caused by OSError --- openpype/lib/log.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/lib/log.py b/openpype/lib/log.py index 98a3bae8e6..f33385e0ba 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -98,6 +98,10 @@ class PypeStreamHandler(logging.StreamHandler): self.flush() except (KeyboardInterrupt, SystemExit): raise + + except OSError: + self.handleError(record) + except Exception: print(repr(record)) self.handleError(record) From ae9e34bc62d5d9918842df27492d4a418a83cc82 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 11:21:26 +0900 Subject: [PATCH 146/854] use collected animation data at multiverse use creator --- .../maya/plugins/create/create_multiverse_usd.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py index 6851e0f6bc..c06c764f95 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -44,15 +44,9 @@ class CreateMultiverseUsd(plugin.Creator): self.data["writeUsdAttributes"] = False self.data["timeVaryingTopology"] = False self.data["customMaterialNamespace"] = '' + self.data["numTimeSamples"] = 1 + self.data["timeSamplesSpan"] = 0.0 - # The attributes below are about animated cache. - self.data["writeTimeRange"] = True - self.data["timeRangeNumTimeSamples"] = 0 - self.data["timeRangeSamplesSpan"] = 0.0 - + # Add animation data animation_data = lib.collect_animation_data(True) - - self.data["timeRangeStart"] = animation_data["frameStart"] - self.data["timeRangeEnd"] = animation_data["frameEnd"] - self.data["timeRangeIncrement"] = animation_data["step"] - self.data["timeRangeFramesPerSecond"] = animation_data["fps"] + self.data.update(animation_data) From 370416a949280a4223a07f0e4a70060b43433fa4 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 11:41:13 +0900 Subject: [PATCH 147/854] use openpye built-in variables for multiverse usd extractor --- .../plugins/publish/extract_multiverse_usd.py | 60 +++++++++---------- 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 7c13252957..db0f57768b 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -58,22 +58,13 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "writeUsdAttributes": bool, "timeVaryingTopology": bool, "customMaterialNamespace": str, - "writeTimeRange": bool, - "timeRangeStart": int, - "timeRangeEnd": int, - "timeRangeIncrement": int, - "timeRangeNumTimeSamples": int, - "timeRangeSamplesSpan": float, - "timeRangeFramesPerSecond": float + "numTimeSamples": int, + "timeSamplesSpan": float } @property def default_options(self): """The default options for Multiverse USD extraction.""" - start_frame = int(cmds.playbackOptions(query=True, - animationStartTime=True)) - end_frame = int(cmds.playbackOptions(query=True, - animationEndTime=True)) return { "stripNamespaces": False, @@ -108,13 +99,8 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "writeUsdAttributes": False, "timeVaryingTopology": False, "customMaterialNamespace": '', - "writeTimeRange": True, - "timeRangeStart": start_frame, - "timeRangeEnd": end_frame, - "timeRangeIncrement": 1, - "timeRangeNumTimeSamples": 0, - "timeRangeSamplesSpan": 0.0, - "timeRangeFramesPerSecond": 24.0 + "numTimeSamples": 1, + "timeSamplesSpan": 0.0 } def process(self, instance): @@ -130,6 +116,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): # Parse export options options = self.default_options self.log.info("Export options: {0}".format(options)) + self.log.info("Export instance data: {0}".format(instance.data)) # Perform extraction self.log.info("Performing extraction ...") @@ -144,30 +131,39 @@ class ExtractMultiverseUsd(openpype.api.Extractor): long=True) self.log.info('Collected object {}'.format(members)) - # TODO: Deal with asset, composition, overide with options. import multiverse time_opts = None - if options["writeTimeRange"]: + frame_start = instance.data['frameStart'] + frame_end = instance.data['frameEnd'] + step = instance.data['step'] + fps = instance.data['fps'] + if frame_end != frame_start: time_opts = multiverse.TimeOptions() time_opts.writeTimeRange = True - - time_range_start = options["timeRangeStart"] - time_range_end = options["timeRangeEnd"] - time_opts.frameRange = (time_range_start, time_range_end) - - time_opts.frameIncrement = options["timeRangeIncrement"] - time_opts.numTimeSamples = options["timeRangeNumTimeSamples"] - time_opts.timeSamplesSpan = options["timeRangeSamplesSpan"] - time_opts.framePerSecond = options["timeRangeFramesPerSecond"] + time_opts.frameRange = (frame_start, frame_end) + time_opts.frameIncrement = step + time_opts.numTimeSamples = instance.data["numTimeSamples"] + time_opts.timeSamplesSpan = instance.data["timeSamplesSpan"] + time_opts.framePerSecond = fps asset_write_opts = multiverse.AssetWriteOptions(time_opts) options_items = getattr(options, "iteritems", options.items) - for (k, v) in options_items(): - if k == "writeTimeRange" or k.startswith("timeRange"): + options_discard_keys = [ + 'numTimeSamples', + 'timeSamplesSpan', + 'frameStart', + 'frameEnd', + 'handleStart', + 'handleEnd', + 'step', + 'fps' + ] + for key, value in options_items(): + if key in options_discard_keys: continue - setattr(asset_write_opts, k, v) + setattr(asset_write_opts, key, instance.data[key]) multiverse.WriteAsset(file_path, members, asset_write_opts) From 889851a2d820cc43c23819b2afca4781c3c471c5 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 11:43:58 +0900 Subject: [PATCH 148/854] tidy variable of unused variable at multiverse usd creator --- openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index db0f57768b..d084ac844e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -160,7 +160,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): 'step', 'fps' ] - for key, value in options_items(): + for key, _value in options_items(): if key in options_discard_keys: continue setattr(asset_write_opts, key, instance.data[key]) From b553fe66a044a4b3f2570503375b1a8a0f13a2c8 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 12:10:35 +0900 Subject: [PATCH 149/854] add method parse_overrides to multiverse usd extractor to update option values --- .../plugins/publish/extract_multiverse_usd.py | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index d084ac844e..9e6d46af7e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -32,9 +32,9 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "flattenParentXforms": bool, "writeSparseOverrides": bool, "useMetaPrimPath": bool, - "customRootPath": str, - "customAttributes": str, - "nodeTypesToIgnore": str, + "customRootPath": unicode, + "customAttributes": unicode, + "nodeTypesToIgnore": unicode, "writeMeshes": bool, "writeCurves": bool, "writeParticles": bool, @@ -57,7 +57,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "writeTransformMatrix": bool, "writeUsdAttributes": bool, "timeVaryingTopology": bool, - "customMaterialNamespace": str, + "customMaterialNamespace": unicode, "numTimeSamples": int, "timeSamplesSpan": float } @@ -103,6 +103,29 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "timeSamplesSpan": 0.0 } + def parse_overrides(self, instance, options): + """Inspect data of instance to determine overridden options""" + + for key in instance.data: + if key not in self.options: + continue + + # Ensure the data is of correct type + value = instance.data[key] + if not isinstance(value, self.options[key]): + self.log.warning( + "Overridden attribute {key} was of " + "the wrong type: {invalid_type} " + "- should have been {valid_type}".format( + key=key, + invalid_type=type(value).__name__, + valid_type=self.options[key].__name__)) + continue + + options[key] = value + + return options + def process(self, instance): # Load plugin firstly cmds.loadPlugin("MultiverseForMaya", quiet=True) @@ -115,8 +138,8 @@ class ExtractMultiverseUsd(openpype.api.Extractor): # Parse export options options = self.default_options + options = self.parse_overrides(instance, options) self.log.info("Export options: {0}".format(options)) - self.log.info("Export instance data: {0}".format(instance.data)) # Perform extraction self.log.info("Performing extraction ...") From cc1d6313d534d1a6dc76cc4101fd8021806bf44d Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 12:24:09 +0900 Subject: [PATCH 150/854] convert string to unicode at multiverse usd extractor --- openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 9e6d46af7e..5fece9cfd3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -112,6 +112,8 @@ class ExtractMultiverseUsd(openpype.api.Extractor): # Ensure the data is of correct type value = instance.data[key] + if isinstance(value, str): + value = unicode(value, "utf-8") if not isinstance(value, self.options[key]): self.log.warning( "Overridden attribute {key} was of " From b5d775700d8cfe20ea8d3db0db2354eaa815380c Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 12:28:51 +0900 Subject: [PATCH 151/854] declare default string option as unicode --- .../maya/plugins/publish/extract_multiverse_usd.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 5fece9cfd3..7b01d3066d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -73,9 +73,9 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "flattenParentXforms": False, "writeSparseOverrides": False, "useMetaPrimPath": False, - "customRootPath": '', - "customAttributes": '', - "nodeTypesToIgnore": '', + "customRootPath": u'', + "customAttributes": u'', + "nodeTypesToIgnore": u'', "writeMeshes": True, "writeCurves": True, "writeParticles": True, @@ -98,7 +98,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "writeTransformMatrix": True, "writeUsdAttributes": False, "timeVaryingTopology": False, - "customMaterialNamespace": '', + "customMaterialNamespace": u'', "numTimeSamples": 1, "timeSamplesSpan": 0.0 } @@ -112,8 +112,6 @@ class ExtractMultiverseUsd(openpype.api.Extractor): # Ensure the data is of correct type value = instance.data[key] - if isinstance(value, str): - value = unicode(value, "utf-8") if not isinstance(value, self.options[key]): self.log.warning( "Overridden attribute {key} was of " From fba9399bd3cef9ea5043f312903684bc88b0cab1 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 12:31:09 +0900 Subject: [PATCH 152/854] use values from processed options from multiverse usd extractor --- openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 7b01d3066d..fd46f87684 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -183,10 +183,10 @@ class ExtractMultiverseUsd(openpype.api.Extractor): 'step', 'fps' ] - for key, _value in options_items(): + for key, value in options_items(): if key in options_discard_keys: continue - setattr(asset_write_opts, key, instance.data[key]) + setattr(asset_write_opts, key, value) multiverse.WriteAsset(file_path, members, asset_write_opts) From adde37f982b001bd1aae6722c4e2d4ff3b94d466 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 13:48:47 +0900 Subject: [PATCH 153/854] removed root transform when load the usd file --- .../maya/plugins/load/load_multiverse_usd.py | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index 3370033141..8a618d5b01 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -1,7 +1,15 @@ # -*- coding: utf-8 -*- +import maya.cmds as cmds +import maya.mel as mel + from avalon import api -import maya.cmds as cmds +from openpype.hosts.maya.api.lib import ( + maintained_selection, + namespaced, + unique_namespace +) +from openpype.hosts.maya.api.pipeline import containerise class MultiverseUsdLoader(api.Loader): @@ -17,9 +25,6 @@ class MultiverseUsdLoader(api.Loader): def load(self, context, name=None, namespace=None, options=None): - from openpype.hosts.maya.api.pipeline import containerise - from openpype.hosts.maya.api.lib import unique_namespace - asset = context['asset']['name'] namespace = namespace or unique_namespace( asset + "_", @@ -27,19 +32,19 @@ class MultiverseUsdLoader(api.Loader): suffix="_", ) + # Create the shape cmds.loadPlugin("MultiverseForMaya", quiet=True) - # Root group - rootName = "{}:{}".format(namespace, name) - root = cmds.group(name=rootName, empty=True) + shape = None + transform = None + with maintained_selection(): + cmds.namespace(addNamespace=namespace) + with namespaced(namespace, new=False): + import multiverse + shape = multiverse.CreateUsdCompound(self.fname) + transform = mel.eval('firstParentOf "{}"'.format(shape)) - # Create shape with transform and move it under root - import multiverse - transform = multiverse.CreateUsdCompound(self.fname) - cmds.parent(transform, root) - - # Rename transform - nodes = [root, transform] + nodes = [transform, shape] self[:] = nodes return containerise( From e1bdbf3cdc61e2dbf3b211cc80c725af6c1c2e06 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Tue, 22 Mar 2022 14:18:17 +0900 Subject: [PATCH 154/854] updated update and remove method in usd loader --- .../maya/plugins/load/load_multiverse_usd.py | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index 8a618d5b01..dac2244b5f 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -55,20 +55,22 @@ class MultiverseUsdLoader(api.Loader): loader=self.__class__.__name__) def update(self, container, representation): + # type: (dict, dict) -> None + """Update container with specified representation.""" + node = container['objectName'] + assert cmds.objExists(node), "Missing container" + + members = cmds.sets(node, query=True) or [] + shapes = cmds.ls(members, type="mvUsdCompoundShape") + assert shapes, "Cannot find mvUsdCompoundShape in container" path = api.get_representation_path(representation) - # Update the shape - members = cmds.sets(container['objectName'], query=True) - shapes = cmds.ls(members, type="mvUsdCompoundShape", long=True) - - assert len(shapes) == 1, "This is a bug" - import multiverse for shape in shapes: multiverse.SetUsdCompoundAssetPaths(shape, [path]) - cmds.setAttr(container["objectName"] + ".representation", + cmds.setAttr("{}.representation".format(node), str(representation["_id"]), type="string") @@ -76,4 +78,19 @@ class MultiverseUsdLoader(api.Loader): self.update(container, representation) def remove(self, container): - pass + # type: (dict) -> None + """Remove loaded container.""" + # Delete container and its contents + if cmds.objExists(container['objectName']): + members = cmds.sets(container['objectName'], query=True) or [] + cmds.delete([container['objectName']] + members) + + # Remove the namespace, if empty + namespace = container['namespace'] + if cmds.namespace(exists=namespace): + members = cmds.namespaceInfo(namespace, listNamespace=True) + if not members: + cmds.namespace(removeNamespace=namespace) + else: + self.log.warning("Namespace not deleted because it " + "still has members: %s", namespace) From 5e5fc4ec55e9bcd9e87573dead58abc92d942c59 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 12:31:14 +0100 Subject: [PATCH 155/854] removed silo references --- openpype/hosts/blender/api/ops.py | 1 - .../avalon_uri_processor.py | 2 - openpype/lib/usdlib.py | 3 +- openpype/pipeline/load/utils.py | 1 - .../publish/extract_hierarchy_avalon.py | 2 +- openpype/tools/context_dialog/window.py | 1 - openpype/tools/launcher/lib.py | 16 ------- openpype/tools/loader/app.py | 17 +------- .../standalonepublish/widgets/model_asset.py | 43 +++---------------- .../standalonepublish/widgets/widget_asset.py | 1 - openpype/tools/texture_copy/app.py | 2 - openpype/tools/workfiles/app.py | 1 - 12 files changed, 10 insertions(+), 80 deletions(-) diff --git a/openpype/hosts/blender/api/ops.py b/openpype/hosts/blender/api/ops.py index 3069c3e1c9..29d6d356c8 100644 --- a/openpype/hosts/blender/api/ops.py +++ b/openpype/hosts/blender/api/ops.py @@ -328,7 +328,6 @@ class LaunchWorkFiles(LaunchQtApp): result = super().execute(context) self._window.set_context({ "asset": avalon.api.Session["AVALON_ASSET"], - "silo": avalon.api.Session["AVALON_SILO"], "task": avalon.api.Session["AVALON_TASK"] }) return result diff --git a/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py b/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py index 4071eb3e0c..499b733570 100644 --- a/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py +++ b/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py @@ -145,7 +145,6 @@ class AvalonURIOutputProcessor(base.OutputProcessorBase): path = self._template.format(**{ "root": root, "project": PROJECT, - "silo": asset_doc["silo"], "asset": asset_doc["name"], "subset": subset, "representation": ext, @@ -165,4 +164,3 @@ output_processor = AvalonURIOutputProcessor() def usdOutputProcessor(): return output_processor - diff --git a/openpype/lib/usdlib.py b/openpype/lib/usdlib.py index 3ae7430c7b..89021156b4 100644 --- a/openpype/lib/usdlib.py +++ b/openpype/lib/usdlib.py @@ -315,7 +315,7 @@ def get_usd_master_path(asset, subset, representation): ) template = project["config"]["template"]["publish"] - if isinstance(asset, dict) and "silo" in asset and "name" in asset: + if isinstance(asset, dict) and "name" in asset: # Allow explicitly passing asset document asset_doc = asset else: @@ -325,7 +325,6 @@ def get_usd_master_path(asset, subset, representation): **{ "root": api.registered_root(), "project": api.Session["AVALON_PROJECT"], - "silo": asset_doc["silo"], "asset": asset_doc["name"], "subset": subset, "representation": representation, diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 6d32c11cd7..699e82ebd2 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -593,7 +593,6 @@ def get_representation_path(representation, root=None, dbcon=None): "code": project.get("data", {}).get("code") }, "asset": asset["name"], - "silo": asset.get("silo"), "hierarchy": hierarchy, "subset": subset["name"], "version": version_["name"], diff --git a/openpype/plugins/publish/extract_hierarchy_avalon.py b/openpype/plugins/publish/extract_hierarchy_avalon.py index e263edd931..b062a9c4b5 100644 --- a/openpype/plugins/publish/extract_hierarchy_avalon.py +++ b/openpype/plugins/publish/extract_hierarchy_avalon.py @@ -64,7 +64,7 @@ class ExtractHierarchyToAvalon(pyblish.api.ContextPlugin): data["tasks"] = tasks parents = [] visualParent = None - # do not store project"s id as visualParent (silo asset) + # do not store project"s id as visualParent if self.project is not None: if self.project["_id"] != parent["_id"]: visualParent = parent["_id"] diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py index c8464faa3e..9e030853bf 100644 --- a/openpype/tools/context_dialog/window.py +++ b/openpype/tools/context_dialog/window.py @@ -308,7 +308,6 @@ class ContextDialog(QtWidgets.QDialog): self._validate_strict() def _set_asset_to_tasks_widget(self): - # filter None docs they are silo asset_id = self._assets_widget.get_selected_asset_id() self._tasks_widget.set_asset_id(asset_id) diff --git a/openpype/tools/launcher/lib.py b/openpype/tools/launcher/lib.py index 68c759f295..c1392b7b8f 100644 --- a/openpype/tools/launcher/lib.py +++ b/openpype/tools/launcher/lib.py @@ -1,19 +1,3 @@ -"""Utility script for updating database with configuration files - -Until assets are created entirely in the database, this script -provides a bridge between the file-based project inventory and configuration. - -- Migrating an old project: - $ python -m avalon.inventory --extract --silo-parent=f02_prod - $ python -m avalon.inventory --upload - -- Managing an existing project: - 1. Run `python -m avalon.inventory --load` - 2. Update the .inventory.toml or .config.toml - 3. Run `python -m avalon.inventory --save` - -""" - import os from Qt import QtGui import qtawesome diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index d73a977ac6..923a1fabdb 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -290,7 +290,6 @@ class LoaderWindow(QtWidgets.QDialog): subsets_model.clear() self.clear_assets_underlines() - # filter None docs they are silo asset_ids = self._assets_widget.get_selected_asset_ids() # Start loading subsets_widget.set_loading_state( @@ -381,17 +380,9 @@ class LoaderWindow(QtWidgets.QDialog): The context must contain `asset` data by name. - Note: Prior to setting context ensure `refresh` is triggered so that - the "silos" are listed correctly, aside from that setting the - context will force a refresh further down because it changes - the active silo and asset. - Args: context (dict): The context to apply. - - Returns: - None - + refrest (bool): Trigger refresh on context set. """ asset = context.get("asset", None) @@ -399,12 +390,6 @@ class LoaderWindow(QtWidgets.QDialog): return if refresh: - # Workaround: - # Force a direct (non-scheduled) refresh prior to setting the - # asset widget's silo and asset selection to ensure it's correctly - # displaying the silo tabs. Calling `window.refresh()` and directly - # `window.set_context()` the `set_context()` seems to override the - # scheduled refresh and the silo tabs are not shown. self._refresh() self._assets_widget.select_asset_by_name(asset) diff --git a/openpype/tools/standalonepublish/widgets/model_asset.py b/openpype/tools/standalonepublish/widgets/model_asset.py index a7316a2aa7..e9d1517497 100644 --- a/openpype/tools/standalonepublish/widgets/model_asset.py +++ b/openpype/tools/standalonepublish/widgets/model_asset.py @@ -35,7 +35,7 @@ def _iter_model_rows(model, class AssetModel(TreeModel): - """A model listing assets in the silo in the active project. + """A model listing assets in the active project. The assets are displayed in a treeview, they are visually parented by a `visualParent` field in the database containing an `_id` to a parent @@ -64,7 +64,7 @@ class AssetModel(TreeModel): self.refresh() - def _add_hierarchy(self, assets, parent=None, silos=None): + def _add_hierarchy(self, assets, parent=None): """Add the assets that are related to the parent as children items. This method does *not* query the database. These instead are queried @@ -72,27 +72,8 @@ class AssetModel(TreeModel): queries. Resulting in up to 10x speed increase. Args: - assets (dict): All assets in the currently active silo stored - by key/value - - Returns: - None - + assets (dict): All assets from current project. """ - if silos: - # WARNING: Silo item "_id" is set to silo value - # mainly because GUI issue with preserve selection and expanded row - # and because of easier hierarchy parenting (in "assets") - for silo in silos: - node = Node({ - "_id": silo, - "name": silo, - "label": silo, - "type": "silo" - }) - self.add_child(node, parent=parent) - self._add_hierarchy(assets, parent=node) - parent_id = parent["_id"] if parent else None current_assets = assets.get(parent_id, list()) @@ -132,27 +113,19 @@ class AssetModel(TreeModel): self.beginResetModel() - # Get all assets in current silo sorted by name + # Get all assets in current project sorted by name db_assets = self.dbcon.find({"type": "asset"}).sort("name", 1) - silos = db_assets.distinct("silo") or None - # if any silo is set to None then it's expected it should not be used - if silos and None in silos: - silos = None # Group the assets by their visual parent's id assets_by_parent = collections.defaultdict(list) for asset in db_assets: - parent_id = ( - asset.get("data", {}).get("visualParent") or - asset.get("silo") - ) + parent_id = asset.get("data", {}).get("visualParent") assets_by_parent[parent_id].append(asset) # Build the hierarchical tree items recursively self._add_hierarchy( assets_by_parent, - parent=None, - silos=silos + parent=None ) self.endResetModel() @@ -173,9 +146,7 @@ class AssetModel(TreeModel): # Allow a custom icon and custom icon color to be defined data = node.get("_document", {}).get("data", {}) - icon = data.get("icon", None) - if icon is None and node.get("type") == "silo": - icon = "database" + icon = data.get("icon", None) color = data.get("color", self._default_asset_icon_color) if icon is None: diff --git a/openpype/tools/standalonepublish/widgets/widget_asset.py b/openpype/tools/standalonepublish/widgets/widget_asset.py index e6b74f8f82..8b43cd7cf8 100644 --- a/openpype/tools/standalonepublish/widgets/widget_asset.py +++ b/openpype/tools/standalonepublish/widgets/widget_asset.py @@ -229,7 +229,6 @@ class AssetWidget(QtWidgets.QWidget): data = { 'project': project['name'], 'asset': asset['name'], - 'silo': asset.get("silo"), 'parents': self.get_parents(asset), 'task': task } diff --git a/openpype/tools/texture_copy/app.py b/openpype/tools/texture_copy/app.py index ceca98a082..0c3c260e51 100644 --- a/openpype/tools/texture_copy/app.py +++ b/openpype/tools/texture_copy/app.py @@ -57,7 +57,6 @@ class TextureCopy: "name": project_name, "code": project['data']['code'] }, - "silo": asset.get('silo'), "asset": asset['name'], "family": 'texture', "subset": 'Main', @@ -155,7 +154,6 @@ def texture_copy(asset, project, path): t.echo(">>> Initializing avalon session ...") os.environ["AVALON_PROJECT"] = project os.environ["AVALON_ASSET"] = asset - os.environ["AVALON_SILO"] = "" TextureCopy().process(asset, project, path) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 713992bc4b..b3d6003b28 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -1266,7 +1266,6 @@ def show(root=None, debug=False, parent=None, use_context=True, save=True): if use_context: context = { "asset": api.Session["AVALON_ASSET"], - "silo": api.Session["AVALON_SILO"], "task": api.Session["AVALON_TASK"] } window.set_context(context) From d721ed94e4e1903a856774a65479894d2961c603 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 12:31:50 +0100 Subject: [PATCH 156/854] simplified loader to not use registered root --- openpype/tools/libraryloader/app.py | 17 ++++------------- openpype/tools/libraryloader/lib.py | 12 ------------ openpype/tools/libraryloader/widgets.py | 18 ------------------ openpype/tools/loader/widgets.py | 23 ++++++++++++++--------- 4 files changed, 18 insertions(+), 52 deletions(-) delete mode 100644 openpype/tools/libraryloader/widgets.py diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py index 9f8845f30f..b73b415128 100644 --- a/openpype/tools/libraryloader/app.py +++ b/openpype/tools/libraryloader/app.py @@ -9,14 +9,14 @@ from openpype.tools.loader.widgets import ( ThumbnailWidget, VersionWidget, FamilyListView, - RepresentationWidget + RepresentationWidget, + SubsetWidget ) from openpype.tools.utils.assets_widget import MultiSelectAssetsWidget from openpype.modules import ModulesManager from . import lib -from .widgets import LibrarySubsetWidget module = sys.modules[__name__] module.window = None @@ -92,7 +92,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): # --- Middle part --- # Subsets widget - subsets_widget = LibrarySubsetWidget( + subsets_widget = SubsetWidget( dbcon, self.groups_config, self.family_config_cache, @@ -448,10 +448,7 @@ class LibraryLoaderWindow(QtWidgets.QDialog): def _set_context(self, context, refresh=True): """Set the selection in the interface using a context. The context must contain `asset` data by name. - Note: Prior to setting context ensure `refresh` is triggered so that - the "silos" are listed correctly, aside from that setting the - context will force a refresh further down because it changes - the active silo and asset. + Args: context (dict): The context to apply. Returns: @@ -463,12 +460,6 @@ class LibraryLoaderWindow(QtWidgets.QDialog): return if refresh: - # Workaround: - # Force a direct (non-scheduled) refresh prior to setting the - # asset widget's silo and asset selection to ensure it's correctly - # displaying the silo tabs. Calling `window.refresh()` and directly - # `window.set_context()` the `set_context()` seems to override the - # scheduled refresh and the silo tabs are not shown. self._refresh_assets() self._assets_widget.select_asset_by_name(asset_name) diff --git a/openpype/tools/libraryloader/lib.py b/openpype/tools/libraryloader/lib.py index 6a497a6a16..182b48893a 100644 --- a/openpype/tools/libraryloader/lib.py +++ b/openpype/tools/libraryloader/lib.py @@ -1,7 +1,6 @@ import os import importlib import logging -from openpype.api import Anatomy log = logging.getLogger(__name__) @@ -20,14 +19,3 @@ def find_config(): log.info("Found %s, loading.." % config) return importlib.import_module(config) - - -class RegisteredRoots: - roots_per_project = {} - - @classmethod - def registered_root(cls, project_name): - if project_name not in cls.roots_per_project: - cls.roots_per_project[project_name] = Anatomy(project_name).roots - - return cls.roots_per_project[project_name] diff --git a/openpype/tools/libraryloader/widgets.py b/openpype/tools/libraryloader/widgets.py deleted file mode 100644 index 45f9ea2048..0000000000 --- a/openpype/tools/libraryloader/widgets.py +++ /dev/null @@ -1,18 +0,0 @@ -from Qt import QtWidgets - -from .lib import RegisteredRoots -from openpype.tools.loader.widgets import SubsetWidget - - -class LibrarySubsetWidget(SubsetWidget): - def on_copy_source(self): - """Copy formatted source path to clipboard""" - source = self.data.get("source", None) - if not source: - return - - project_name = self.dbcon.Session["AVALON_PROJECT"] - root = RegisteredRoots.registered_root(project_name) - path = source.format(root=root) - clipboard = QtWidgets.QApplication.clipboard() - clipboard.setText(path) diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index b14bdd0e93..0934642937 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -7,8 +7,9 @@ import collections from Qt import QtWidgets, QtCore, QtGui -from avalon import api, pipeline +from avalon import pipeline +from openpype.api import Anatomy from openpype.pipeline import HeroVersionType from openpype.pipeline.load import ( discover_loader_plugins, @@ -640,6 +641,7 @@ class VersionTextEdit(QtWidgets.QTextEdit): "source": None, "raw": None } + self._anatomy = None # Reset self.set_version(None) @@ -730,20 +732,20 @@ class VersionTextEdit(QtWidgets.QTextEdit): # Add additional actions when any text so we can assume # the version is set. if self.toPlainText().strip(): - menu.addSeparator() - action = QtWidgets.QAction("Copy source path to clipboard", - menu) + action = QtWidgets.QAction( + "Copy source path to clipboard", menu + ) action.triggered.connect(self.on_copy_source) menu.addAction(action) - action = QtWidgets.QAction("Copy raw data to clipboard", - menu) + action = QtWidgets.QAction( + "Copy raw data to clipboard", menu + ) action.triggered.connect(self.on_copy_raw) menu.addAction(action) menu.exec_(event.globalPos()) - del menu def on_copy_source(self): """Copy formatted source path to clipboard""" @@ -751,7 +753,11 @@ class VersionTextEdit(QtWidgets.QTextEdit): if not source: return - path = source.format(root=api.registered_root()) + project_name = self.dbcon.Session["AVALON_PROJECT"] + if self._anatomy is None or self._anatomy.project_name != project_name: + self._anatomy = Anatomy(project_name) + + path = source.format(root=self._anatomy.roots) clipboard = QtWidgets.QApplication.clipboard() clipboard.setText(path) @@ -771,7 +777,6 @@ class VersionTextEdit(QtWidgets.QTextEdit): class ThumbnailWidget(QtWidgets.QLabel): - aspect_ratio = (16, 9) max_width = 300 From 6a6cbe6b99a275f3034c782d2abc725be864e9de Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 12:32:13 +0100 Subject: [PATCH 157/854] removed terminal splash --- openpype/lib/splash.txt | 413 -------------------------------- openpype/lib/terminal_splash.py | 43 ---- 2 files changed, 456 deletions(-) delete mode 100644 openpype/lib/splash.txt delete mode 100644 openpype/lib/terminal_splash.py diff --git a/openpype/lib/splash.txt b/openpype/lib/splash.txt deleted file mode 100644 index 833bcd4b9c..0000000000 --- a/openpype/lib/splash.txt +++ /dev/null @@ -1,413 +0,0 @@ - - - - * - - - - - - - .* - - - - - - * - .* - * - - - - . - * - .* - * - . - - . - * - .* - .* - .* - * - . - . - * - .* - .* - .* - * - . - _. - /** - \ * - \* - * - * - . - __. - ---* - \ \* - \ * - \* - * - . - \___. - /* * - \ \ * - \ \* - \ * - \* - . - |____. - /* * - \|\ * - \ \ * - \ \ * - \ \* - \/. - _/_____. - /* * - / \ * - \ \ * - \ \ * - \ \__* - \/__. - __________. - --*-- ___* - \ \ \/_* - \ \ __* - \ \ \_* - \ \____\* - \/____/. - \____________ . - /* ___ \* - \ \ \/_\ * - \ \ _____* - \ \ \___/* - \ \____\ * - \/____/ . - |___________ . - /* ___ \ * - \|\ \/_\ \ * - \ \ _____/ * - \ \ \___/ * - \ \____\ / * - \/____/ \. - _/__________ . - /* ___ \ * - / \ \/_\ \ * - \ \ _____/ * - \ \ \___/ ---* - \ \____\ / \__* - \/____/ \/__. - ____________ . - --*-- ___ \ * - \ \ \/_\ \ * - \ \ _____/ * - \ \ \___/ ---- * - \ \____\ / \____\* - \/____/ \/____/. - ____________ - /\ ___ \ . - \ \ \/_\ \ * - \ \ _____/ * - \ \ \___/ ---- * - \ \____\ / \____\ . - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ . - \ \ _____/ * - \ \ \___/ ---- * - \ \____\ / \____\ . - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ . - \ \ \___/ ---- * - \ \____\ / \____\ . - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ - \ \ \___/ ---- * - \ \____\ / \____\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ - \ \ \___/ ---- . - \ \____\ / \____\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ _ - \ \ \___/ ---- - \ \____\ / \____\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ - \ \ \___/ ---- - \ \____\ / \____\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ - \ \ \___/ ---- \ - \ \____\ / \____\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ - \ \ \___/ ---- \ - \ \____\ / \____\ \ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ - \ \ \___/ ---- \ - \ \____\ / \____\ __\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ - \ \ \___/ ---- \ - \ \____\ / \____\ \__\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ - \ \ \___/ ---- \ \ - \ \____\ / \____\ \__\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ - \ \ \___/ ---- \ \ - \ \____\ / \____\ \__\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___. - \ \ \___/ ---- \ \\ - \ \____\ / \____\ \__\, - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ . - \ \ \___/ ---- \ \\ - \ \____\ / \____\ \__\\, - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ _. - \ \ \___/ ---- \ \\\ - \ \____\ / \____\ \__\\\ - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ __. - \ \ \___/ ---- \ \\ \ - \ \____\ / \____\ \__\\_/. - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___. - \ \ \___/ ---- \ \\ \\ - \ \____\ / \____\ \__\\__\. - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ . - \ \ \___/ ---- \ \\ \\ - \ \____\ / \____\ \__\\__\\. - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ _. - \ \ \___/ ---- \ \\ \\\ - \ \____\ / \____\ \__\\__\\. - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ __. - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\_. - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ __. - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__. - \/____/ \/____/ - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ * - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ O* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ .oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ ..oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . .oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . p.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . Py.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYp.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPe.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE .oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE c.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE C1.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE ClU.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE CluB.oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE Club .oO* - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE Club . .. - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE Club . .. - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE Club . . - ____________ - /\ ___ \ - \ \ \/_\ \ - \ \ _____/ ___ ___ ___ - \ \ \___/ ---- \ \\ \\ \ - \ \____\ / \____\ \__\\__\\__\ - \/____/ \/____/ . PYPE Club . diff --git a/openpype/lib/terminal_splash.py b/openpype/lib/terminal_splash.py deleted file mode 100644 index 0ba2706a27..0000000000 --- a/openpype/lib/terminal_splash.py +++ /dev/null @@ -1,43 +0,0 @@ -# -*- coding: utf-8 -*- -"""Pype terminal animation.""" -import blessed -from pathlib import Path -from time import sleep - -NO_TERMINAL = False - -try: - term = blessed.Terminal() -except AttributeError: - # this happens when blessed cannot find proper terminal. - # If so, skip printing ascii art animation. - NO_TERMINAL = True - - -def play_animation(): - """Play ASCII art Pype animation.""" - if NO_TERMINAL: - return - print(term.home + term.clear) - frame_size = 7 - splash_file = Path(__file__).parent / "splash.txt" - with splash_file.open("r") as sf: - animation = sf.readlines() - - animation_length = int(len(animation) / frame_size) - current_frame = 0 - for _ in range(animation_length): - frame = "".join( - scanline - for y, scanline in enumerate( - animation[current_frame: current_frame + frame_size] - ) - ) - - with term.location(0, 0): - # term.aquamarine3_bold(frame) - print(f"{term.bold}{term.aquamarine3}{frame}{term.normal}") - - sleep(0.02) - current_frame += frame_size - print(term.move_y(7)) From eba76ad9c0b4931059b8409cda8cfa656698a359 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Mar 2022 12:52:39 +0100 Subject: [PATCH 158/854] Change note Co-authored-by: Roy Nieterau --- openpype/tools/workfiles/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/window.py b/openpype/tools/workfiles/window.py index c2a3f74a22..8654a18036 100644 --- a/openpype/tools/workfiles/window.py +++ b/openpype/tools/workfiles/window.py @@ -20,7 +20,7 @@ from .lib import TempPublishFiles, file_size_to_string class SidePanelWidget(QtWidgets.QWidget): save_clicked = QtCore.Signal() published_workfile_message = ( - "INFO: Published workfiles you'll opened will be stored in" + "INFO: Opened published workfiles will be stored in" " temp directory on your machine. Current temp size: {}." ) From 42a79966a3a771be3ec39a53a862850212567739 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 13:38:38 +0100 Subject: [PATCH 159/854] fix trailing spaces --- openpype/tools/standalonepublish/widgets/model_asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/standalonepublish/widgets/model_asset.py b/openpype/tools/standalonepublish/widgets/model_asset.py index e9d1517497..02e9073555 100644 --- a/openpype/tools/standalonepublish/widgets/model_asset.py +++ b/openpype/tools/standalonepublish/widgets/model_asset.py @@ -146,7 +146,7 @@ class AssetModel(TreeModel): # Allow a custom icon and custom icon color to be defined data = node.get("_document", {}).get("data", {}) - icon = data.get("icon", None) + icon = data.get("icon", None) color = data.get("color", self._default_asset_icon_color) if icon is None: From bfbf7c8d54168015ba106c8496b265f122bdc4e7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 14:48:16 +0100 Subject: [PATCH 160/854] removed redundant line --- openpype/tools/utils/delegates.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index 41de7cce60..71f817a1d7 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -289,4 +289,3 @@ class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate): def displayText(self, value, locale): if value is not None: return pretty_timestamp(value) - return None From 35e0b043e1dfeb259128e84a954f2e7df2879a71 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 14:49:15 +0100 Subject: [PATCH 161/854] added few docstrings --- openpype/tools/workfiles/lib.py | 62 ++++++++++++++++++++++++++++++- openpype/tools/workfiles/model.py | 43 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/openpype/tools/workfiles/lib.py b/openpype/tools/workfiles/lib.py index 84f2e76450..b9a1f5b19b 100644 --- a/openpype/tools/workfiles/lib.py +++ b/openpype/tools/workfiles/lib.py @@ -10,7 +10,7 @@ import appdirs class TempPublishFilesItem(object): - """Object representing on subfolder in app temp files. + """Object representing copied workfile in app temp folfer. Args: item_id (str): Id of item used as subfolder. @@ -44,7 +44,39 @@ class TempPublishFilesItem(object): class TempPublishFiles(object): - """Directory where """ + """Directory where published workfiles are copied when opened. + + Directory is located in appdirs on the machine. Folder contains file + with metadata about stored files. Each item in metadata has id, filename + and expiration time. When expiration time is higher then current time the + item is removed from metadata and it's files are deleted. Files of items + are stored in subfolder named by item's id. + + Metadata file can be in theory opened and modified by multiple processes, + threads at one time. For those cases is created simple lock file which + is created before modification begins and is removed when modification + ends. Existince of the file means that it should not be modified by + any other process at the same time. + + Metadata example: + ``` + { + "96050b4a-8974-4fca-8179-7c446c478d54": { + "created": 1647880725.555, + "expiration": 1647884325.555, + "filename": "cg_pigeon_workfileModeling_v025.ma" + }, + ... + } + ``` + + ## Why is this needed + Combination of more issues. Temp files are not automatically removed by + OS on windows so using tempfiles in TEMP would lead to kill disk space of + machine. There are also cases when someone wants to open multiple files + in short period of time and want to manually remove those files so keeping + track of temporary copied files in pre-defined structure is needed. + """ minute_in_seconds = 60 hour_in_seconds = 60 * minute_in_seconds day_in_seconds = 24 * hour_in_seconds @@ -72,16 +104,26 @@ class TempPublishFiles(object): @property def life_time(self): + """How long will be new item kept in temp in seconds. + + Returns: + int: Lifetime of temp item. + """ return int(self.hour_in_seconds) @property def size(self): + """File size of existing items.""" size = 0 for item in self.get_items(): size += item.size return size def add_file(self, src_path): + """Add workfile to temp directory. + + This will create new item and source path is copied to it's directory. + """ filename = os.path.basename(src_path) item_id = str(uuid.uuid4()) @@ -105,6 +147,7 @@ class TempPublishFiles(object): @contextlib.contextmanager def _modify_data(self): + """Create lock file when data in metadata file are modified.""" start_time = time.time() timeout = 3 while os.path.exists(self._lock_path): @@ -139,6 +182,15 @@ class TempPublishFiles(object): return output def cleanup(self, check_expiration=True): + """Cleanup files based on metadata. + + Items that passed expiration are removed when this is called. Or all + files are removed when `check_expiration` is set to False. + + Args: + check_expiration (bool): All items and files are removed when set + to True. + """ data = self._get_data() now = time.time() remove_ids = set() @@ -182,6 +234,11 @@ class TempPublishFiles(object): self.cleanup(False) def get_items(self): + """Receive all items from metadata file. + + Returns: + list: Info about each item in metadata. + """ output = [] data = self._get_data() for item_id, item_data in data.items(): @@ -190,6 +247,7 @@ class TempPublishFiles(object): return output def remove_id(self, item_id): + """Remove files of item and then remove the item from metadata.""" filepath = os.path.join(self._root_dir, item_id) if os.path.exists(filepath): shutil.rmtree(filepath) diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 563a2fc558..4d772c58e0 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -19,6 +19,8 @@ ITEM_ID_ROLE = QtCore.Qt.UserRole + 4 class WorkAreaFilesModel(QtGui.QStandardItemModel): + """Model is looking into one folder for files with extension.""" + def __init__(self, extensions, *args, **kwargs): super(WorkAreaFilesModel, self).__init__(*args, **kwargs) @@ -64,6 +66,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): return self._empty_root_item def set_root(self, root): + """Change directory where to look for file.""" self._root = root if root and not os.path.exists(root): log.debug("Work Area does not exist: {}".format(root)) @@ -81,7 +84,9 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): self._items_by_filename = {} def refresh(self): + """Refresh and update model items.""" root_item = self.invisibleRootItem() + # If path is not set or does not exist then add invalid path item if not self._root or not os.path.exists(self._root): self._clear() # Add Work Area does not exist placeholder @@ -90,9 +95,14 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): self._invalid_item_visible = True return + # Clear items if previous refresh set '_invalid_item_visible' to True + # - Invalid items are not stored to '_items_by_filename' so they would + # not be removed if self._invalid_item_visible: self._clear() + # Check for new items that should be added and items that should be + # removed new_items = [] items_to_remove = set(self._items_by_filename.keys()) for filename in os.listdir(self._root): @@ -106,6 +116,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): modified = os.path.getmtime(filepath) + # Use existing item or create new one if filename in items_to_remove: items_to_remove.remove(filename) item = self._items_by_filename[filename] @@ -118,16 +129,20 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): item.setData(self._file_icon, QtCore.Qt.DecorationRole) new_items.append(item) self._items_by_filename[filename] = item + # Update data that may be different item.setData(filepath, FILEPATH_ROLE) item.setData(modified, DATE_MODIFIED_ROLE) + # Add new items if there are any if new_items: root_item.appendRows(new_items) + # Remove items that are no longer available for filename in items_to_remove: item = self._items_by_filename.pop(filename) root_item.removeRow(item.row()) + # Add empty root item if there are not filenames that could be shown if root_item.rowCount() > 0: self._invalid_item_visible = False else: @@ -136,9 +151,11 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): root_item.appendRow(item) def has_valid_items(self): + """Directory has files that are listed in items.""" return not self._invalid_item_visible def flags(self, index): + # Use flags of first column for all columns if index.column() != 0: index = self.index(index.row(), 0, index.parent()) return super(WorkAreaFilesModel, self).flags(index) @@ -147,6 +164,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): if role is None: role = QtCore.Qt.DisplayRole + # Handle roles for first column if index.column() == 1: if role == QtCore.Qt.DecorationRole: return None @@ -174,6 +192,22 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): class PublishFilesModel(QtGui.QStandardItemModel): + """Model filling files with published files calculated from representation. + + This model looks for workfile family representations based on selected + asset and task. + + Asset must set to be able look for representations that could be used. + Task is used to filter representations by task. + Model has few filter criteria for filling. + - First criteria is that version document must have "workfile" in + "data.families". + - Second cirteria is that representation must have extension same as + defined extensions + - If task is set then representation must have 'task["name"]' with same + name. + """ + def __init__(self, extensions, dbcon, anatomy, *args, **kwargs): super(PublishFilesModel, self).__init__(*args, **kwargs) @@ -225,6 +259,12 @@ class PublishFilesModel(QtGui.QStandardItemModel): return self._empty_root_item def set_context(self, asset_id, task_name): + """Change context to asset and task. + + Args: + asset_id (ObjectId): Id of selected asset. + task_name (str): Name of selected task. + """ self._asset_id = asset_id self._task_name = task_name self.refresh() @@ -242,6 +282,7 @@ class PublishFilesModel(QtGui.QStandardItemModel): def _get_workfie_representations(self): output = [] + # Get subset docs of asset subset_docs = self._dbcon.find( { "type": "subset", @@ -286,6 +327,7 @@ class PublishFilesModel(QtGui.QStandardItemModel): "context.ext": {"$in": extensions} } ) + # Filter queried representations by task name if task is set filtered_repre_docs = [] for repre_doc in repre_docs: if self._task_name is None: @@ -305,6 +347,7 @@ class PublishFilesModel(QtGui.QStandardItemModel): if task_name == self._task_name: filtered_repre_docs.append(repre_doc) + # Collect paths of representations for repre_doc in filtered_repre_docs: path = get_representation_path( repre_doc, root=self._anatomy.roots From b71554fe25375af9e87b7c854d3492d9f932de02 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 22 Mar 2022 14:56:29 +0100 Subject: [PATCH 162/854] OP-2765 - fix for update of multiple instances --- openpype/hosts/aftereffects/plugins/create/create_render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 1a5a826137..e4f1f57b84 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -34,9 +34,9 @@ class RenderCreator(Creator): self._add_instance_to_context(instance) def update_instances(self, update_list): - created_inst, changes = update_list[0] - api.get_stub().imprint(created_inst.get("instance_id"), - created_inst.data_to_store()) + for created_inst, _changes in update_list: + api.get_stub().imprint(created_inst.get("instance_id"), + created_inst.data_to_store()) def remove_instances(self, instances): for instance in instances: From cc7a5e0a6fddfb7d0d55dae0f3445251f4d9c5f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 22 Mar 2022 15:03:25 +0100 Subject: [PATCH 163/854] node renaming wip --- .../publish/extract_unreal_skeletalmesh.py | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index 6f4c70fc07..58154638e6 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Create Unreal Skeletal Mesh data to be extracted as FBX.""" import os +from contextlib import contextmanager from maya import cmds # noqa @@ -12,6 +13,16 @@ from openpype.hosts.maya.api.lib import ( ) from openpype.hosts.maya.api import fbx +@contextmanager +def renamed(original_name, renamed_name): + # type: (str, str) -> None + try: + cmds.rename(original_name, renamed_name) + yield + finally: + cmds.rename(renamed_name, original_name) + yield + class ExtractUnrealSkeletalMesh(openpype.api.Extractor): """Extract Unreal Skeletal Mesh as FBX from Maya. """ @@ -48,13 +59,14 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): instance.data.get("variant", "") ) with maintained_selection(): - with parent_nodes(to_extract, parent=parent): - rooted = [ - "{}|{}".format(parent, i.split("|")[-1]) - for i in to_extract - ] - self.log.info("Un-parenting: {}".format(rooted, path)) - fbx_exporter.export(rooted, path) + with renamed() + with parent_nodes(to_extract, parent=parent): + rooted = [ + "{}|{}".format(parent, i.split("|")[-1]) + for i in to_extract + ] + self.log.info("Un-parenting: {}".format(rooted, path)) + fbx_exporter.export(rooted, path) if "representations" not in instance.data: instance.data["representations"] = [] From dbe643e4100a6d0630a30f0aefab686ad023e3e7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 15:10:30 +0100 Subject: [PATCH 164/854] Fix cases when published file is not available on the machine --- openpype/tools/workfiles/model.py | 73 ++++++++++++++++++------------- 1 file changed, 43 insertions(+), 30 deletions(-) diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 4d772c58e0..2695e0d26e 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -223,6 +223,10 @@ class PublishFilesModel(QtGui.QStandardItemModel): "fa.file-o", color=get_default_entity_icon_color() ) + self._invalid_icon = qtawesome.icon( + "fa.times", + color=get_disabled_entity_icon_color() + ) self._invalid_item_visible = False self._items_by_id = {} @@ -230,31 +234,29 @@ class PublishFilesModel(QtGui.QStandardItemModel): self._asset_id = None self._task_name = None + def _set_item_invalid(self, item): + item.setFlags(QtCore.Qt.NoItemFlags) + item.setData(self._invalid_icon, QtCore.Qt.DecorationRole) + + def _set_item_valid(self, item): + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(self._file_icon, QtCore.Qt.DecorationRole) + def _get_invalid_context_item(self): if self._invalid_context_item is None: - message = "Selected context is not valid." - item = QtGui.QStandardItem(message) - icon = qtawesome.icon( - "fa.times", - color=get_disabled_entity_icon_color() - ) - item.setData(icon, QtCore.Qt.DecorationRole) - item.setFlags(QtCore.Qt.NoItemFlags) + item = QtGui.QStandardItem("Selected context is not valid.") item.setColumnCount(self.columnCount()) + self._set_item_invalid(item) self._invalid_context_item = item return self._invalid_context_item def _get_empty_root_item(self): if self._empty_root_item is None: - message = "Didn't find any published workfiles." - item = QtGui.QStandardItem(message) - icon = qtawesome.icon( - "fa.times", - color=get_disabled_entity_icon_color() - ) - item.setData(icon, QtCore.Qt.DecorationRole) - item.setFlags(QtCore.Qt.NoItemFlags) + item = QtGui.QStandardItem("Didn't find any published workfiles.") item.setColumnCount(self.columnCount()) + self._set_item_invalid(item) self._empty_root_item = item return self._empty_root_item @@ -290,21 +292,15 @@ class PublishFilesModel(QtGui.QStandardItemModel): }, { "_id": True, - "data.families": True, "name": True } ) - filtered_subsets = [] - for subset_doc in subset_docs: - data = subset_doc.get("data") or {} - families = data.get("families") or [] - if "workfile" in families: - filtered_subsets.append(subset_doc) - subset_ids = [subset_doc["_id"] for subset_doc in filtered_subsets] + subset_ids = [subset_doc["_id"] for subset_doc in subset_docs] if not subset_ids: return output + # Get version docs of subsets with their families version_docs = self._dbcon.find( { "type": "version", @@ -312,13 +308,24 @@ class PublishFilesModel(QtGui.QStandardItemModel): }, { "_id": True, + "data.families": True, "parent": True } ) - version_ids = [version_doc["_id"] for version_doc in version_docs] + # Filter versions if they contain 'workfile' family + filtered_versions = [] + for version_doc in version_docs: + data = version_doc.get("data") or {} + families = data.get("families") or [] + if "workfile" in families: + filtered_versions.append(version_doc) + + version_ids = [version_doc["_id"] for version_doc in filtered_versions] if not version_ids: return output + # Query representations of filtered versions and add filter for + # extension extensions = [ext.replace(".", "") for ext in self._file_extensions] repre_docs = self._dbcon.find( { @@ -372,7 +379,6 @@ class PublishFilesModel(QtGui.QStandardItemModel): items_to_remove = set(self._items_by_id.keys()) for item in self._get_workfie_representations(): filepath, repre_id = item - modified = os.path.getmtime(filepath) filename = os.path.basename(filepath) if repre_id in items_to_remove: @@ -381,12 +387,19 @@ class PublishFilesModel(QtGui.QStandardItemModel): else: item = QtGui.QStandardItem(filename) item.setColumnCount(self.columnCount()) - item.setFlags( - QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - ) - item.setData(self._file_icon, QtCore.Qt.DecorationRole) new_items.append(item) self._items_by_id[repre_id] = item + + if os.path.exists(filepath): + modified = os.path.getmtime(filepath) + tooltip = None + self._set_item_valid(item) + else: + modified = None + tooltip = "File is not available from this machine" + self._set_item_invalid(item) + + item.setData(tooltip, QtCore.Qt.ToolTipRole) item.setData(filepath, FILEPATH_ROLE) item.setData(modified, DATE_MODIFIED_ROLE) item.setData(repre_id, ITEM_ID_ROLE) From 8b06aa590a44e231d519ed19ee605361ff04a1e4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 15:11:38 +0100 Subject: [PATCH 165/854] skip empty filepaths --- openpype/tools/workfiles/model.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 2695e0d26e..8f9dd8c6ba 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -379,6 +379,9 @@ class PublishFilesModel(QtGui.QStandardItemModel): items_to_remove = set(self._items_by_id.keys()) for item in self._get_workfie_representations(): filepath, repre_id = item + # TODO handle empty filepaths + if not filepath: + continue filename = os.path.basename(filepath) if repre_id in items_to_remove: From db03b47b8ee5615bb19ee90f1dc52f8f05cf379b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Mar 2022 15:14:38 +0100 Subject: [PATCH 166/854] hound fix --- openpype/hosts/flame/api/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 7c03186ff0..97f83ccf07 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -146,7 +146,6 @@ __all__ = [ "export_clip", "get_preset_path_by_xml_name", "modify_preset_file", - # batch utils "create_batch" From aab2ed17f8d582056f5835613845d13ea7205b24 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Mar 2022 15:15:04 +0100 Subject: [PATCH 167/854] flame: ingegrate batch wip --- .../plugins/publish/integrate_batch_group.py | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index fd88ed318e..aaa405343c 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -1,6 +1,7 @@ import pyblish import openpype.hosts.flame.api as opfapi + @pyblish.api.log class IntegrateBatchGroup(pyblish.api.InstancePlugin): """Integrate published shot to batch group""" @@ -11,4 +12,67 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): families = ["clip"] def process(self, instance): - opfapi.create_batch \ No newline at end of file + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] + handle_start = instance.data["handleStart"] + handle_end = instance.data["handleEnd"] + + asset_name = instance.data["asset"] + write_pref_data = self._get_write_prefs(instance) + + batch_data = { + "shematic_reels": [ + "OP_LoadedReel" + ], + "write_pref": write_pref_data, + "handleStart": handle_start, + "handleEnd": handle_end + } + + opfapi.create_batch(asset_name, frame_start, frame_end, batch_data) + + def _get_write_prefs(self, instance): + # The path attribute where the rendered clip is exported + # /path/to/file.[0001-0010].exr + media_path = "{render_path}".format() + # name of file represented by tokens + media_path_pattern = "_v." + # The Create Open Clip attribute of the Write File node. \ + # Determines if an Open Clip is created by the Write File node. + create_clip = True + # The Include Setup attribute of the Write File node. + # Determines if a Batch Setup file is created by the Write File node. + include_setup = True + # The path attribute where the Open Clip file is exported by + # the Write File node. + create_clip_path = "" + include_setup_path = None + # The file type for the files written by the Write File node. + # Setting this attribute also overwrites format_extension, + # bit_depth and compress_mode to match the defaults for + # this file type. + file_type = "OpenEXR" + # The bit depth for the files written by the Write File node. + # This attribute resets to match file_type whenever file_type is set. + bit_depth = "16" + frame_index_mode = None + frame_padding = 0 + # The versioning mode of the Open Clip exported by the Write File node. + # Only available if create_clip = True. + version_mode = "Follow Iteration" + version_name = "v" + + return { + "media_path": media_path, + "media_path_pattern": media_path_pattern, + "create_clip": create_clip, + "include_setup": include_setup, + "create_clip_path": create_clip_path, + "include_setup_path": include_setup_path, + "file_type": file_type, + "bit_depth": bit_depth, + "frame_index_mode": frame_index_mode, + "frame_padding": frame_padding, + "version_mode": version_mode, + "version_name": version_name + } From 8be14fc8818fa4842276a5078cd7611f9e165e82 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Mar 2022 16:17:52 +0100 Subject: [PATCH 168/854] Fix typo Co-authored-by: Roy Nieterau --- openpype/tools/workfiles/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/lib.py b/openpype/tools/workfiles/lib.py index b9a1f5b19b..0aa78fa00e 100644 --- a/openpype/tools/workfiles/lib.py +++ b/openpype/tools/workfiles/lib.py @@ -10,7 +10,7 @@ import appdirs class TempPublishFilesItem(object): - """Object representing copied workfile in app temp folfer. + """Object representing copied workfile in app temp folder. Args: item_id (str): Id of item used as subfolder. From 231b63df60d67d315f5c2cdc1da0746cabcc53b1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 22 Mar 2022 16:18:01 +0100 Subject: [PATCH 169/854] Fix typo 2 Co-authored-by: Roy Nieterau --- openpype/tools/workfiles/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/lib.py b/openpype/tools/workfiles/lib.py index 0aa78fa00e..21a7485b7b 100644 --- a/openpype/tools/workfiles/lib.py +++ b/openpype/tools/workfiles/lib.py @@ -55,7 +55,7 @@ class TempPublishFiles(object): Metadata file can be in theory opened and modified by multiple processes, threads at one time. For those cases is created simple lock file which is created before modification begins and is removed when modification - ends. Existince of the file means that it should not be modified by + ends. Existence of the file means that it should not be modified by any other process at the same time. Metadata example: From 37f152adbf7e621363726266c315bc02330c5095 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 16:27:57 +0100 Subject: [PATCH 170/854] update avalon core --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index 64491fbbcf..2fa14cea6f 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 64491fbbcf89ba2a0b3a20d67d7486c6142232b3 +Subproject commit 2fa14cea6f6a9d86eec70bbb96860cbe4c75c8eb From 6fde2110148e62649ae3bd0d25726d5dd9c16859 Mon Sep 17 00:00:00 2001 From: Pype Club Date: Tue, 22 Mar 2022 16:37:31 +0100 Subject: [PATCH 171/854] OP-2766 - fix loaders because of change in imprint signature --- openpype/hosts/photoshop/plugins/load/load_image.py | 4 ++-- openpype/hosts/photoshop/plugins/load/load_reference.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/load/load_image.py b/openpype/hosts/photoshop/plugins/load/load_image.py index 0a9421b8f2..91a9787781 100644 --- a/openpype/hosts/photoshop/plugins/load/load_image.py +++ b/openpype/hosts/photoshop/plugins/load/load_image.py @@ -61,7 +61,7 @@ class ImageLoader(photoshop.PhotoshopLoader): ) stub.imprint( - layer, {"representation": str(representation["_id"])} + layer.id, {"representation": str(representation["_id"])} ) def remove(self, container): @@ -73,7 +73,7 @@ class ImageLoader(photoshop.PhotoshopLoader): stub = self.get_stub() layer = container.pop("layer") - stub.imprint(layer, {}) + stub.imprint(layer.id, {}) stub.delete_layer(layer.id) def switch(self, container, representation): diff --git a/openpype/hosts/photoshop/plugins/load/load_reference.py b/openpype/hosts/photoshop/plugins/load/load_reference.py index f5f0545d39..1f32a5d23c 100644 --- a/openpype/hosts/photoshop/plugins/load/load_reference.py +++ b/openpype/hosts/photoshop/plugins/load/load_reference.py @@ -61,7 +61,7 @@ class ReferenceLoader(photoshop.PhotoshopLoader): ) stub.imprint( - layer, {"representation": str(representation["_id"])} + layer.id, {"representation": str(representation["_id"])} ) def remove(self, container): @@ -72,7 +72,7 @@ class ReferenceLoader(photoshop.PhotoshopLoader): """ stub = self.get_stub() layer = container.pop("layer") - stub.imprint(layer, {}) + stub.imprint(layer.id, {}) stub.delete_layer(layer.id) def switch(self, container, representation): From bdc3a05c4d52a29c1aaff99d83c993be48c7563e Mon Sep 17 00:00:00 2001 From: Pype Club Date: Tue, 22 Mar 2022 16:38:46 +0100 Subject: [PATCH 172/854] OP-2766 - fix wrongly used functions --- openpype/hosts/photoshop/api/pipeline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 2f4343753c..abc4e63bf6 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -179,10 +179,10 @@ def remove_instance(instance): stub.remove_instance(inst_id) if instance.get("members"): - item = stub.get_item(instance["members"][0]) + item = stub.get_layer(instance["members"][0]) if item: - stub.rename_item(item.id, - item.name.replace(stub.PUBLISH_ICON, '')) + stub.rename_layer(item.id, + item.name.replace(stub.PUBLISH_ICON, '')) def _get_stub(): From 7651ebd8521fd7f2408aaebb262a877ca8840d81 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 16:40:11 +0100 Subject: [PATCH 173/854] added option to save as to current context --- openpype/tools/workfiles/files_widget.py | 82 +++++++++++++++++------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index d2b8a76952..74729e5346 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -155,18 +155,33 @@ class FilesWidget(QtWidgets.QWidget): # Home Page # Build buttons widget for files widget btns_widget = QtWidgets.QWidget(self) - btn_save = QtWidgets.QPushButton("Save As", btns_widget) - btn_browse = QtWidgets.QPushButton("Browse", btns_widget) - btn_open = QtWidgets.QPushButton("Open", btns_widget) - btn_view_published = QtWidgets.QPushButton("View", btns_widget) + workarea_btns_widget = QtWidgets.QWidget(btns_widget) + btn_save = QtWidgets.QPushButton("Save As", workarea_btns_widget) + btn_browse = QtWidgets.QPushButton("Browse", workarea_btns_widget) + btn_open = QtWidgets.QPushButton("Open", workarea_btns_widget) + + workarea_btns_layout = QtWidgets.QHBoxLayout(workarea_btns_widget) + workarea_btns_layout.setContentsMargins(0, 0, 0, 0) + workarea_btns_layout.addWidget(btn_open, 1) + workarea_btns_layout.addWidget(btn_browse, 1) + workarea_btns_layout.addWidget(btn_save, 1) + + publish_btns_widget = QtWidgets.QWidget(btns_widget) + btn_view_published = QtWidgets.QPushButton("View", publish_btns_widget) + btn_save_as_published = QtWidgets.QPushButton( + "Save As", publish_btns_widget + ) + + publish_btns_layout = QtWidgets.QHBoxLayout(publish_btns_widget) + publish_btns_layout.setContentsMargins(0, 0, 0, 0) + publish_btns_layout.addWidget(btn_view_published, 1) + publish_btns_layout.addWidget(btn_save_as_published, 1) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addWidget(btn_open, 1) - btns_layout.addWidget(btn_browse, 1) - btns_layout.addWidget(btn_save, 1) - btns_layout.addWidget(btn_view_published, 1) + btns_layout.addWidget(workarea_btns_widget, 1) + btns_layout.addWidget(publish_btns_widget, 1) # Build files widgets for home page main_layout = QtWidgets.QVBoxLayout(self) @@ -189,13 +204,16 @@ class FilesWidget(QtWidgets.QWidget): self.on_file_select ) publish_files_view.doubleClickedLeft.connect( - self._on_view_published_pressed + self._on_published_view_pressed ) btn_open.pressed.connect(self._on_workarea_open_pressed) btn_browse.pressed.connect(self.on_browse_pressed) - btn_save.pressed.connect(self.on_save_as_pressed) - btn_view_published.pressed.connect(self._on_view_published_pressed) + btn_save.pressed.connect(self._on_save_as_pressed) + btn_view_published.pressed.connect(self._on_published_view_pressed) + btn_save_as_published.pressed.connect( + self._on_published_save_as_pressed + ) # Store attributes self._published_checkbox = published_checkbox @@ -211,7 +229,8 @@ class FilesWidget(QtWidgets.QWidget): self._publish_files_model = publish_files_model self._publish_proxy_model = publish_proxy_model - self._btns_widget = btns_widget + self._workarea_btns_widget = workarea_btns_widget + self._publish_btns_widget = publish_btns_widget self._btn_open = btn_open self._btn_browse = btn_browse self._btn_save = btn_save @@ -222,7 +241,7 @@ class FilesWidget(QtWidgets.QWidget): # Hide publish files widgets publish_files_view.setVisible(False) - btn_view_published.setVisible(False) + publish_btns_widget.setVisible(False) @property def published_enabled(self): @@ -232,12 +251,10 @@ class FilesWidget(QtWidgets.QWidget): published_enabled = self.published_enabled self._workarea_files_view.setVisible(not published_enabled) - self._btn_open.setVisible(not published_enabled) - self._btn_browse.setVisible(not published_enabled) - self._btn_save.setVisible(not published_enabled) + self._workarea_btns_widget.setVisible(not published_enabled) self._publish_files_view.setVisible(published_enabled) - self._btn_view_published.setVisible(published_enabled) + self._publish_btns_widget.setVisible(published_enabled) self._update_filtering() self._update_asset_task() @@ -462,11 +479,16 @@ class FilesWidget(QtWidgets.QWidget): if work_file: self.open_file(work_file) - def on_save_as_pressed(self): + def _on_save_as_pressed(self): + self._save_as_with_dialog() + + def _save_as_with_dialog(self): work_filename = self.get_filename() if not work_filename: return + src_path = self._get_selected_filepath() + # Trigger before save event emit_event( "workfile.save.before", @@ -486,13 +508,20 @@ class FilesWidget(QtWidgets.QWidget): log.debug("Initializing Work Directory: %s", self._workfiles_root) os.makedirs(self._workfiles_root) - # Update session if context has changed - self._enter_session() # Prepare full path to workfile and save it filepath = os.path.join( os.path.normpath(self._workfiles_root), work_filename ) - self.host.save_file(filepath) + + # Update session if context has changed + self._enter_session() + + if not self.published_enabled: + self.host.save_file(filepath) + else: + shutil.copy(src_path, filepath) + self.host.open_file(filepath) + # Create extra folders create_workdir_extra_folders( self._workdir_path, @@ -510,9 +539,12 @@ class FilesWidget(QtWidgets.QWidget): self.workfile_created.emit(filepath) # Refresh files model - self.refresh() + if self.published_enabled: + self._published_checkbox.setChecked(False) + else: + self.refresh() - def _on_view_published_pressed(self): + def _on_published_view_pressed(self): filepath = self._get_selected_filepath() if not filepath or not os.path.exists(filepath): return @@ -522,6 +554,10 @@ class FilesWidget(QtWidgets.QWidget): # Change state back to workarea self._published_checkbox.setChecked(False) + def _on_published_save_as_pressed(self): + self._save_as_with_dialog() + + def on_file_select(self): self.file_selected.emit(self._get_selected_filepath()) From b8dd330be3f0de72ba1a28652dff2ae4702c3dc2 Mon Sep 17 00:00:00 2001 From: Pype Club Date: Tue, 22 Mar 2022 16:40:28 +0100 Subject: [PATCH 174/854] OP-2766 - fix new creator for multiple instance's update --- .../hosts/photoshop/plugins/create/create_image.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index c24d8bde2f..bc0fa6a051 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -102,11 +102,11 @@ class ImageCreator(Creator): def update_instances(self, update_list): self.log.debug("update_list:: {}".format(update_list)) - created_inst, changes = update_list[0] - if created_inst.get("layer"): - created_inst.pop("layer") # not storing PSItem layer to metadata - api.stub().imprint(created_inst.get("instance_id"), - created_inst.data_to_store()) + for created_inst, _changes in update_list: + if created_inst.get("layer"): + created_inst.pop("layer") # not storing PSItem layer to metadata + api.stub().imprint(created_inst.get("instance_id"), + created_inst.data_to_store()) def remove_instances(self, instances): for instance in instances: From bf0bc2436e1f0a085ca30381e2b5285cadf70320 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 16:46:38 +0100 Subject: [PATCH 175/854] added option to save as to context --- openpype/tools/workfiles/files_widget.py | 81 +++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 74729e5346..1faafe2bdb 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -172,11 +172,23 @@ class FilesWidget(QtWidgets.QWidget): btn_save_as_published = QtWidgets.QPushButton( "Save As", publish_btns_widget ) + btn_save_as_to_published = QtWidgets.QPushButton( + "Save As (to context)", publish_btns_widget + ) + btn_select_context_published = QtWidgets.QPushButton( + "Select context", publish_btns_widget + ) + btn_cancel_published = QtWidgets.QPushButton( + "Cancel", publish_btns_widget + ) publish_btns_layout = QtWidgets.QHBoxLayout(publish_btns_widget) publish_btns_layout.setContentsMargins(0, 0, 0, 0) publish_btns_layout.addWidget(btn_view_published, 1) publish_btns_layout.addWidget(btn_save_as_published, 1) + publish_btns_layout.addWidget(btn_save_as_to_published, 1) + publish_btns_layout.addWidget(btn_cancel_published, 1) + publish_btns_layout.addWidget(btn_select_context_published, 1) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) @@ -214,6 +226,15 @@ class FilesWidget(QtWidgets.QWidget): btn_save_as_published.pressed.connect( self._on_published_save_as_pressed ) + btn_save_as_to_published.pressed.connect( + self._on_publish_save_as_to_pressed + ) + btn_select_context_published.pressed.connect( + self._on_publish_select_context_pressed + ) + btn_cancel_published.pressed.connect( + self._on_publish_cancel_pressed + ) # Store attributes self._published_checkbox = published_checkbox @@ -234,7 +255,12 @@ class FilesWidget(QtWidgets.QWidget): self._btn_open = btn_open self._btn_browse = btn_browse self._btn_save = btn_save + self._btn_view_published = btn_view_published + self._btn_save_as_published = btn_save_as_published + self._btn_save_as_to_published = btn_save_as_to_published + self._btn_select_context_published = btn_select_context_published + self._btn_cancel_published = btn_cancel_published # Create a proxy widget for files widget self.setFocusProxy(btn_open) @@ -242,6 +268,10 @@ class FilesWidget(QtWidgets.QWidget): # Hide publish files widgets publish_files_view.setVisible(False) publish_btns_widget.setVisible(False) + btn_select_context_published.setVisible(False) + btn_cancel_published.setVisible(False) + + self._publish_context_select_mode = False @property def published_enabled(self): @@ -285,12 +315,15 @@ class FilesWidget(QtWidgets.QWidget): self._update_asset_task() def _update_asset_task(self): - if self.published_enabled: + if self.published_enabled and not self._publish_context_select_mode: self._publish_files_model.set_context( self._asset_id, self._task_name ) has_valid_items = self._publish_files_model.has_valid_items() self._btn_view_published.setEnabled(has_valid_items) + self._btn_save_as_published.setEnabled(has_valid_items) + self._btn_save_as_to_published.setEnabled(has_valid_items) + else: # Define a custom session so we can query the work root # for a "Work area" that is not our current Session. @@ -308,6 +341,13 @@ class FilesWidget(QtWidgets.QWidget): has_valid_items = self._workarea_files_model.has_valid_items() self._btn_browse.setEnabled(has_valid_items) self._btn_open.setEnabled(has_valid_items) + + if self._publish_context_select_mode: + self._btn_select_context_published.setEnabled( + bool(self._asset_id) and bool(self._task_name) + ) + return + # Manually trigger file selection if not has_valid_items: self.on_file_select() @@ -557,6 +597,45 @@ class FilesWidget(QtWidgets.QWidget): def _on_published_save_as_pressed(self): self._save_as_with_dialog() + def _set_publish_context_select_mode(self, enabled): + self._publish_context_select_mode = enabled + + # Show buttons related to context selection + self._btn_cancel_published.setVisible(enabled) + self._btn_select_context_published.setVisible(enabled) + # Change enabled state based on select context + self._btn_select_context_published.setEnabled( + bool(self._asset_id) and bool(self._task_name) + ) + + self._btn_view_published.setVisible(not enabled) + self._btn_save_as_published.setVisible(not enabled) + self._btn_save_as_to_published.setVisible(not enabled) + + # Change views and disable workarea view if enabled + self._workarea_files_view.setEnabled(not enabled) + if self.published_enabled: + self._workarea_files_view.setVisible(enabled) + self._publish_files_view.setVisible(not enabled) + else: + self._workarea_files_view.setVisible(True) + self._publish_files_view.setVisible(False) + + # Disable filter widgets + self._published_checkbox.setEnabled(not enabled) + self._filter_input.setEnabled(not enabled) + + def _on_publish_save_as_to_pressed(self): + self._set_publish_context_select_mode(True) + + def _on_publish_select_context_pressed(self): + self._save_as_with_dialog() + self._set_publish_context_select_mode(False) + self._update_asset_task() + + def _on_publish_cancel_pressed(self): + self._set_publish_context_select_mode(False) + self._update_asset_task() def on_file_select(self): self.file_selected.emit(self._get_selected_filepath()) From 8f77e92d6f456e06c98326c4d0c55b6cb4f70208 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 22 Mar 2022 16:48:16 +0100 Subject: [PATCH 176/854] rename top node for variants --- .../publish/extract_unreal_skeletalmesh.py | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index 58154638e6..5b0eb5a3bc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -13,6 +13,7 @@ from openpype.hosts.maya.api.lib import ( ) from openpype.hosts.maya.api import fbx + @contextmanager def renamed(original_name, renamed_name): # type: (str, str) -> None @@ -21,7 +22,6 @@ def renamed(original_name, renamed_name): yield finally: cmds.rename(renamed_name, original_name) - yield class ExtractUnrealSkeletalMesh(openpype.api.Extractor): @@ -42,6 +42,8 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): geo = instance.data.get("geometry") joints = instance.data.get("joints") + joints_parent = cmds.listRelatives(joints, p=True) + to_extract = geo + joints # The export requires forward slashes because we need @@ -54,19 +56,30 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): fbx_exporter.set_options_from_instance(instance) + # This magic is done for variants. To let Unreal merge correctly + # existing data, top node must have the same name. So for every + # variant we extract we need to rename top node of the rig correctly. + # It is finally done in context manager so it won't affect current + # scene. parent = "{}{}".format( instance.data["asset"], instance.data.get("variant", "") ) - with maintained_selection(): - with renamed() - with parent_nodes(to_extract, parent=parent): - rooted = [ - "{}|{}".format(parent, i.split("|")[-1]) - for i in to_extract - ] - self.log.info("Un-parenting: {}".format(rooted, path)) - fbx_exporter.export(rooted, path) + + renamed_to_extract = [] + for node in to_extract: + node_path = node.split("|") + node_path[1] = parent + renamed_to_extract.append("|".join(node_path)) + + with renamed(joints_parent, parent): + with parent_nodes(renamed_to_extract, parent=parent): + rooted = [ + "{}|{}".format(parent, i.split("|")[-1]) + for i in renamed_to_extract + ] + self.log.info("Un-parenting: {}".format(rooted, path)) + fbx_exporter.export(rooted, path) if "representations" not in instance.data: instance.data["representations"] = [] From adc135cb4c1d09eb27d51dae067f054a93c74d77 Mon Sep 17 00:00:00 2001 From: Pype Club Date: Tue, 22 Mar 2022 16:59:53 +0100 Subject: [PATCH 177/854] OP-2766 - added newPublishing flag to differentiate old from new --- openpype/plugins/publish/collect_from_create_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 16e3f669c3..09584ab37c 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -25,7 +25,7 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): # Update global data to context context.data.update(create_context.context_data_to_store()) - + context.data["newPublishing"] = True # Update context data for key in ("AVALON_PROJECT", "AVALON_ASSET", "AVALON_TASK"): value = create_context.dbcon.Session.get(key) From 96d88e592d56cb5193a13764aba9f5fcecff9616 Mon Sep 17 00:00:00 2001 From: Pype Club Date: Tue, 22 Mar 2022 17:01:35 +0100 Subject: [PATCH 178/854] OP-2766 - renamed collector --- openpype/hosts/photoshop/plugins/publish/collect_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index ee402dcabf..d506b9a5bf 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -13,7 +13,7 @@ class CollectInstances(pyblish.api.ContextPlugin): id (str): "pyblish.avalon.instance" """ - label = "Instances" + label = "Collect Instances" order = pyblish.api.CollectorOrder hosts = ["photoshop"] families_mapping = { From 12aeb88e0a2cd7ccfa6dae9dd7f20d83bf4577b5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 22 Mar 2022 17:13:11 +0100 Subject: [PATCH 179/854] flame: integrate batch [wip] --- .../plugins/publish/integrate_batch_group.py | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index aaa405343c..780531287b 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -1,3 +1,4 @@ +import os import pyblish import openpype.hosts.flame.api as opfapi @@ -32,9 +33,12 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): opfapi.create_batch(asset_name, frame_start, frame_end, batch_data) def _get_write_prefs(self, instance): + shot_path = instance.data[""] + render_dir_path = os.path.join( + shot_path, "work", task, "render", "flame") # The path attribute where the rendered clip is exported # /path/to/file.[0001-0010].exr - media_path = "{render_path}".format() + media_path = render_dir_path # name of file represented by tokens media_path_pattern = "_v." # The Create Open Clip attribute of the Write File node. \ @@ -46,17 +50,33 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): # The path attribute where the Open Clip file is exported by # the Write File node. create_clip_path = "" - include_setup_path = None + # The path attribute where the Batch setup file + # is exported by the Write File node. + include_setup_path = "./_v" # The file type for the files written by the Write File node. # Setting this attribute also overwrites format_extension, # bit_depth and compress_mode to match the defaults for # this file type. file_type = "OpenEXR" + # The file extension for the files written by the Write File node. + # This attribute resets to match file_type whenever file_type + # is set. If you require a specific extension, you must + # set format_extension after setting file_type. + format_extension = "exr" # The bit depth for the files written by the Write File node. # This attribute resets to match file_type whenever file_type is set. bit_depth = "16" - frame_index_mode = None - frame_padding = 0 + # The compressing attribute for the files exported by the Write + # File node. Only relevant when file_type in 'OpenEXR', 'Sgi', 'Tiff' + compress = True + # The compression format attribute for the specific File Types + # export by the Write File node. You must set compress_mode + # after setting file_type. + compress_mode = "DWAB" + # The frame index mode attribute of the Write File node. + # Value range: `Use Timecode` or `Use Start Frame` + frame_index_mode = "Use Start Frame" + frame_padding = 6 # The versioning mode of the Open Clip exported by the Write File node. # Only available if create_clip = True. version_mode = "Follow Iteration" @@ -70,7 +90,10 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): "create_clip_path": create_clip_path, "include_setup_path": include_setup_path, "file_type": file_type, + "format_extension": format_extension, "bit_depth": bit_depth, + "compress": compress, + "compress_mode": compress_mode, "frame_index_mode": frame_index_mode, "frame_padding": frame_padding, "version_mode": version_mode, From 970d0768116d3a658ce1cf5c77b6a5331a1e5c2a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 18:18:02 +0100 Subject: [PATCH 180/854] moved attribute definitions to openpype/lib --- .../testhost/plugins/create/auto_creator.py | 6 ++-- .../testhost/plugins/create/test_creator_1.py | 20 ++++++++----- .../testhost/plugins/create/test_creator_2.py | 6 ++-- .../plugins/publish/collect_instance_1.py | 6 ++-- openpype/lib/__init__.py | 28 +++++++++++++++++ .../lib/attribute_definitions.py | 0 openpype/pipeline/create/context.py | 3 +- openpype/pipeline/lib/__init__.py | 30 ------------------- openpype/widgets/attribute_defs/widgets.py | 2 +- 9 files changed, 52 insertions(+), 49 deletions(-) rename openpype/{pipeline => }/lib/attribute_definitions.py (100%) delete mode 100644 openpype/pipeline/lib/__init__.py diff --git a/openpype/hosts/testhost/plugins/create/auto_creator.py b/openpype/hosts/testhost/plugins/create/auto_creator.py index 45c573e487..d5935602a0 100644 --- a/openpype/hosts/testhost/plugins/create/auto_creator.py +++ b/openpype/hosts/testhost/plugins/create/auto_creator.py @@ -1,10 +1,10 @@ +from avalon import io +from openpype.lib import NumberDef from openpype.hosts.testhost.api import pipeline from openpype.pipeline import ( AutoCreator, CreatedInstance, - lib ) -from avalon import io class MyAutoCreator(AutoCreator): @@ -13,7 +13,7 @@ class MyAutoCreator(AutoCreator): def get_instance_attr_defs(self): output = [ - lib.NumberDef("number_key", label="Number") + NumberDef("number_key", label="Number") ] return output diff --git a/openpype/hosts/testhost/plugins/create/test_creator_1.py b/openpype/hosts/testhost/plugins/create/test_creator_1.py index 45c30e8a27..7664276fa2 100644 --- a/openpype/hosts/testhost/plugins/create/test_creator_1.py +++ b/openpype/hosts/testhost/plugins/create/test_creator_1.py @@ -1,10 +1,16 @@ import json from openpype import resources from openpype.hosts.testhost.api import pipeline +from openpype.lib import ( + UISeparatorDef, + UILabelDef, + BoolDef, + NumberDef, + FileDef, +) from openpype.pipeline import ( Creator, CreatedInstance, - lib ) @@ -54,17 +60,17 @@ class TestCreatorOne(Creator): def get_instance_attr_defs(self): output = [ - lib.NumberDef("number_key", label="Number"), + NumberDef("number_key", label="Number"), ] return output def get_pre_create_attr_defs(self): output = [ - lib.BoolDef("use_selection", label="Use selection"), - lib.UISeparatorDef(), - lib.UILabelDef("Testing label"), - lib.FileDef("filepath", folders=True, label="Filepath"), - lib.FileDef( + BoolDef("use_selection", label="Use selection"), + UISeparatorDef(), + UILabelDef("Testing label"), + FileDef("filepath", folders=True, label="Filepath"), + FileDef( "filepath_2", multipath=True, folders=True, label="Filepath 2" ) ] diff --git a/openpype/hosts/testhost/plugins/create/test_creator_2.py b/openpype/hosts/testhost/plugins/create/test_creator_2.py index e66304a038..f54adee8a2 100644 --- a/openpype/hosts/testhost/plugins/create/test_creator_2.py +++ b/openpype/hosts/testhost/plugins/create/test_creator_2.py @@ -1,8 +1,8 @@ +from openpype.lib import NumberDef, TextDef from openpype.hosts.testhost.api import pipeline from openpype.pipeline import ( Creator, CreatedInstance, - lib ) @@ -40,8 +40,8 @@ class TestCreatorTwo(Creator): def get_instance_attr_defs(self): output = [ - lib.NumberDef("number_key"), - lib.TextDef("text_key") + NumberDef("number_key"), + TextDef("text_key") ] return output diff --git a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py index 3c035eccb6..c7241a15a8 100644 --- a/openpype/hosts/testhost/plugins/publish/collect_instance_1.py +++ b/openpype/hosts/testhost/plugins/publish/collect_instance_1.py @@ -1,10 +1,8 @@ import json import pyblish.api -from openpype.pipeline import ( - OpenPypePyblishPluginMixin, - attribute_definitions -) +from openpype.lib import attribute_definitions +from openpype.pipeline import OpenPypePyblishPluginMixin class CollectInstanceOneTestHost( diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 1ebafbb2d2..e8b6d18f4e 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -29,6 +29,21 @@ from .vendor_bin_utils import ( is_oiio_supported ) +from .attribute_definitions import ( + AbtractAttrDef, + + UIDef, + UISeparatorDef, + UILabelDef, + + UnknownDef, + NumberDef, + TextDef, + EnumDef, + BoolDef, + FileDef, +) + from .env_tools import ( env_value_to_bool, get_paths_from_environ, @@ -233,6 +248,19 @@ __all__ = [ "get_ffmpeg_tool_path", "is_oiio_supported", + "AbtractAttrDef", + + "UIDef", + "UISeparatorDef", + "UILabelDef", + + "UnknownDef", + "NumberDef", + "TextDef", + "EnumDef", + "BoolDef", + "FileDef", + "import_filepath", "modules_from_path", "recursive_bases_from_class", diff --git a/openpype/pipeline/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py similarity index 100% rename from openpype/pipeline/lib/attribute_definitions.py rename to openpype/lib/attribute_definitions.py diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index c2757a4502..eeb08a6294 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -6,7 +6,6 @@ import inspect from uuid import uuid4 from contextlib import contextmanager -from ..lib import UnknownDef from .creator_plugins import ( BaseCreator, Creator, @@ -87,6 +86,8 @@ class AttributeValues: origin_data(dict): Values loaded from host before conversion. """ def __init__(self, attr_defs, values, origin_data=None): + from openpype.lib.attribute_definitions import UnknownDef + if origin_data is None: origin_data = copy.deepcopy(values) self._origin_data = origin_data diff --git a/openpype/pipeline/lib/__init__.py b/openpype/pipeline/lib/__init__.py deleted file mode 100644 index f762c4205d..0000000000 --- a/openpype/pipeline/lib/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from .attribute_definitions import ( - AbtractAttrDef, - - UIDef, - UISeparatorDef, - UILabelDef, - - UnknownDef, - NumberDef, - TextDef, - EnumDef, - BoolDef, - FileDef, -) - - -__all__ = ( - "AbtractAttrDef", - - "UIDef", - "UISeparatorDef", - "UILabelDef", - - "UnknownDef", - "NumberDef", - "TextDef", - "EnumDef", - "BoolDef", - "FileDef", -) diff --git a/openpype/widgets/attribute_defs/widgets.py b/openpype/widgets/attribute_defs/widgets.py index a6f1b8d6c9..23f025967d 100644 --- a/openpype/widgets/attribute_defs/widgets.py +++ b/openpype/widgets/attribute_defs/widgets.py @@ -2,7 +2,7 @@ import uuid from Qt import QtWidgets, QtCore -from openpype.pipeline.lib import ( +from openpype.lib.attribute_definitions import ( AbtractAttrDef, UnknownDef, NumberDef, From 534ef55e221f0fde90ad57e0c321dd277798c604 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 22 Mar 2022 18:50:26 +0100 Subject: [PATCH 181/854] fix attribute definitions import --- openpype/pipeline/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index d44fbad33e..6ed307dbc7 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -3,8 +3,6 @@ from .constants import ( HOST_WORKFILE_EXTENSIONS, ) -from .lib import attribute_definitions - from .create import ( BaseCreator, Creator, From e86dc1acd77b841d36486a594862473e6aaf76a8 Mon Sep 17 00:00:00 2001 From: Pype Club Date: Tue, 22 Mar 2022 19:57:02 +0100 Subject: [PATCH 182/854] OP-2766 - refactored new creator --- .../photoshop/plugins/create/create_image.py | 79 ++++++++----------- 1 file changed, 33 insertions(+), 46 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index bc0fa6a051..cd7e219bd0 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -3,8 +3,7 @@ from openpype.hosts.photoshop import api from openpype.pipeline import ( Creator, CreatedInstance, - lib, - CreatorError + lib ) @@ -30,65 +29,53 @@ class ImageCreator(Creator): ) self._add_instance_to_context(instance) - def create(self, subset_name, data, pre_create_data): - groups = [] - layers = [] - create_group = False + def create(self, subset_name_from_ui, data, pre_create_data): + groups_to_create = [] + top_layers_to_wrap = [] + create_empty_group = False stub = api.stub() # only after PS is up - multiple_instances = pre_create_data.get("create_multiple") - selection = stub.get_selected_layers() + top_level_selected_items = stub.get_selected_layers() if pre_create_data.get("use_selection"): - if len(selection) > 1: - if multiple_instances: - for item in selection: - if item.group: - groups.append(item) - else: - layers.append(item) + only_single_item_selected = len(top_level_selected_items) == 1 + for selected_item in top_level_selected_items: + if only_single_item_selected or pre_create_data.get("create_multiple"): + if selected_item.group: + groups_to_create.append(selected_item) + else: + top_layers_to_wrap.append(selected_item) else: - group = stub.group_selected_layers(subset_name) - groups.append(group) - elif len(selection) == 1: - # One selected item. Use group if its a LayerSet (group), else - # create a new group. - selected_item = selection[0] - if selected_item.group: - groups.append(selected_item) - else: - layers.append(selected_item) - elif len(selection) == 0: - # No selection creates an empty group. - create_group = True - else: - group = stub.create_group(subset_name) - groups.append(group) + group = stub.group_selected_layers(subset_name_from_ui) + groups_to_create.append(group) - if create_group: - group = stub.create_group(subset_name) - groups.append(group) + if not groups_to_create and not top_layers_to_wrap: + group = stub.create_group(subset_name_from_ui) + groups_to_create.append(group) - for layer in layers: + # wrap each top level layer into separate new group + for layer in top_layers_to_wrap: stub.select_layers([layer]) group = stub.group_selected_layers(layer.name) - groups.append(group) + groups_to_create.append(group) - for group in groups: - long_names = [] - group.name = self._clean_highlights(stub, group.name) + creating_multiple_groups = len(groups_to_create) > 1 + for group in groups_to_create: + subset_name = subset_name_from_ui # reset to name from creator UI + layer_names_in_hierarchy = [] + created_group_name = self._clean_highlights(stub, group.name) - if len(groups) > 1: + if creating_multiple_groups: + # concatenate with layer name to differentiate subsets subset_name += group.name.title().replace(" ", "") if group.long_name: for directory in group.long_name[::-1]: name = self._clean_highlights(stub, directory) - long_names.append(name) + layer_names_in_hierarchy.append(name) data.update({"subset": subset_name}) - data.update({"layer": group}) data.update({"members": [str(group.id)]}) - data.update({"long_name": "_".join(long_names)}) + data.update({"long_name": "_".join(layer_names_in_hierarchy)}) new_instance = CreatedInstance(self.family, subset_name, data, self) @@ -97,8 +84,8 @@ class ImageCreator(Creator): new_instance.data_to_store()) self._add_instance_to_context(new_instance) # reusing existing group, need to rename afterwards - if not create_group: - stub.rename_layer(group.id, stub.PUBLISH_ICON + group.name) + if not create_empty_group: + stub.rename_layer(group.id, stub.PUBLISH_ICON + created_group_name) def update_instances(self, update_list): self.log.debug("update_list:: {}".format(update_list)) @@ -120,7 +107,7 @@ class ImageCreator(Creator): def get_pre_create_attr_defs(self): output = [ - lib.BoolDef("use_selection", default=True, label="Use selection"), + lib.BoolDef("use_selection", default=True, label="Create only for selected"), lib.BoolDef("create_multiple", default=True, label="Create separate instance for each selected") From 0990163a0b487f3e32c5563c7f742e29ca26d0bd Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Wed, 23 Mar 2022 18:30:34 +0900 Subject: [PATCH 183/854] fix removed module from multiverse usd extractor --- .../hosts/maya/plugins/publish/extract_multiverse_usd.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index fd46f87684..d45ceb1932 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -1,10 +1,10 @@ import os -import avalon.maya -import openpype.api - from maya import cmds +import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection + class ExtractMultiverseUsd(openpype.api.Extractor): """Extractor for USD by Multiverse.""" @@ -144,7 +144,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): # Perform extraction self.log.info("Performing extraction ...") - with avalon.maya.maintained_selection(): + with maintained_selection(): members = instance.data("setMembers") members = cmds.ls(members, dag=True, From 9be8885bc3845d3fd5a4aed6b9558a3758e38a8b Mon Sep 17 00:00:00 2001 From: Pype Club Date: Wed, 23 Mar 2022 10:47:50 +0100 Subject: [PATCH 184/854] OP-2766 - added support for new publisher NP already collected instances, need to only add layer information --- .../plugins/publish/collect_instances.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index d506b9a5bf..1b30fb053a 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -1,3 +1,4 @@ +import pprint import pyblish.api from openpype.hosts.photoshop import api as photoshop @@ -21,9 +22,10 @@ class CollectInstances(pyblish.api.ContextPlugin): } def process(self, context): - if context.data.get("newPublishing"): - self.log.debug("Not applicable for New Publisher, skip") - return + instance_by_layer_id = {} + for instance in context: + if instance.data["family"] == "image" and instance.data.get("members"): + instance_by_layer_id[str(instance.data["members"][0])] = instance stub = photoshop.stub() layers = stub.get_layers() @@ -40,13 +42,10 @@ class CollectInstances(pyblish.api.ContextPlugin): if "container" in layer_data["id"]: continue - # child_layers = [*layer.Layers] - # self.log.debug("child_layers {}".format(child_layers)) - # if not child_layers: - # self.log.info("%s skipped, it was empty." % layer.Name) - # continue + instance = instance_by_layer_id.get(str(layer.id)) + if instance is None: + instance = context.create_instance(layer_data["subset"]) - instance = context.create_instance(layer_data["subset"]) instance.data["layer"] = layer instance.data.update(layer_data) instance.data["families"] = self.families_mapping[ @@ -58,7 +57,7 @@ class CollectInstances(pyblish.api.ContextPlugin): # Produce diagnostic message for any graphical # user interface interested in visualising it. self.log.info("Found: \"%s\" " % instance.data["name"]) - self.log.info("instance: {} ".format(instance.data)) + self.log.info("instance: {} ".format(pprint.pformat(instance.data, indent=4))) if len(instance_names) != len(set(instance_names)): self.log.warning("Duplicate instances found. " + From 11a9ad18738ffa9ff036722f699d715663d3fb53 Mon Sep 17 00:00:00 2001 From: Pype Club Date: Wed, 23 Mar 2022 10:53:58 +0100 Subject: [PATCH 185/854] OP-2766 - refactor --- .../plugins/publish/collect_instances.py | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 1b30fb053a..9449662067 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -7,8 +7,8 @@ from openpype.hosts.photoshop import api as photoshop class CollectInstances(pyblish.api.ContextPlugin): """Gather instances by LayerSet and file metadata - This collector takes into account assets that are associated with - an LayerSet and marked with a unique identifier; + Collects publishable instances from file metadata or enhance + already collected by creator (family == "image"). Identifier: id (str): "pyblish.avalon.instance" @@ -24,40 +24,44 @@ class CollectInstances(pyblish.api.ContextPlugin): def process(self, context): instance_by_layer_id = {} for instance in context: - if instance.data["family"] == "image" and instance.data.get("members"): - instance_by_layer_id[str(instance.data["members"][0])] = instance + if ( + instance.data["family"] == "image" and + instance.data.get("members")): + layer_id = str(instance.data["members"][0]) + instance_by_layer_id[layer_id] = instance stub = photoshop.stub() - layers = stub.get_layers() + layer_items = stub.get_layers() layers_meta = stub.get_layers_metadata() instance_names = [] - for layer in layers: - layer_data = stub.read(layer, layers_meta) + for layer_item in layer_items: + layer_instance_data = stub.read(layer_item, layers_meta) # Skip layers without metadata. - if layer_data is None: + if layer_instance_data is None: continue # Skip containers. - if "container" in layer_data["id"]: + if "container" in layer_instance_data["id"]: continue - instance = instance_by_layer_id.get(str(layer.id)) + instance = instance_by_layer_id.get(str(layer_item.id)) if instance is None: - instance = context.create_instance(layer_data["subset"]) + instance = context.create_instance(layer_instance_data["subset"]) - instance.data["layer"] = layer - instance.data.update(layer_data) + instance.data["layer"] = layer_item + instance.data.update(layer_instance_data) instance.data["families"] = self.families_mapping[ - layer_data["family"] + layer_instance_data["family"] ] - instance.data["publish"] = layer.visible - instance_names.append(layer_data["subset"]) + instance.data["publish"] = layer_item.visible + instance_names.append(layer_instance_data["subset"]) # Produce diagnostic message for any graphical # user interface interested in visualising it. self.log.info("Found: \"%s\" " % instance.data["name"]) - self.log.info("instance: {} ".format(pprint.pformat(instance.data, indent=4))) + self.log.info("instance: {} ".format( + pprint.pformat(instance.data, indent=4))) if len(instance_names) != len(set(instance_names)): self.log.warning("Duplicate instances found. " + From 755a6dabfd1ba5d1bb80000ab69140d1a54d9c3d Mon Sep 17 00:00:00 2001 From: Pype Club Date: Wed, 23 Mar 2022 11:47:36 +0100 Subject: [PATCH 186/854] OP-2766 - added NP validators for subset names and uniqueness --- .../plugins/publish/help/validate_naming.xml | 21 +++++++++ .../publish/help/validate_unique_subsets.xml | 14 ++++++ .../plugins/publish/validate_naming.py | 47 +++++++++++-------- .../publish/validate_unique_subsets.py | 9 +++- 4 files changed, 71 insertions(+), 20 deletions(-) create mode 100644 openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml create mode 100644 openpype/hosts/photoshop/plugins/publish/help/validate_unique_subsets.xml diff --git a/openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml b/openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml new file mode 100644 index 0000000000..5a1e266748 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/help/validate_naming.xml @@ -0,0 +1,21 @@ + + + +Subset name + +## Invalid subset or layer name + +Subset or layer name cannot contain specific characters (spaces etc) which could cause issue when subset name is used in a published file name. + {msg} + +### How to repair? + +You can fix this with "repair" button on the right. + + +### __Detailed Info__ (optional) + +Not all characters are available in a file names on all OS. Wrong characters could be configured in Settings. + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/plugins/publish/help/validate_unique_subsets.xml b/openpype/hosts/photoshop/plugins/publish/help/validate_unique_subsets.xml new file mode 100644 index 0000000000..4b47973193 --- /dev/null +++ b/openpype/hosts/photoshop/plugins/publish/help/validate_unique_subsets.xml @@ -0,0 +1,14 @@ + + + +Subset not unique + +## Non unique subset name found + + Non unique subset names: '{non_unique}' +### How to repair? + +Remove offending instance, rename it to have unique name. Maybe layer name wasn't used for multiple instances? + + + \ No newline at end of file diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index b40e44d016..c0ca4cfb69 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -2,6 +2,7 @@ import re import pyblish.api import openpype.api +from openpype.pipeline import PublishXmlValidationError from openpype.hosts.photoshop import api as photoshop @@ -22,32 +23,33 @@ class ValidateNamingRepair(pyblish.api.Action): failed.append(result["instance"]) invalid_chars, replace_char = plugin.get_replace_chars() - self.log.info("{} --- {}".format(invalid_chars, replace_char)) + self.log.debug("{} --- {}".format(invalid_chars, replace_char)) # Apply pyblish.logic to get the instances for the plug-in instances = pyblish.api.instances_by_plugin(failed, plugin) stub = photoshop.stub() for instance in instances: - self.log.info("validate_naming instance {}".format(instance)) - metadata = stub.read(instance[0]) - self.log.info("metadata instance {}".format(metadata)) - layer_name = None - if metadata.get("uuid"): - layer_data = stub.get_layer(metadata["uuid"]) - self.log.info("layer_data {}".format(layer_data)) - if layer_data: - layer_name = re.sub(invalid_chars, - replace_char, - layer_data.name) + self.log.debug("validate_naming instance {}".format(instance)) + current_layer_state = stub.get_layer(instance.data["layer"].id) + self.log.debug("current_layer_state instance {}".format(current_layer_state)) - stub.rename_layer(instance.data["uuid"], layer_name) + layer_meta = stub.read(current_layer_state) + instance_id = layer_meta.get("instance_id") or layer_meta.get("uuid") + if not instance_id: + self.log.warning("Unable to repair, cannot find layer") + continue + + layer_name = re.sub(invalid_chars, + replace_char, + current_layer_state.name) + + stub.rename_layer(current_layer_state.id, layer_name) subset_name = re.sub(invalid_chars, replace_char, - instance.data["name"]) + instance.data["subset"]) - instance[0].Name = layer_name or subset_name - metadata["subset"] = subset_name - stub.imprint(instance[0], metadata) + layer_meta["subset"] = subset_name + stub.imprint(instance_id, layer_meta) return True @@ -72,11 +74,18 @@ class ValidateNaming(pyblish.api.InstancePlugin): help_msg = ' Use Repair action (A) in Pyblish to fix it.' msg = "Name \"{}\" is not allowed.{}".format(instance.data["name"], help_msg) - assert not re.search(self.invalid_chars, instance.data["name"]), msg + + formatting_data = {"msg": msg} + if re.search(self.invalid_chars, instance.data["name"]): + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) msg = "Subset \"{}\" is not allowed.{}".format(instance.data["subset"], help_msg) - assert not re.search(self.invalid_chars, instance.data["subset"]), msg + formatting_data = {"msg": msg} + if re.search(self.invalid_chars, instance.data["subset"]): + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) @classmethod def get_replace_chars(cls): diff --git a/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py b/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py index 40abfb1bbd..01f2323157 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_unique_subsets.py @@ -1,6 +1,7 @@ import collections import pyblish.api import openpype.api +from openpype.pipeline import PublishXmlValidationError class ValidateSubsetUniqueness(pyblish.api.ContextPlugin): @@ -27,4 +28,10 @@ class ValidateSubsetUniqueness(pyblish.api.ContextPlugin): if count > 1] msg = ("Instance subset names {} are not unique. ".format(non_unique) + "Remove duplicates via SubsetManager.") - assert not non_unique, msg + formatting_data = { + "non_unique": ",".join(non_unique) + } + + if non_unique: + raise PublishXmlValidationError(self, msg, + formatting_data=formatting_data) From 85b49da44e14ec82a93e43bd4f8f1571b403627a Mon Sep 17 00:00:00 2001 From: Pype Club Date: Wed, 23 Mar 2022 12:11:48 +0100 Subject: [PATCH 187/854] OP-2766 - skip non active instances --- .../plugins/publish/collect_instances.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 9449662067..52a8310594 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -35,27 +35,30 @@ class CollectInstances(pyblish.api.ContextPlugin): layers_meta = stub.get_layers_metadata() instance_names = [] for layer_item in layer_items: - layer_instance_data = stub.read(layer_item, layers_meta) + layer_meta_data = stub.read(layer_item, layers_meta) # Skip layers without metadata. - if layer_instance_data is None: + if layer_meta_data is None: continue # Skip containers. - if "container" in layer_instance_data["id"]: + if "container" in layer_meta_data["id"]: + continue + + if not layer_meta_data.get("active", True): # active might not be in legacy meta continue instance = instance_by_layer_id.get(str(layer_item.id)) if instance is None: - instance = context.create_instance(layer_instance_data["subset"]) + instance = context.create_instance(layer_meta_data["subset"]) instance.data["layer"] = layer_item - instance.data.update(layer_instance_data) + instance.data.update(layer_meta_data) instance.data["families"] = self.families_mapping[ - layer_instance_data["family"] + layer_meta_data["family"] ] instance.data["publish"] = layer_item.visible - instance_names.append(layer_instance_data["subset"]) + instance_names.append(layer_meta_data["subset"]) # Produce diagnostic message for any graphical # user interface interested in visualising it. From 4261d224228c3c3ecf4d8d36252ad594d0a66fc1 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 23 Mar 2022 12:37:13 +0100 Subject: [PATCH 188/854] Fix support for Maya 2018 Older versions of Maya do not allow `set` type to be passed to Maya commands and will result in e.g. "RuntimeError: # Syntax error: unexpected end ( at position 4 while parsing" --- openpype/hosts/maya/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/api/lib.py b/openpype/hosts/maya/api/lib.py index 376c033d46..92fc5133a9 100644 --- a/openpype/hosts/maya/api/lib.py +++ b/openpype/hosts/maya/api/lib.py @@ -1511,7 +1511,7 @@ def get_container_members(container): members = cmds.sets(container, query=True) or [] members = cmds.ls(members, long=True, objectsOnly=True) or [] - members = set(members) + all_members = set(members) # Include any referenced nodes from any reference in the container # This is required since we've removed adding ALL nodes of a reference @@ -1530,9 +1530,9 @@ def get_container_members(container): reference_members = cmds.ls(reference_members, long=True, objectsOnly=True) - members.update(reference_members) + all_members.update(reference_members) - return members + return list(all_members) # region LOOKDEV From d211471ea099f53d8349f33d7e20ad29da7f178c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 13:49:50 +0100 Subject: [PATCH 189/854] OP-2766 - Hound --- openpype/hosts/photoshop/api/pipeline.py | 4 ++-- .../photoshop/plugins/create/create_image.py | 15 +++++++++------ .../plugins/publish/collect_instances.py | 3 ++- .../photoshop/plugins/publish/validate_naming.py | 5 +++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 1b471ef1d3..db40e456db 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -151,7 +151,7 @@ def list_instances(): layers_meta = stub.get_layers_metadata() if layers_meta: for instance in layers_meta: - if instance.get("id") == "pyblish.avalon.instance": # TODO only this way? + if instance.get("id") == "pyblish.avalon.instance": instances.append(instance) return instances @@ -266,4 +266,4 @@ def get_context_title(): project_name = avalon.api.Session["AVALON_PROJECT"] asset_name = avalon.api.Session["AVALON_ASSET"] task_name = avalon.api.Session["AVALON_TASK"] - return "{}/{}/{}".format(project_name, asset_name, task_name) \ No newline at end of file + return "{}/{}/{}".format(project_name, asset_name, task_name) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index cd7e219bd0..e332cfd9c2 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -39,7 +39,9 @@ class ImageCreator(Creator): if pre_create_data.get("use_selection"): only_single_item_selected = len(top_level_selected_items) == 1 for selected_item in top_level_selected_items: - if only_single_item_selected or pre_create_data.get("create_multiple"): + if ( + only_single_item_selected or + pre_create_data.get("create_multiple")): if selected_item.group: groups_to_create.append(selected_item) else: @@ -85,13 +87,15 @@ class ImageCreator(Creator): self._add_instance_to_context(new_instance) # reusing existing group, need to rename afterwards if not create_empty_group: - stub.rename_layer(group.id, stub.PUBLISH_ICON + created_group_name) + stub.rename_layer(group.id, + stub.PUBLISH_ICON + created_group_name) def update_instances(self, update_list): self.log.debug("update_list:: {}".format(update_list)) for created_inst, _changes in update_list: if created_inst.get("layer"): - created_inst.pop("layer") # not storing PSItem layer to metadata + # not storing PSItem layer to metadata + created_inst.pop("layer") api.stub().imprint(created_inst.get("instance_id"), created_inst.data_to_store()) @@ -107,7 +111,8 @@ class ImageCreator(Creator): def get_pre_create_attr_defs(self): output = [ - lib.BoolDef("use_selection", default=True, label="Create only for selected"), + lib.BoolDef("use_selection", default=True, + label="Create only for selected"), lib.BoolDef("create_multiple", default=True, label="Create separate instance for each selected") @@ -138,5 +143,3 @@ class ImageCreator(Creator): def _clean_highlights(self, stub, item): return item.replace(stub.PUBLISH_ICON, '').replace(stub.LOADED_ICON, '') - - diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index 52a8310594..a7bb2d40c7 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -45,7 +45,8 @@ class CollectInstances(pyblish.api.ContextPlugin): if "container" in layer_meta_data["id"]: continue - if not layer_meta_data.get("active", True): # active might not be in legacy meta + # active might not be in legacy meta + if not layer_meta_data.get("active", True): continue instance = instance_by_layer_id.get(str(layer_item.id)) diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index c0ca4cfb69..bcae24108c 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -31,10 +31,11 @@ class ValidateNamingRepair(pyblish.api.Action): for instance in instances: self.log.debug("validate_naming instance {}".format(instance)) current_layer_state = stub.get_layer(instance.data["layer"].id) - self.log.debug("current_layer_state instance {}".format(current_layer_state)) + self.log.debug("current_layer{}".format(current_layer_state)) layer_meta = stub.read(current_layer_state) - instance_id = layer_meta.get("instance_id") or layer_meta.get("uuid") + instance_id = (layer_meta.get("instance_id") or + layer_meta.get("uuid")) if not instance_id: self.log.warning("Unable to repair, cannot find layer") continue From 49d26ef9593271a6b36dfbdd353f7bed017478ad Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 14:11:39 +0100 Subject: [PATCH 190/854] OP-2766 - changed imports after refactor of attribute definitions --- openpype/hosts/photoshop/plugins/create/create_image.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index e332cfd9c2..12898bb7f4 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,9 +1,9 @@ from avalon import api as avalon_api from openpype.hosts.photoshop import api +from openpype.lib import BoolDef from openpype.pipeline import ( Creator, - CreatedInstance, - lib + CreatedInstance ) @@ -111,9 +111,9 @@ class ImageCreator(Creator): def get_pre_create_attr_defs(self): output = [ - lib.BoolDef("use_selection", default=True, + BoolDef("use_selection", default=True, label="Create only for selected"), - lib.BoolDef("create_multiple", + BoolDef("create_multiple", default=True, label="Create separate instance for each selected") ] From 7273fd44daa2ebb266c9f95f9beb0cbfad53258a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 14:14:35 +0100 Subject: [PATCH 191/854] OP-2765 - changed imports after refactor of attribute definitions --- .../hosts/aftereffects/plugins/create/create_render.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 1eff992fe0..826d438fa3 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,11 +1,11 @@ from avalon import api as avalon_api from openpype import resources +from openpype.lib import BoolDef, UISeparatorDef from openpype.hosts.aftereffects import api from openpype.pipeline import ( Creator, CreatedInstance, - lib, CreatorError ) @@ -86,13 +86,13 @@ class RenderCreator(Creator): ] def get_instance_attr_defs(self): - return [lib.BoolDef("farm", label="Render on farm")] + return [BoolDef("farm", label="Render on farm")] def get_pre_create_attr_defs(self): output = [ - lib.BoolDef("use_selection", default=True, label="Use selection"), - lib.UISeparatorDef(), - lib.BoolDef("farm", label="Render on farm") + BoolDef("use_selection", default=True, label="Use selection"), + UISeparatorDef(), + BoolDef("farm", label="Render on farm") ] return output From c829cc19ac675bbc9752980b805b69964cccb6b7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 14:15:16 +0100 Subject: [PATCH 192/854] OP-2765 - changed default variant --- openpype/hosts/aftereffects/plugins/create/create_render.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 826d438fa3..c43ada84b5 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -79,11 +79,7 @@ class RenderCreator(Creator): self._add_instance_to_context(new_instance) def get_default_variants(self): - return [ - "myVariant", - "variantTwo", - "different_variant" - ] + return ["Main"] def get_instance_attr_defs(self): return [BoolDef("farm", label="Render on farm")] From 1534c878d2e57dad50823d52d434feb2cecd3f10 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 14:33:49 +0100 Subject: [PATCH 193/854] OP-2766 - Hound --- openpype/hosts/photoshop/plugins/create/create_image.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index 12898bb7f4..c2fe8b6c78 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -112,10 +112,10 @@ class ImageCreator(Creator): def get_pre_create_attr_defs(self): output = [ BoolDef("use_selection", default=True, - label="Create only for selected"), + label="Create only for selected"), BoolDef("create_multiple", - default=True, - label="Create separate instance for each selected") + default=True, + label="Create separate instance for each selected") ] return output From 8956e70665926ee2c66fd3735bcf554d0f9137a4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 14:33:53 +0100 Subject: [PATCH 194/854] added discover login into openpype pipeline --- openpype/pipeline/plugin_discover.py | 208 +++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 openpype/pipeline/plugin_discover.py diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py new file mode 100644 index 0000000000..305a8bab67 --- /dev/null +++ b/openpype/pipeline/plugin_discover.py @@ -0,0 +1,208 @@ +import os +import inspect +from openpype.lib.python_module_tools import ( + modules_from_path, + classes_from_module, +) + + +class DiscoverResult: + """Hold result of publish plugins discovery. + + Stores discovered plugins duplicated plugins and file paths which + crashed on execution of file. + """ + + def __init__(self): + self.plugins = [] + self.crashed_file_paths = {} + self.duplicated_plugins = [] + self.abstract_plugins = [] + self.ignored_plugins = set() + + def __iter__(self): + for plugin in self.plugins: + yield plugin + + def __getitem__(self, item): + return self.plugins[item] + + def __setitem__(self, item, value): + self.plugins[item] = value + + +class PluginDiscoverContext(object): + """Store and discover registered types nad registered paths to types. + + Keeps in memory all registered types and their paths. Paths are dynamically + loaded on discover so different discover calls won't return the same + class objects even if were loaded from same file. + """ + + def __init__(self): + self._registered_plugins = {} + self._registered_plugin_paths = {} + self._last_discovered_plugins = {} + + def get_last_discovered_plugins(self, superclass): + return self._last_discovered_plugins.get(superclass) + + def discover( + self, superclass, allow_duplicates=True, ignore_classes=None + ): + """Find and return subclasses of `superclass` + + Args: + superclass (type): Class which determines discovered subclasses. + allow_duplicates (bool): Validate class name duplications. + ignore_classes (list): List of classes that will be ignored + and not added to result. + + Returns: + DiscoverResult: Object holding succesfully discovered plugins, + ignored plugins, plugins with missing abstract implementation + and duplicated plugin. + """ + + if not ignore_classes: + ignore_classes = [] + + result = DiscoverResult() + plugin_names = set() + registered_classes = self._registered_plugins.get(superclass) or [] + registered_paths = self._registered_plugin_paths.get(superclass) or [] + for cls in registered_classes: + if cls is superclass or cls in ignore_classes: + result.ignored_plugins.add(cls) + continue + + if inspect.isabstract(cls): + result.abstract_plugins.append(cls) + continue + + class_name = cls.__name__ + if class_name in plugin_names: + result.duplicated_plugins.append(cls) + continue + plugin_names.add(class_name) + result.plugins.append(cls) + + # Include plug-ins from registered paths + for path in registered_paths: + modules, crashed = modules_from_path(path) + for item in crashed: + filepath, exc_info = item + result.crashed_file_paths[filepath] = exc_info + + for item in modules: + filepath, module = item + for cls in classes_from_module(superclass, module): + if cls is superclass or cls in ignore_classes: + result.ignored_plugins.add(cls) + continue + + if inspect.isabstract(cls): + result.abstract_plugins.append(cls) + continue + + if not allow_duplicates: + class_name = cls.__name__ + if class_name in plugin_names: + result.duplicated_plugins.append(cls) + continue + plugin_names.add(class_name) + + result.plugins.append(cls) + + self._last_discovered_plugins[superclass] = list( + result.plugins + ) + return result + + def register_plugin(self, superclass, cls): + """Register an individual `obj` of type `superclass` + + Arguments: + superclass (type): Superclass of plug-in + cls (object): Subclass of `superclass` + """ + + if superclass not in self._registered_plugins: + self._registered_plugins[superclass] = list() + + if cls not in self._registered_plugins[superclass]: + self._registered_plugins[superclass].append(cls) + + def register_plugin_path(self, superclass, path): + """Register a directory of one or more plug-ins + + Arguments: + superclass (type): Superclass of plug-ins to look for during discovery + path (str): Absolute path to directory in which to discover plug-ins + + """ + + if superclass not in self._registered_plugin_paths: + self._registered_plugin_paths[superclass] = list() + + path = os.path.normpath(path) + if path not in self._registered_plugin_paths[superclass]: + self._registered_plugin_paths[superclass].append(path) + + def registered_plugin_paths(self): + """Return all currently registered plug-in paths""" + # Prohibit editing in-place + duplicate = { + superclass: paths[:] + for superclass, paths in self._registered_plugin_paths.items() + } + return duplicate + + def deregister_plugin(self, superclass, plugin): + """Oppsite of `register_plugin()`""" + if superclass in self._registered_plugins: + self._registered_plugins[superclass].remove(plugin) + + def deregister_plugin_path(self, superclass, path): + """Oppsite of `register_plugin_path()`""" + self._registered_plugin_paths[superclass].remove(path) + + +class GlobalDiscover: + _context = None + + @classmethod + def get_context(cls): + if cls._context is None: + cls._context = PluginDiscoverContext() + return cls._context + + +def discover(superclass, allow_duplicates=True): + context = GlobalDiscover.get_context() + return context.discover(superclass, allow_duplicates) + + +def get_last_discovered_plugins(superclass): + context = GlobalDiscover.get_context() + return context.get_last_discovered_plugins(superclass) + + +def register_plugin(superclass, cls): + context = GlobalDiscover.get_context() + context.register_plugin(superclass, cls) + + +def register_plugin_path(superclass, path): + context = GlobalDiscover.get_context() + context.register_plugin_path(superclass, path) + + +def deregister_plugin(superclass, cls): + context = GlobalDiscover.get_context() + context.deregister_plugin(superclass, cls) + + +def deregister_plugin_path(superclass, path): + context = GlobalDiscover.get_context() + context.deregister_plugin_path(superclass, path) From 239f70a8f76df1536b19a0f07d6c070b0cf74b3d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 14:51:04 +0100 Subject: [PATCH 195/854] added better check of classes and subclasses --- openpype/lib/python_module_tools.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index f62c848e4a..4ef31b5579 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -5,8 +5,9 @@ import importlib import inspect import logging +import six + log = logging.getLogger(__name__) -PY3 = sys.version_info[0] == 3 def import_filepath(filepath, module_name=None): @@ -28,7 +29,7 @@ def import_filepath(filepath, module_name=None): # Prepare module object where content of file will be parsed module = types.ModuleType(module_name) - if PY3: + if six.PY3: # Use loader so module has full specs module_loader = importlib.machinery.SourceFileLoader( module_name, filepath @@ -38,7 +39,7 @@ def import_filepath(filepath, module_name=None): # Execute module code and store content to module with open(filepath) as _stream: # Execute content and store it to module object - exec(_stream.read(), module.__dict__) + six.exec_(_stream.read(), module.__dict__) module.__file__ = filepath return module @@ -129,20 +130,14 @@ def classes_from_module(superclass, module): for name in dir(module): # It could be anything at this point obj = getattr(module, name) - if not inspect.isclass(obj): - continue - - # These are subclassed from nothing, not even `object` - if not len(obj.__bases__) > 0: + if not inspect.isclass(obj) or obj is superclass: continue # Use string comparison rather than `issubclass` # in order to support reloading of this module. - bases = recursive_bases_from_class(obj) - if not any(base.__name__ == superclass.__name__ for base in bases): - continue + if issubclass(obj, superclass): + classes.append(obj) - classes.append(obj) return classes @@ -228,7 +223,7 @@ def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): dst_module_name(str): Parent module name under which can be loaded module added. """ - if PY3: + if six.PY3: module = _import_module_from_dirpath_py3( dirpath, folder_name, dst_module_name ) From dc9852a0d6fde1c40b33bdb1508156738affae02 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 14:53:14 +0100 Subject: [PATCH 196/854] use new register and discover functions for load, thumbnail and actions --- openpype/pipeline/actions.py | 43 ++++++++++++------------------- openpype/pipeline/load/plugins.py | 40 ++++++++++++++-------------- openpype/pipeline/thumbnail.py | 17 ++++++------ 3 files changed, 44 insertions(+), 56 deletions(-) diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py index a045c92aa7..6cb2e9a5a4 100644 --- a/openpype/pipeline/actions.py +++ b/openpype/pipeline/actions.py @@ -1,4 +1,11 @@ import logging +from openpype.pipeline.plugin_discover import ( + discover, + register_plugin, + register_plugin_path, + deregister_plugin, + deregister_plugin_path +) class LauncherAction(object): @@ -90,57 +97,39 @@ class InventoryAction(object): # Launcher action def discover_launcher_actions(): - import avalon.api - - return avalon.api.discover(LauncherAction) + return discover(LauncherAction).plugins def register_launcher_action(plugin): - import avalon.api - - return avalon.api.register_plugin(LauncherAction, plugin) + return register_plugin(LauncherAction, plugin) def register_launcher_action_path(path): - import avalon.api - - return avalon.api.register_plugin_path(LauncherAction, path) + return register_plugin_path(LauncherAction, path) # Inventory action def discover_inventory_actions(): - import avalon.api - - actions = avalon.api.discover(InventoryAction) + actions = discover(InventoryAction).plugins filtered_actions = [] for action in actions: if action is not InventoryAction: - print("DISCOVERED", action) filtered_actions.append(action) - else: - print("GOT SOURCE") + return filtered_actions def register_inventory_action(plugin): - import avalon.api - - return avalon.api.register_plugin(InventoryAction, plugin) + return register_plugin(InventoryAction, plugin) def deregister_inventory_action(plugin): - import avalon.api - - avalon.api.deregister_plugin(InventoryAction, plugin) + deregister_plugin(InventoryAction, plugin) def register_inventory_action_path(path): - import avalon.api - - return avalon.api.register_plugin_path(InventoryAction, path) + return register_plugin_path(InventoryAction, path) def deregister_inventory_action_path(path): - import avalon.api - - return avalon.api.deregister_plugin_path(InventoryAction, path) + return deregister_plugin_path(InventoryAction, path) diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index 9b2b6bb084..fb5d1df9b5 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -1,5 +1,13 @@ import logging +from openpype.lib import set_plugin_attributes_from_settings +from openpype.pipeline.plugin_discover import ( + discover, + register_plugin, + register_plugin_path, + deregister_plugin, + deregister_plugin_path +) from .utils import get_representation_path_from_context @@ -102,30 +110,22 @@ class SubsetLoaderPlugin(LoaderPlugin): def discover_loader_plugins(): - import avalon.api - - return avalon.api.discover(LoaderPlugin) + plugins = discover(LoaderPlugin).plugins + set_plugin_attributes_from_settings(plugins, LoaderPlugin) + return plugins def register_loader_plugin(plugin): - import avalon.api - - return avalon.api.register_plugin(LoaderPlugin, plugin) - - -def deregister_loader_plugin_path(path): - import avalon.api - - avalon.api.deregister_plugin_path(LoaderPlugin, path) - - -def register_loader_plugin_path(path): - import avalon.api - - return avalon.api.register_plugin_path(LoaderPlugin, path) + return register_plugin(LoaderPlugin, plugin) def deregister_loader_plugin(plugin): - import avalon.api + deregister_plugin(LoaderPlugin, plugin) - avalon.api.deregister_plugin(LoaderPlugin, plugin) + +def deregister_loader_plugin_path(path): + deregister_plugin_path(LoaderPlugin, path) + + +def register_loader_plugin_path(path): + return register_plugin_path(LoaderPlugin, path) diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index 12bab83be6..47452b21e7 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -2,6 +2,11 @@ import os import copy import logging +from .plugin_discover import ( + discover, + register_plugin, + register_plugin_path, +) log = logging.getLogger(__name__) @@ -126,21 +131,15 @@ class BinaryThumbnail(ThumbnailResolver): # Thumbnail resolvers def discover_thumbnail_resolvers(): - import avalon.api - - return avalon.api.discover(ThumbnailResolver) + return discover(ThumbnailResolver).plugins def register_thumbnail_resolver(plugin): - import avalon.api - - return avalon.api.register_plugin(ThumbnailResolver, plugin) + register_plugin(ThumbnailResolver, plugin) def register_thumbnail_resolver_path(path): - import avalon.api - - return avalon.api.register_plugin_path(ThumbnailResolver, path) + register_plugin_path(ThumbnailResolver, path) register_thumbnail_resolver(TemplateResolver) From 748f84b600ac4a362edf08d622d74fc3db2bc05d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:02:07 +0100 Subject: [PATCH 197/854] fix usage of collapsed value in CollapsibleWrapper --- openpype/tools/settings/settings/wrapper_widgets.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/wrapper_widgets.py b/openpype/tools/settings/settings/wrapper_widgets.py index 7370fcf945..6b2258157c 100644 --- a/openpype/tools/settings/settings/wrapper_widgets.py +++ b/openpype/tools/settings/settings/wrapper_widgets.py @@ -92,7 +92,8 @@ class CollapsibleWrapper(WrapperWidget): self.content_layout = content_layout if self.collapsible: - body_widget.toggle_content(self.collapsed) + if not self.entity.collapsed: + body_widget.toggle_content() else: body_widget.hide_toolbox(hide_content=False) From 645531f4025f7606c90f92f6ce846876e9e05c51 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:05:10 +0100 Subject: [PATCH 198/854] creators have their specific functions --- openpype/pipeline/__init__.py | 14 ++++++ openpype/pipeline/create/__init__.py | 16 ++++++- openpype/pipeline/create/creator_plugins.py | 51 ++++++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index d44fbad33e..eaee180619 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -15,6 +15,13 @@ from .create import ( LegacyCreator, legacy_create, + + discover_creator_plugins, + discover_legacy_creator_plugins, + register_creator_plugin, + deregister_creator_plugin, + register_creator_plugin_path, + deregister_creator_plugin_path, ) from .load import ( @@ -81,6 +88,13 @@ __all__ = ( "LegacyCreator", "legacy_create", + "discover_creator_plugins", + "discover_legacy_creator_plugins", + "register_creator_plugin", + "deregister_creator_plugin", + "register_creator_plugin_path", + "deregister_creator_plugin_path", + # --- Load --- "HeroVersionType", "IncompatibleLoaderError", diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index 9571f56b8f..1beeb4267b 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -6,7 +6,14 @@ from .creator_plugins import ( BaseCreator, Creator, - AutoCreator + AutoCreator, + + discover_creator_plugins, + discover_legacy_creator_plugins, + register_creator_plugin, + deregister_creator_plugin, + register_creator_plugin_path, + deregister_creator_plugin_path, ) from .context import ( @@ -29,6 +36,13 @@ __all__ = ( "Creator", "AutoCreator", + "discover_creator_plugins", + "discover_legacy_creator_plugins", + "register_creator_plugin", + "deregister_creator_plugin", + "register_creator_plugin_path", + "deregister_creator_plugin_path", + "CreatedInstance", "CreateContext", diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index 1ac2c420a2..dbeeb94050 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -8,7 +8,19 @@ from abc import ( ) import six -from openpype.lib import get_subset_name_with_asset_doc +from openpype.lib import ( + get_subset_name_with_asset_doc, + set_plugin_attributes_from_settings, +) +from openpype.pipeline.plugin_discover import ( + discover, + register_plugin, + register_plugin_path, + deregister_plugin, + deregister_plugin_path +) + +from .legacy_create import LegacyCreator class CreatorError(Exception): @@ -284,6 +296,43 @@ class AutoCreator(BaseCreator): Can be used e.g. for `workfile`. """ + def remove_instances(self, instances): """Skip removement.""" pass + + +def discover_creator_plugins(): + return discover(BaseCreator).plugins + + +def discover_legacy_creator_plugins(): + plugins = discover(LegacyCreator).plugins + set_plugin_attributes_from_settings(plugins, LegacyCreator) + return plugins + + +def register_creator_plugin(plugin): + if issubclass(plugin, BaseCreator): + register_plugin(BaseCreator, plugin) + + elif issubclass(plugin, LegacyCreator): + register_plugin(LegacyCreator, plugin) + + +def deregister_creator_plugin(plugin): + if issubclass(plugin, BaseCreator): + deregister_plugin(BaseCreator, plugin) + + elif issubclass(plugin, LegacyCreator): + deregister_plugin(LegacyCreator, plugin) + + +def register_creator_plugin_path(path): + register_plugin_path(BaseCreator, path) + register_plugin_path(LegacyCreator, path) + + +def deregister_creator_plugin_path(path): + deregister_plugin_path(BaseCreator, path) + deregister_plugin_path(LegacyCreator, path) From 77e2f6eb8d40315dd84cade6a40828e120f8811b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:11:43 +0100 Subject: [PATCH 199/854] use create register/discover functions in code --- openpype/hosts/aftereffects/api/pipeline.py | 8 ++++---- openpype/hosts/blender/api/pipeline.py | 7 ++++--- openpype/hosts/flame/api/pipeline.py | 8 ++++---- openpype/hosts/fusion/api/pipeline.py | 10 +++++----- openpype/hosts/harmony/api/pipeline.py | 7 ++++--- openpype/hosts/hiero/api/pipeline.py | 8 ++++---- openpype/hosts/houdini/api/pipeline.py | 4 ++-- openpype/hosts/maya/api/pipeline.py | 6 ++++-- openpype/hosts/nuke/api/lib.py | 3 ++- openpype/hosts/nuke/api/pipeline.py | 8 ++++---- .../nuke/plugins/publish/validate_write_legacy.py | 5 ++--- openpype/hosts/photoshop/api/pipeline.py | 7 ++++--- openpype/hosts/resolve/api/pipeline.py | 13 ++++++++----- openpype/hosts/tvpaint/api/pipeline.py | 7 ++++--- openpype/hosts/unreal/api/pipeline.py | 7 ++++--- openpype/lib/avalon_context.py | 4 ++-- openpype/pipeline/create/context.py | 5 +++-- openpype/tools/creator/model.py | 5 ++--- 18 files changed, 66 insertions(+), 56 deletions(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index bb9affc9b6..94bc369856 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -5,15 +5,15 @@ from Qt import QtWidgets from bson.objectid import ObjectId import pyblish.api -import avalon.api from avalon import io from openpype import lib from openpype.api import Logger from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) import openpype.hosts.aftereffects @@ -73,7 +73,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) log.info(PUBLISH_PATH) pyblish.api.register_callback( @@ -86,7 +86,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) - avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) def on_pyblish_instance_toggled(instance, old_value, new_value): diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index 8c580cf214..b9ec2cfea4 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -14,9 +14,10 @@ import avalon.api from avalon import io, schema from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.api import Logger @@ -54,7 +55,7 @@ def install(): pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) - avalon.api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) + register_creator_plugin_path(str(CREATE_PATH)) lib.append_user_scripts() @@ -76,7 +77,7 @@ def uninstall(): pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) deregister_loader_plugin_path(str(LOAD_PATH)) - avalon.api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) + deregister_creator_plugin_path(str(CREATE_PATH)) if not IS_HEADLESS: ops.unregister() diff --git a/openpype/hosts/flame/api/pipeline.py b/openpype/hosts/flame/api/pipeline.py index ca3f38c1bc..da44be1b15 100644 --- a/openpype/hosts/flame/api/pipeline.py +++ b/openpype/hosts/flame/api/pipeline.py @@ -3,14 +3,14 @@ Basic avalon integration """ import os import contextlib -from avalon import api as avalon from pyblish import api as pyblish from openpype.api import Logger from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from .lib import ( @@ -37,7 +37,7 @@ def install(): pyblish.register_host("flame") pyblish.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) log.info("OpenPype Flame plug-ins registred ...") # register callback for switching publishable @@ -52,7 +52,7 @@ def uninstall(): log.info("Deregistering Flame plug-ins..") pyblish.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) - avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/fusion/api/pipeline.py b/openpype/hosts/fusion/api/pipeline.py index c9cd76770a..0867b464d5 100644 --- a/openpype/hosts/fusion/api/pipeline.py +++ b/openpype/hosts/fusion/api/pipeline.py @@ -7,14 +7,14 @@ import logging import contextlib import pyblish.api -import avalon.api from openpype.api import Logger from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, - deregister_loader_plugin_path, + register_creator_plugin_path, register_inventory_action_path, + deregister_loader_plugin_path, + deregister_creator_plugin_path, deregister_inventory_action_path, AVALON_CONTAINER_ID, ) @@ -70,7 +70,7 @@ def install(): log.info("Registering Fusion plug-ins..") register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) pyblish.api.register_callback( @@ -94,7 +94,7 @@ def uninstall(): log.info("Deregistering Fusion plug-ins..") deregister_loader_plugin_path(LOAD_PATH) - avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) deregister_inventory_action_path(INVENTORY_PATH) pyblish.api.deregister_callback( diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index 420e9720db..b7d5941182 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -11,9 +11,10 @@ import avalon.api from openpype import lib from openpype.lib import register_event_callback from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) import openpype.hosts.harmony @@ -186,7 +187,7 @@ def install(): pyblish.api.register_host("harmony") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) log.info(PUBLISH_PATH) # Register callbacks. @@ -200,7 +201,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) - avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) def on_pyblish_instance_toggled(instance, old_value, new_value): diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index 0d3c8914ce..b334102129 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -5,13 +5,13 @@ import os import contextlib from collections import OrderedDict -from avalon import api as avalon from avalon import schema from pyblish import api as pyblish from openpype.api import Logger from openpype.pipeline import ( - LegacyCreator, + register_creator_plugin_path, register_loader_plugin_path, + deregister_creator_plugin_path, deregister_loader_plugin_path, AVALON_CONTAINER_ID, ) @@ -50,7 +50,7 @@ def install(): pyblish.register_host("hiero") pyblish.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) @@ -71,7 +71,7 @@ def uninstall(): pyblish.deregister_host("hiero") pyblish.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) - avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index d079c9ea81..8e093a89bc 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -11,7 +11,7 @@ import avalon.api from avalon.lib import find_submodule from openpype.pipeline import ( - LegacyCreator, + register_creator_plugin_path, register_loader_plugin_path, AVALON_CONTAINER_ID, ) @@ -54,7 +54,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) log.info("Installing callbacks ... ") # register_event_callback("init", on_init) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index bb61128178..a8834d1ea3 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -23,8 +23,10 @@ from openpype.pipeline import ( LegacyCreator, register_loader_plugin_path, register_inventory_action_path, + register_creator_plugin_path, deregister_loader_plugin_path, deregister_inventory_action_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.hosts.maya.lib import copy_workspace_mel @@ -60,7 +62,7 @@ def install(): pyblish.api.register_host("maya") register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) log.info(PUBLISH_PATH) @@ -189,7 +191,7 @@ def uninstall(): pyblish.api.deregister_host("maya") deregister_loader_plugin_path(LOAD_PATH) - avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) deregister_inventory_action_path(INVENTORY_PATH) menu.uninstall() diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 3c8ba3e77c..c22488f728 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -26,6 +26,7 @@ from openpype.tools.utils import host_tools from openpype.lib.path_tools import HostDirmap from openpype.settings import get_project_settings from openpype.modules import ModulesManager +from openpype.pipeline import discover_legacy_creator_plugins from .workio import ( save_file, @@ -1902,7 +1903,7 @@ def recreate_instance(origin_node, avalon_data=None): # create new node # get appropriate plugin class creator_plugin = None - for Creator in api.discover(api.Creator): + for Creator in discover_legacy_creator_plugins(): if Creator.__name__ == data["creator"]: creator_plugin = Creator break diff --git a/openpype/hosts/nuke/api/pipeline.py b/openpype/hosts/nuke/api/pipeline.py index 1d110cb94a..6ee3d2ce05 100644 --- a/openpype/hosts/nuke/api/pipeline.py +++ b/openpype/hosts/nuke/api/pipeline.py @@ -5,7 +5,6 @@ from collections import OrderedDict import nuke import pyblish.api -import avalon.api import openpype from openpype.api import ( @@ -15,10 +14,11 @@ from openpype.api import ( ) from openpype.lib import register_event_callback from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, register_inventory_action_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, deregister_inventory_action_path, AVALON_CONTAINER_ID, ) @@ -106,7 +106,7 @@ def install(): log.info("Registering Nuke plug-ins..") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) register_inventory_action_path(INVENTORY_PATH) # Register Avalon event for workfiles loading. @@ -132,7 +132,7 @@ def uninstall(): pyblish.deregister_host("nuke") pyblish.api.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) - avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) deregister_inventory_action_path(INVENTORY_PATH) pyblish.api.deregister_callback( diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py index 08f09f8097..9fb57c1698 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_legacy.py @@ -1,11 +1,10 @@ -import os import toml import nuke -from avalon import api import pyblish.api import openpype.api +from openpype.pipeline import discover_creator_plugins from openpype.hosts.nuke.api.lib import get_avalon_knob_data @@ -79,7 +78,7 @@ class ValidateWriteLegacy(pyblish.api.InstancePlugin): # get appropriate plugin class creator_plugin = None - for Creator in api.discover(api.Creator): + for Creator in discover_creator_plugins(): if Creator.__name__ != Create_name: continue diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index c2ad0ac7b0..7fdaa61b40 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -9,9 +9,10 @@ from avalon import io from openpype.api import Logger from openpype.lib import register_event_callback from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) import openpype.hosts.photoshop @@ -75,7 +76,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) log.info(PUBLISH_PATH) pyblish.api.register_callback( @@ -88,7 +89,7 @@ def install(): def uninstall(): pyblish.api.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) - avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) def ls(): diff --git a/openpype/hosts/resolve/api/pipeline.py b/openpype/hosts/resolve/api/pipeline.py index e8b017ead5..636c826a11 100644 --- a/openpype/hosts/resolve/api/pipeline.py +++ b/openpype/hosts/resolve/api/pipeline.py @@ -4,14 +4,17 @@ Basic avalon integration import os import contextlib from collections import OrderedDict -from avalon import api as avalon -from avalon import schema + from pyblish import api as pyblish + +from avalon import schema + from openpype.api import Logger from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from . import lib @@ -46,7 +49,7 @@ def install(): log.info("Registering DaVinci Resovle plug-ins..") register_loader_plugin_path(LOAD_PATH) - avalon.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.register_callback("instanceToggled", on_pyblish_instance_toggled) @@ -70,7 +73,7 @@ def uninstall(): log.info("Deregistering DaVinci Resovle plug-ins..") deregister_loader_plugin_path(LOAD_PATH) - avalon.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) # register callback for switching publishable pyblish.deregister_callback("instanceToggled", on_pyblish_instance_toggled) diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index ec880a1abc..cafdf0701d 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -15,9 +15,10 @@ from openpype.hosts import tvpaint from openpype.api import get_current_project_settings from openpype.lib import register_event_callback from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) @@ -82,7 +83,7 @@ def install(): pyblish.api.register_host("tvpaint") pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) registered_callbacks = ( pyblish.api.registered_callbacks().get("instanceToggled") or [] @@ -104,7 +105,7 @@ def uninstall(): pyblish.api.deregister_host("tvpaint") pyblish.api.deregister_plugin_path(PUBLISH_PATH) deregister_loader_plugin_path(LOAD_PATH) - avalon.api.deregister_plugin_path(LegacyCreator, CREATE_PATH) + deregister_creator_plugin_path(CREATE_PATH) def containerise( diff --git a/openpype/hosts/unreal/api/pipeline.py b/openpype/hosts/unreal/api/pipeline.py index 713c588976..6d7a6ad1e2 100644 --- a/openpype/hosts/unreal/api/pipeline.py +++ b/openpype/hosts/unreal/api/pipeline.py @@ -7,9 +7,10 @@ import pyblish.api from avalon import api from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, + register_creator_plugin_path, deregister_loader_plugin_path, + deregister_creator_plugin_path, AVALON_CONTAINER_ID, ) from openpype.tools.utils import host_tools @@ -49,7 +50,7 @@ def install(): logger.info("installing OpenPype for Unreal") pyblish.api.register_plugin_path(str(PUBLISH_PATH)) register_loader_plugin_path(str(LOAD_PATH)) - api.register_plugin_path(LegacyCreator, str(CREATE_PATH)) + register_creator_plugin_path(str(CREATE_PATH)) _register_callbacks() _register_events() @@ -58,7 +59,7 @@ def uninstall(): """Uninstall Unreal configuration for Avalon.""" pyblish.api.deregister_plugin_path(str(PUBLISH_PATH)) deregister_loader_plugin_path(str(LOAD_PATH)) - api.deregister_plugin_path(LegacyCreator, str(CREATE_PATH)) + deregister_creator_plugin_path(str(CREATE_PATH)) def _register_callbacks(): diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 05d2ffd821..26e05ecd63 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1604,13 +1604,13 @@ def get_creator_by_name(creator_name, case_sensitive=False): Returns: Creator: Return first matching plugin or `None`. """ - from openpype.pipeline import LegacyCreator + from openpype.pipeline import discover_legacy_creator_plugins # Lower input creator name if is not case sensitive if not case_sensitive: creator_name = creator_name.lower() - for creator_plugin in avalon.api.discover(LegacyCreator): + for creator_plugin in discover_legacy_creator_plugins(): _creator_name = creator_plugin.__name__ # Lower creator plugin name if is not case sensitive diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index c2757a4502..21e726e060 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -10,7 +10,8 @@ from ..lib import UnknownDef from .creator_plugins import ( BaseCreator, Creator, - AutoCreator + AutoCreator, + discover_creator_plugins, ) from openpype.api import ( @@ -842,7 +843,7 @@ class CreateContext: creators = {} autocreators = {} manual_creators = {} - for creator_class in avalon.api.discover(BaseCreator): + for creator_class in discover_creator_plugins(BaseCreator): if inspect.isabstract(creator_class): self.log.info( "Skipping abstract Creator {}".format(str(creator_class)) diff --git a/openpype/tools/creator/model.py b/openpype/tools/creator/model.py index ef61c6e0f0..d3d60b96f2 100644 --- a/openpype/tools/creator/model.py +++ b/openpype/tools/creator/model.py @@ -1,8 +1,7 @@ import uuid from Qt import QtGui, QtCore -from avalon import api -from openpype.pipeline import LegacyCreator +from openpype.pipeline import discover_legacy_creator_plugins from . constants import ( FAMILY_ROLE, @@ -22,7 +21,7 @@ class CreatorsModel(QtGui.QStandardItemModel): self._creators_by_id = {} items = [] - creators = api.discover(LegacyCreator) + creators = discover_legacy_creator_plugins() for creator in creators: item_id = str(uuid.uuid4()) self._creators_by_id[item_id] = creator From 1612ad0f96d7a9778df9130dece9a29dae6fae8e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:12:18 +0100 Subject: [PATCH 200/854] call 'ls' directly in harmony --- openpype/hosts/harmony/api/pipeline.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/hosts/harmony/api/pipeline.py b/openpype/hosts/harmony/api/pipeline.py index b7d5941182..88f11dd16f 100644 --- a/openpype/hosts/harmony/api/pipeline.py +++ b/openpype/hosts/harmony/api/pipeline.py @@ -6,7 +6,6 @@ from bson.objectid import ObjectId import pyblish.api from avalon import io -import avalon.api from openpype import lib from openpype.lib import register_event_callback @@ -109,9 +108,8 @@ def check_inventory(): if not lib.any_outdated(): return - host = avalon.api.registered_host() outdated_containers = [] - for container in host.ls(): + for container in ls(): representation = container['representation'] representation_doc = io.find_one( { From b767458bff9573e221303ce6c9d0e7d4137596e7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:12:42 +0100 Subject: [PATCH 201/854] use direct imports of LegacyCreator --- .../hosts/aftereffects/plugins/create/create_render.py | 8 +++++--- openpype/hosts/fusion/plugins/create/create_exr_saver.py | 4 ++-- openpype/hosts/photoshop/plugins/create/create_image.py | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index 41efb4b0ba..831085a5f1 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -1,12 +1,14 @@ -from openpype.pipeline import create -from openpype.pipeline import CreatorError +from openpype.pipeline import ( + CreatorError, + LegacyCreator +) from openpype.hosts.aftereffects.api import ( get_stub, list_instances ) -class CreateRender(create.LegacyCreator): +class CreateRender(LegacyCreator): """Render folder for publish. Creates subsets in format 'familyTaskSubsetname', diff --git a/openpype/hosts/fusion/plugins/create/create_exr_saver.py b/openpype/hosts/fusion/plugins/create/create_exr_saver.py index ff8bdb21ef..8bab5ee9b1 100644 --- a/openpype/hosts/fusion/plugins/create/create_exr_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_exr_saver.py @@ -1,13 +1,13 @@ import os -from openpype.pipeline import create +from openpype.pipeline import LegacyCreator from openpype.hosts.fusion.api import ( get_current_comp, comp_lock_and_undo_chunk ) -class CreateOpenEXRSaver(create.LegacyCreator): +class CreateOpenEXRSaver(LegacyCreator): name = "openexrDefault" label = "Create OpenEXR Saver" diff --git a/openpype/hosts/photoshop/plugins/create/create_image.py b/openpype/hosts/photoshop/plugins/create/create_image.py index a001b5f171..5078cbb587 100644 --- a/openpype/hosts/photoshop/plugins/create/create_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_image.py @@ -1,9 +1,9 @@ from Qt import QtWidgets -from openpype.pipeline import create +from openpype.pipeline import LegacyCreator from openpype.hosts.photoshop import api as photoshop -class CreateImage(create.LegacyCreator): +class CreateImage(LegacyCreator): """Image folder for publish.""" name = "imageDefault" From ea79f0908b9070cc8a9301183a8a0432b272a508 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:14:22 +0100 Subject: [PATCH 202/854] fix test --- openpype/tests/test_avalon_plugin_presets.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/tests/test_avalon_plugin_presets.py b/openpype/tests/test_avalon_plugin_presets.py index f1b1a94713..c491be1c05 100644 --- a/openpype/tests/test_avalon_plugin_presets.py +++ b/openpype/tests/test_avalon_plugin_presets.py @@ -1,6 +1,10 @@ import avalon.api as api import openpype -from openpype.pipeline import LegacyCreator +from openpype.pipeline import ( + LegacyCreator, + register_creator_plugin, + discover_creator_plugins, +) class MyTestCreator(LegacyCreator): @@ -27,8 +31,8 @@ def test_avalon_plugin_presets(monkeypatch, printer): openpype.install() api.register_host(Test()) - api.register_plugin(LegacyCreator, MyTestCreator) - plugins = api.discover(LegacyCreator) + register_creator_plugin(MyTestCreator) + plugins = discover_creator_plugins() printer("Test if we got our test plugin") assert MyTestCreator in plugins for p in plugins: From aac580bda4d77b4862ca99e61366955e73d8bf3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:15:00 +0100 Subject: [PATCH 203/854] removed patched discover logic --- openpype/__init__.py | 75 +++++++------------------------------------- 1 file changed, 11 insertions(+), 64 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 8b94b2dc3f..2820091bcc 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -2,20 +2,16 @@ """Pype module.""" import os import platform -import functools import logging from .settings import get_project_settings from .lib import ( Anatomy, filter_pyblish_plugins, - set_plugin_attributes_from_settings, change_timer_to_current_context, register_event_callback, ) -pyblish = avalon = _original_discover = None - log = logging.getLogger(__name__) @@ -27,60 +23,17 @@ PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") LOAD_PATH = os.path.join(PLUGINS_DIR, "load") -def import_wrapper(func): - """Wrap module imports to specific functions.""" - @functools.wraps(func) - def decorated(*args, **kwargs): - global pyblish - global avalon - global _original_discover - if pyblish is None: - from pyblish import api as pyblish - from avalon import api as avalon - - # we are monkey patching `avalon.api.discover()` to allow us to - # load plugin presets on plugins being discovered by avalon. - # Little bit of hacking, but it allows us to add out own features - # without need to modify upstream code. - - _original_discover = avalon.discover - - return func(*args, **kwargs) - - return decorated - - -@import_wrapper -def patched_discover(superclass): - """Patch `avalon.api.discover()`. - - Monkey patched version of :func:`avalon.api.discover()`. It allows - us to load presets on plugins being discovered. - """ - # run original discover and get plugins - plugins = _original_discover(superclass) - filtered_plugins = [ - plugin - for plugin in plugins - if issubclass(plugin, superclass) - ] - - set_plugin_attributes_from_settings(filtered_plugins, superclass) - - return filtered_plugins - - -@import_wrapper def install(): """Install Pype to Avalon.""" + import avalon.api + import pyblish.api from pyblish.lib import MessageHandler from openpype.modules import load_modules from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, register_inventory_action, + register_creator_plugin_path, ) - from avalon import pipeline # Make sure modules are loaded load_modules() @@ -93,8 +46,8 @@ def install(): MessageHandler.emit = modified_emit log.info("Registering global plug-ins..") - pyblish.register_plugin_path(PUBLISH_PATH) - pyblish.register_discovery_filter(filter_pyblish_plugins) + pyblish.api.register_plugin_path(PUBLISH_PATH) + pyblish.api.register_discovery_filter(filter_pyblish_plugins) register_loader_plugin_path(LOAD_PATH) project_name = os.environ.get("AVALON_PROJECT") @@ -103,7 +56,7 @@ def install(): if project_name: anatomy = Anatomy(project_name) anatomy.set_root_environments() - avalon.register_root(anatomy.roots) + avalon.api.register_root(anatomy.roots) project_settings = get_project_settings(project_name) platform_name = platform.system().lower() @@ -122,17 +75,14 @@ def install(): if not path or not os.path.exists(path): continue - pyblish.register_plugin_path(path) + pyblish.api.register_plugin_path(path) register_loader_plugin_path(path) - avalon.register_plugin_path(LegacyCreator, path) + register_creator_plugin_path(path) register_inventory_action(path) # apply monkey patched discover to original one log.info("Patching discovery") - avalon.discover = patched_discover - pipeline.discover = patched_discover - register_event_callback("taskChanged", _on_task_change) @@ -140,16 +90,13 @@ def _on_task_change(): change_timer_to_current_context() -@import_wrapper def uninstall(): """Uninstall Pype from Avalon.""" + import pyblish.api from openpype.pipeline import deregister_loader_plugin_path log.info("Deregistering global plug-ins..") - pyblish.deregister_plugin_path(PUBLISH_PATH) - pyblish.deregister_discovery_filter(filter_pyblish_plugins) + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + pyblish.api.deregister_discovery_filter(filter_pyblish_plugins) deregister_loader_plugin_path(LOAD_PATH) log.info("Global plug-ins unregistred") - - # restore original discover - avalon.discover = _original_discover From c7039e91f8665b1a3f47e317e5b807faee03783c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 15:20:43 +0100 Subject: [PATCH 204/854] OP-2766 - return back uuid for legacy creator --- openpype/hosts/photoshop/plugins/create/create_legacy_image.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/photoshop/plugins/create/create_legacy_image.py b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py index 6fa455fa03..9736471a26 100644 --- a/openpype/hosts/photoshop/plugins/create/create_legacy_image.py +++ b/openpype/hosts/photoshop/plugins/create/create_legacy_image.py @@ -91,6 +91,7 @@ class CreateImage(create.LegacyCreator): long_names.append(name) self.data.update({"subset": subset_name}) + self.data.update({"uuid": str(group.id)}) self.data.update({"members": [str(group.id)]}) self.data.update({"long_name": "_".join(long_names)}) stub.imprint(group, self.data) From cd4e01f400344a0351246f70a7ef09b9e0b1cbba Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 15:54:43 +0100 Subject: [PATCH 205/854] print report of failed parts of discover --- openpype/pipeline/plugin_discover.py | 56 ++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index 305a8bab67..f4a133b0aa 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -1,5 +1,7 @@ import os import inspect +import traceback + from openpype.lib.python_module_tools import ( modules_from_path, classes_from_module, @@ -30,6 +32,59 @@ class DiscoverResult: def __setitem__(self, item, value): self.plugins[item] = value + def get_report(self, only_errors=True, exc_info=True, full_report=False): + lines = [] + if not only_errors: + # Successfully discovered plugins + if self.plugins or full_report: + lines.append( + "*** Discovered {} plugins".format(len(self.plugins)) + ) + for cls in self.plugins: + lines.append("- {}".format(cls.__class__.__name__)) + + # Plugin that were defined to be ignored + if self.ignored_plugins or full_report: + lines.append("*** Ignored plugins {}".format(len( + self.ignored_plugins + ))) + for cls in self.ignored_plugins: + lines.append("- {}".format(cls.__class__.__name__)) + + # Abstract classes + if self.abstract_plugins or full_report: + lines.append("*** Discovered {} abstract plugins".format(len( + self.abstract_plugins + ))) + for cls in self.abstract_plugins: + lines.append("- {}".format(cls.__class__.__name__)) + + # Abstract classes + if self.duplicated_plugins or full_report: + lines.append("*** There were {} duplicated plugins".format(len( + self.duplicated_plugins + ))) + for cls in self.duplicated_plugins: + lines.append("- {}".format(cls.__class__.__name__)) + + if self.crashed_file_paths or full_report: + lines.append("*** Failed to load {} files".format(len( + self.crashed_file_paths + ))) + for path, exc_info_args in self.crashed_file_paths.items(): + lines.append("- {}".format(path)) + if exc_info: + lines.append(10 * "*") + lines.extend(traceback.format_exception(*exc_info_args)) + lines.append(10 * "*") + + return "\n".join(lines) + + def print_report(self, only_errors=True, exc_info=True): + report = self.get_report(only_errors, exc_info) + if report: + print(report) + class PluginDiscoverContext(object): """Store and discover registered types nad registered paths to types. @@ -117,6 +172,7 @@ class PluginDiscoverContext(object): self._last_discovered_plugins[superclass] = list( result.plugins ) + result.print_report() return result def register_plugin(self, superclass, cls): From 06502b10064020e01d0e2102334f6d0b499a45d8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 16:52:35 +0100 Subject: [PATCH 206/854] safer delete versions --- .../event_handlers_user/action_delete_old_versions.py | 3 ++- openpype/plugins/load/delete_old_versions.py | 7 ++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py index 1b694e25f1..5871646b20 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py +++ b/openpype/modules/ftrack/event_handlers_user/action_delete_old_versions.py @@ -492,7 +492,8 @@ class DeleteOldVersions(BaseAction): os.remove(file_path) self.log.debug("Removed file: {}".format(file_path)) - remainders.remove(file_path_base) + if file_path_base in remainders: + remainders.remove(file_path_base) continue seq_path_base = os.path.split(seq_path)[1] diff --git a/openpype/plugins/load/delete_old_versions.py b/openpype/plugins/load/delete_old_versions.py index 692acdec02..2789f4ea23 100644 --- a/openpype/plugins/load/delete_old_versions.py +++ b/openpype/plugins/load/delete_old_versions.py @@ -126,7 +126,8 @@ class DeleteOldVersions(load.SubsetLoaderPlugin): os.remove(file_path) self.log.debug("Removed file: {}".format(file_path)) - remainders.remove(file_path_base) + if file_path_base in remainders: + remainders.remove(file_path_base) continue seq_path_base = os.path.split(seq_path)[1] @@ -333,6 +334,8 @@ class DeleteOldVersions(load.SubsetLoaderPlugin): def main(self, data, remove_publish_folder): # Size of files. size = 0 + if not data: + return size if remove_publish_folder: size = self.delete_whole_dir_paths(data["dir_paths"].values()) @@ -418,6 +421,8 @@ class DeleteOldVersions(load.SubsetLoaderPlugin): ) data = self.get_data(context, versions_to_keep) + if not data: + continue size += self.main(data, remove_publish_folder) print("Progressing {}/{}".format(count + 1, len(contexts))) From 8964fdb754ff837028f032d6bafbdc3ef160aa31 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 17:11:25 +0100 Subject: [PATCH 207/854] OP-2766 - clean up import --- openpype/hosts/aftereffects/api/pipeline.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/api/pipeline.py b/openpype/hosts/aftereffects/api/pipeline.py index 2a213e1b59..e14b8adc8c 100644 --- a/openpype/hosts/aftereffects/api/pipeline.py +++ b/openpype/hosts/aftereffects/api/pipeline.py @@ -11,12 +11,12 @@ from openpype import lib from openpype.api import Logger from openpype.pipeline import ( LegacyCreator, + BaseCreator, register_loader_plugin_path, deregister_loader_plugin_path, AVALON_CONTAINER_ID, ) import openpype.hosts.aftereffects -from openpype.pipeline import BaseCreator from openpype.lib import register_event_callback from .launch_logic import get_stub From 7dd0c86a17f0a95ac6e16416473978169cf793a7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Mar 2022 17:11:48 +0100 Subject: [PATCH 208/854] flame: collect timeline instances settings --- .../defaults/project_settings/flame.json | 31 +++++++ .../projects_schema/schema_project_flame.json | 81 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index c7188b10b5..939752c778 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -20,6 +20,37 @@ } }, "publish": { + "CollectTimelineInstances": { + "xml_preset_attrs_from_comments": [ + { + "name": "width", + "type": "number" + }, + { + "name": "height", + "type": "number" + }, + { + "name": "pixelRatio", + "type": "float" + }, + { + "name": "resizeType", + "type": "string" + }, + { + "name": "resizeFilter", + "type": "string" + } + ], + "add_tasks": [ + { + "name": "compositing", + "type": "Compositing", + "create_batch_group": true + } + ] + }, "ExtractSubsetResources": { "keep_original_representation": false, "export_presets_mapping": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index e352f8b132..8057b07d9c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -136,6 +136,87 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CollectTimelineInstances", + "label": "Collect Timeline Instances", + "is_group": true, + "children": [ + { + "type": "collapsible-wrap", + "label": "XML presets attributes parsable from segment comments", + "collapsible": true, + "collapsed": true, + "children": [ + { + "type": "list", + "key": "xml_preset_attrs_from_comments", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "name", + "label": "Attribute name" + }, + { + "key": "type", + "label": "Attribute type", + "type": "enum", + "default": "number", + "enum_items": [ + { + "number": "number" + }, + { + "float": "float" + }, + { + "string": "string" + } + ] + } + ] + } + } + ] + }, + { + "type": "collapsible-wrap", + "label": "Add tasks", + "collapsible": true, + "collapsed": true, + "children": [ + { + "type": "list", + "key": "add_tasks", + "object_type": { + "type": "dict", + "children": [ + { + "type": "text", + "key": "name", + "label": "Task name" + }, + { + "key": "type", + "label": "Task type", + "multiselection": false, + "type": "task-types-enum" + }, + { + "type": "boolean", + "key": "create_batch_group", + "label": "Create batch group" + } + ] + } + } + ] + } + ] + }, { "type": "dict", "collapsible": true, From 074703f8cff5b4432a98d1fb28f9a6f42943c694 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Mar 2022 17:13:09 +0100 Subject: [PATCH 209/854] flame: use settings in collect timeline instances --- .../publish/collect_timeline_instances.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 70340ad7a2..94348601b2 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -21,15 +21,9 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): audio_track_items = [] - # TODO: add to settings # settings - xml_preset_attrs_from_comments = { - "width": "number", - "height": "number", - "pixelRatio": "float", - "resizeType": "string", - "resizeFilter": "string" - } + xml_preset_attrs_from_comments = [] + add_tasks = [] def process(self, context): project = context.data["flameProject"] @@ -106,7 +100,11 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): "fps": self.fps, "flameSourceClip": source_clip, "sourceFirstFrame": int(first_frame), - "path": file_path + "path": file_path, + "flameAddTasks": self.add_tasks, + "tasks": { + task["name"]: {"type": task["type"]} + for task in self.add_tasks} }) # get otio clip data @@ -181,14 +179,17 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): # split to key and value key, value = split.split(":") - for a_name, a_type in self.xml_preset_attrs_from_comments.items(): + for attr_data in self.xml_preset_attrs_from_comments: + a_name = attr_data["name"] + a_type = attr_data["type"] + # exclude all not related attributes if a_name.lower() not in key.lower(): continue # get pattern defined by type pattern = TXT_PATERN - if a_type in ("number" , "float"): + if a_type in ("number", "float"): pattern = NUM_PATERN res_goup = pattern.findall(value) From 0858ee0ce8483c123a67525342fba6f782c15ae2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 17:15:46 +0100 Subject: [PATCH 210/854] OP-2765 - remove wrong logging function --- .../aftereffects/plugins/publish/collect_workfile.py | 4 ---- openpype/lib/__init__.py | 3 +-- openpype/lib/log.py | 11 ----------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index 67f037e6e6..f285ae49e4 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -2,8 +2,6 @@ import os from avalon import api import pyblish.api -from openpype.lib import debug_log_instance - class CollectWorkfile(pyblish.api.ContextPlugin): """ Adds the AE render instances """ @@ -76,5 +74,3 @@ class CollectWorkfile(pyblish.api.ContextPlugin): } instance.data["representations"].append(representation) - - debug_log_instance(self.log, "Workfile instance", instance) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index f02706e44f..e8b6d18f4e 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -63,7 +63,7 @@ from .execute import ( path_to_subprocess_arg, CREATE_NO_WINDOW ) -from .log import PypeLogger, timeit, debug_log_instance +from .log import PypeLogger, timeit from .path_templates import ( merge_dict, @@ -369,7 +369,6 @@ __all__ = [ "OpenPypeMongoConnection", "timeit", - "debug_log_instance", "is_overlapping_otio_ranges", "otio_range_with_handles", diff --git a/openpype/lib/log.py b/openpype/lib/log.py index 991dc3349a..c963807014 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -498,14 +498,3 @@ def timeit(method): print('%r %2.2f ms' % (method.__name__, (te - ts) * 1000)) return result return timed - - -def debug_log_instance(logger, msg, instance): - """Helper function to write instance.data as json""" - def _default_json(value): - return str(value) - - logger.debug(msg) - logger.debug( - json.dumps(instance.data, indent=4, default=_default_json) - ) From 91879de0ad4ed7859b4fa330bcc03685fd3d39ad Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 17:24:24 +0100 Subject: [PATCH 211/854] OP-2765 - revert of unwanted commit --- openpype/modules/log_viewer/log_view_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/log_viewer/log_view_module.py b/openpype/modules/log_viewer/log_view_module.py index 5e141f6aa2..14be6b392e 100644 --- a/openpype/modules/log_viewer/log_view_module.py +++ b/openpype/modules/log_viewer/log_view_module.py @@ -8,7 +8,7 @@ class LogViewModule(OpenPypeModule, ITrayModule): def initialize(self, modules_settings): logging_settings = modules_settings[self.name] - self.enabled = False # logging_settings["enabled"] + self.enabled = logging_settings["enabled"] # Tray attributes self.window = None From bfbb2061bcbe900a05ac59ff1e4894f1ae4cefa5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 17:25:34 +0100 Subject: [PATCH 212/854] OP-2765 - revert of unwanted commit --- .../deadline/repository/custom/plugins/GlobalJobPreLoad.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py index ed932d35b9..eeb1f7744c 100644 --- a/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py +++ b/openpype/modules/deadline/repository/custom/plugins/GlobalJobPreLoad.py @@ -85,9 +85,7 @@ def inject_openpype_environment(deadlinePlugin): with open(export_url) as fp: contents = json.load(fp) for key, value in contents.items(): - print("key:: {}".format(key)) - if key != 'NUMBER_OF_PROCESSORS': - deadlinePlugin.SetProcessEnvironmentVariable(key, value) + deadlinePlugin.SetProcessEnvironmentVariable(key, value) print(">>> Removing temporary file") os.remove(export_url) From 16c919e93d0d65af801a10dff431058ec1da8203 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 17:26:40 +0100 Subject: [PATCH 213/854] OP-2765 - revert of unwanted commit --- openpype/hosts/harmony/plugins/publish/extract_render.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/harmony/plugins/publish/extract_render.py b/openpype/hosts/harmony/plugins/publish/extract_render.py index 49133d9608..2f8169248e 100644 --- a/openpype/hosts/harmony/plugins/publish/extract_render.py +++ b/openpype/hosts/harmony/plugins/publish/extract_render.py @@ -41,7 +41,6 @@ class ExtractRender(pyblish.api.InstancePlugin): func = """function %s(args) { node.setTextAttr(args[0], "DRAWING_NAME", 1, args[1]); - node.setTextAttr(args[0], 'MOVIE_PATH', 1, args[1]); } %s """ % (sig, sig) From 59f2adbf341334fcb0ef239ce082f2c50bfe6a43 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 17:27:48 +0100 Subject: [PATCH 214/854] OP-2765 - revert of unwanted commit --- openpype/lib/log.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/lib/log.py b/openpype/lib/log.py index c963807014..f33385e0ba 100644 --- a/openpype/lib/log.py +++ b/openpype/lib/log.py @@ -23,7 +23,6 @@ import time import traceback import threading import copy -import json from . import Terminal from .mongo import ( From 953ff9eedb1c419b954c5f1e12346ba9f5fd30e1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 18:00:57 +0100 Subject: [PATCH 215/854] fix attribute access --- openpype/tools/settings/settings/wrapper_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/settings/settings/wrapper_widgets.py b/openpype/tools/settings/settings/wrapper_widgets.py index 6b2258157c..b14a226912 100644 --- a/openpype/tools/settings/settings/wrapper_widgets.py +++ b/openpype/tools/settings/settings/wrapper_widgets.py @@ -92,7 +92,7 @@ class CollapsibleWrapper(WrapperWidget): self.content_layout = content_layout if self.collapsible: - if not self.entity.collapsed: + if not self.collapsed: body_widget.toggle_content() else: body_widget.hide_toolbox(hide_content=False) From 881ec1579ec82460734e9bdf93e9d5c968525b1d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 23 Mar 2022 18:10:17 +0100 Subject: [PATCH 216/854] OP-2765 - fix exception if no file opened Should be refactored, merged 2 functions in code and extension. --- openpype/hosts/aftereffects/api/workio.py | 23 +++++++++++++--------- openpype/hosts/aftereffects/api/ws_stub.py | 2 +- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/aftereffects/api/workio.py b/openpype/hosts/aftereffects/api/workio.py index 5a8f86ead5..d6c732285a 100644 --- a/openpype/hosts/aftereffects/api/workio.py +++ b/openpype/hosts/aftereffects/api/workio.py @@ -5,14 +5,6 @@ from openpype.pipeline import HOST_WORKFILE_EXTENSIONS from .launch_logic import get_stub -def _active_document(): - document_name = get_stub().get_active_document_name() - if not document_name: - return None - - return document_name - - def file_extensions(): return HOST_WORKFILE_EXTENSIONS["aftereffects"] @@ -39,7 +31,8 @@ def current_file(): full_name = get_stub().get_active_document_full_name() if full_name and full_name != "null": return os.path.normpath(full_name).replace("\\", "/") - except Exception: + except ValueError: + print("Nothing opened") pass return None @@ -47,3 +40,15 @@ def current_file(): def work_root(session): return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") + + +def _active_document(): + # TODO merge with current_file - even in extension + document_name = None + try: + document_name = get_stub().get_active_document_name() + except ValueError: + print("Nothing opened") + pass + + return document_name diff --git a/openpype/hosts/aftereffects/api/ws_stub.py b/openpype/hosts/aftereffects/api/ws_stub.py index 1dfea697a1..9a6462fcd4 100644 --- a/openpype/hosts/aftereffects/api/ws_stub.py +++ b/openpype/hosts/aftereffects/api/ws_stub.py @@ -171,7 +171,7 @@ class AfterEffectsServerStub(): def get_active_document_full_name(self): """ - Returns just a name of active document via ws call + Returns absolute path of active document via ws call Returns(string): file name """ res = self.websocketserver.call(self.client.call( From 6bb10852f5aaee9a85a66c83d5a66aeab439d6d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 23 Mar 2022 18:18:59 +0100 Subject: [PATCH 217/854] Fix creator discover Co-authored-by: Roy Nieterau --- openpype/pipeline/create/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 21e726e060..d833e6f686 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -843,7 +843,7 @@ class CreateContext: creators = {} autocreators = {} manual_creators = {} - for creator_class in discover_creator_plugins(BaseCreator): + for creator_class in discover_creator_plugins(): if inspect.isabstract(creator_class): self.log.info( "Skipping abstract Creator {}".format(str(creator_class)) From e3196a40236e4eb1540019333fc30c3a2b5b0cc7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 23 Mar 2022 18:30:28 +0100 Subject: [PATCH 218/854] Fix docstring Co-authored-by: Roy Nieterau --- openpype/pipeline/plugin_discover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index f4a133b0aa..a657df7994 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -190,7 +190,7 @@ class PluginDiscoverContext(object): self._registered_plugins[superclass].append(cls) def register_plugin_path(self, superclass, path): - """Register a directory of one or more plug-ins + """Register a directory containing plug-ins of type `superclass` Arguments: superclass (type): Superclass of plug-ins to look for during discovery From 89d61723de876c02b061198e9462feb7f52d98ba Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 23 Mar 2022 19:20:37 +0100 Subject: [PATCH 219/854] applied comments --- openpype/lib/python_module_tools.py | 2 - openpype/pipeline/actions.py | 4 +- openpype/pipeline/create/creator_plugins.py | 4 +- openpype/pipeline/load/plugins.py | 2 +- openpype/pipeline/plugin_discover.py | 88 ++++++++++++++------- 5 files changed, 66 insertions(+), 34 deletions(-) diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 4ef31b5579..6fad3b547f 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -133,8 +133,6 @@ def classes_from_module(superclass, module): if not inspect.isclass(obj) or obj is superclass: continue - # Use string comparison rather than `issubclass` - # in order to support reloading of this module. if issubclass(obj, superclass): classes.append(obj) diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py index 6cb2e9a5a4..b488fe3e1f 100644 --- a/openpype/pipeline/actions.py +++ b/openpype/pipeline/actions.py @@ -97,7 +97,7 @@ class InventoryAction(object): # Launcher action def discover_launcher_actions(): - return discover(LauncherAction).plugins + return discover(LauncherAction) def register_launcher_action(plugin): @@ -110,7 +110,7 @@ def register_launcher_action_path(path): # Inventory action def discover_inventory_actions(): - actions = discover(InventoryAction).plugins + actions = discover(InventoryAction) filtered_actions = [] for action in actions: if action is not InventoryAction: diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index dbeeb94050..c3ba8b1d1c 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -303,11 +303,11 @@ class AutoCreator(BaseCreator): def discover_creator_plugins(): - return discover(BaseCreator).plugins + return discover(BaseCreator) def discover_legacy_creator_plugins(): - plugins = discover(LegacyCreator).plugins + plugins = discover(LegacyCreator) set_plugin_attributes_from_settings(plugins, LegacyCreator) return plugins diff --git a/openpype/pipeline/load/plugins.py b/openpype/pipeline/load/plugins.py index fb5d1df9b5..d60aed0083 100644 --- a/openpype/pipeline/load/plugins.py +++ b/openpype/pipeline/load/plugins.py @@ -110,7 +110,7 @@ class SubsetLoaderPlugin(LoaderPlugin): def discover_loader_plugins(): - plugins = discover(LoaderPlugin).plugins + plugins = discover(LoaderPlugin) set_plugin_attributes_from_settings(plugins, LoaderPlugin) return plugins diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index a657df7994..b5edda7e9d 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -2,25 +2,31 @@ import os import inspect import traceback +from openpype.lib import Logger from openpype.lib.python_module_tools import ( modules_from_path, classes_from_module, ) +log = Logger.get_logger(__name__) + class DiscoverResult: - """Hold result of publish plugins discovery. + """Result of Plug-ins discovery of a single superclass type. - Stores discovered plugins duplicated plugins and file paths which - crashed on execution of file. + Stores discovered, duplicated, ignored and abstract plugins and file paths + which crashed on execution of file. """ - def __init__(self): + def __init__(self, superclass): + self.superclass = superclass self.plugins = [] self.crashed_file_paths = {} self.duplicated_plugins = [] self.abstract_plugins = [] self.ignored_plugins = set() + # Store loaded modules to keep them in memory + self._modules = set() def __iter__(self): for plugin in self.plugins: @@ -32,6 +38,10 @@ class DiscoverResult: def __setitem__(self, item, value): self.plugins[item] = value + def add_module(self, module): + """Add dynamically loaded python module to keep it in memory.""" + self._modules.add(module) + def get_report(self, only_errors=True, exc_info=True, full_report=False): lines = [] if not only_errors: @@ -80,10 +90,10 @@ class DiscoverResult: return "\n".join(lines) - def print_report(self, only_errors=True, exc_info=True): + def log_report(self, only_errors=True, exc_info=True): report = self.get_report(only_errors, exc_info) if report: - print(report) + log.info(report) class PluginDiscoverContext(object): @@ -98,12 +108,25 @@ class PluginDiscoverContext(object): self._registered_plugins = {} self._registered_plugin_paths = {} self._last_discovered_plugins = {} + # Store the last result to memory + self._last_discovered_results = {} def get_last_discovered_plugins(self, superclass): + """Access last discovered plugin by a subperclass. + + Returns: + None: When superclass was not discovered yet. + list: Lastly discovered plugins of the superclass. + """ + return self._last_discovered_plugins.get(superclass) def discover( - self, superclass, allow_duplicates=True, ignore_classes=None + self, + superclass, + allow_duplicates=True, + ignore_classes=None, + return_report=False ): """Find and return subclasses of `superclass` @@ -122,7 +145,7 @@ class PluginDiscoverContext(object): if not ignore_classes: ignore_classes = [] - result = DiscoverResult() + result = DiscoverResult(superclass) plugin_names = set() registered_classes = self._registered_plugins.get(superclass) or [] registered_paths = self._registered_plugin_paths.get(superclass) or [] @@ -151,6 +174,7 @@ class PluginDiscoverContext(object): for item in modules: filepath, module = item + result.add_module(module) for cls in classes_from_module(superclass, module): if cls is superclass or cls in ignore_classes: result.ignored_plugins.add(cls) @@ -169,14 +193,18 @@ class PluginDiscoverContext(object): result.plugins.append(cls) + # Store in memory last result to keep in memory loaded modules + self._last_discovered_results[superclass] = result self._last_discovered_plugins[superclass] = list( result.plugins ) - result.print_report() - return result + result.log_report() + if return_report: + return result + return result.plugins def register_plugin(self, superclass, cls): - """Register an individual `obj` of type `superclass` + """Register a directory containing plug-ins of type `superclass` Arguments: superclass (type): Superclass of plug-in @@ -190,12 +218,13 @@ class PluginDiscoverContext(object): self._registered_plugins[superclass].append(cls) def register_plugin_path(self, superclass, path): - """Register a directory containing plug-ins of type `superclass` + """Register a directory of one or more plug-ins Arguments: - superclass (type): Superclass of plug-ins to look for during discovery - path (str): Absolute path to directory in which to discover plug-ins - + superclass (type): Superclass of plug-ins to look for during + discovery + path (str): Absolute path to directory in which to discover + plug-ins """ if superclass not in self._registered_plugin_paths: @@ -207,24 +236,29 @@ class PluginDiscoverContext(object): def registered_plugin_paths(self): """Return all currently registered plug-in paths""" - # Prohibit editing in-place - duplicate = { + # Return shallow copy so we the original data can't be changed + return { superclass: paths[:] for superclass, paths in self._registered_plugin_paths.items() } - return duplicate def deregister_plugin(self, superclass, plugin): - """Oppsite of `register_plugin()`""" + """Opposite of `register_plugin()`""" if superclass in self._registered_plugins: self._registered_plugins[superclass].remove(plugin) def deregister_plugin_path(self, superclass, path): - """Oppsite of `register_plugin_path()`""" + """Opposite of `register_plugin_path()`""" self._registered_plugin_paths[superclass].remove(path) -class GlobalDiscover: +class _GlobalDiscover: + """Access to global object of PluginDiscoverContext. + + Using singleton object to register/deregister plugins and plugin paths + and then discover them by superclass. + """ + _context = None @classmethod @@ -235,30 +269,30 @@ class GlobalDiscover: def discover(superclass, allow_duplicates=True): - context = GlobalDiscover.get_context() + context = _GlobalDiscover.get_context() return context.discover(superclass, allow_duplicates) def get_last_discovered_plugins(superclass): - context = GlobalDiscover.get_context() + context = _GlobalDiscover.get_context() return context.get_last_discovered_plugins(superclass) def register_plugin(superclass, cls): - context = GlobalDiscover.get_context() + context = _GlobalDiscover.get_context() context.register_plugin(superclass, cls) def register_plugin_path(superclass, path): - context = GlobalDiscover.get_context() + context = _GlobalDiscover.get_context() context.register_plugin_path(superclass, path) def deregister_plugin(superclass, cls): - context = GlobalDiscover.get_context() + context = _GlobalDiscover.get_context() context.deregister_plugin(superclass, cls) def deregister_plugin_path(superclass, path): - context = GlobalDiscover.get_context() + context = _GlobalDiscover.get_context() context.deregister_plugin_path(superclass, path) From 029135acf5513756ffc3e01fafa66e8ddd565d32 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 23 Mar 2022 20:38:46 +0100 Subject: [PATCH 220/854] flame: integrator bath group [finishing] --- openpype/hosts/flame/api/batch_utils.py | 12 +++- .../plugins/publish/integrate_batch_group.py | 65 +++++++++++++++---- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/flame/api/batch_utils.py b/openpype/hosts/flame/api/batch_utils.py index a1fe7961c4..d309c5985d 100644 --- a/openpype/hosts/flame/api/batch_utils.py +++ b/openpype/hosts/flame/api/batch_utils.py @@ -37,8 +37,12 @@ def create_batch(name, frame_start, frame_end, **kwargs): comp_node = flame.batch.create_node("Comp") + # TODO: convert this to iterational processing, + # so it could be driven from `imageio` settigns # create write node write_node = flame.batch.create_node('Write File') + # assign attrs + write_node.name = write_pref["name"] write_node.media_path = write_pref["media_path"] write_node.media_path_pattern = write_pref["media_path_pattern"] write_node.create_clip = write_pref["create_clip"] @@ -46,11 +50,15 @@ def create_batch(name, frame_start, frame_end, **kwargs): write_node.create_clip_path = write_pref["create_clip_path"] write_node.include_setup_path = write_pref["include_setup_path"] write_node.file_type = write_pref["file_type"] + write_node.format_extension = write_pref["format_extension"] write_node.bit_depth = write_pref["bit_depth"] + write_node.compress = write_pref["compress"] + write_node.compress_mode = write_pref["compress_mode"] write_node.frame_index_mode = write_pref["frame_index_mode"] - write_node.frame_padding = int(write_pref["frame_padding"]) + write_node.frame_padding = write_pref["frame_padding"] + write_node.version_mode = write_pref["version_mode"] + write_node.version_name = write_pref["version_name"] - # connect nodes flame.batch.connect_nodes(comp_node, "Result", write_node, "Front") # sort batch nodes diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 780531287b..808c059816 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -1,5 +1,7 @@ import os +from pprint import pformat import pyblish +from openpype.lib import get_workdir import openpype.hosts.flame.api as opfapi @@ -17,25 +19,52 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): frame_end = instance.data["frameEnd"] handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] - asset_name = instance.data["asset"] - write_pref_data = self._get_write_prefs(instance) + add_tasks = instance.data["flameAddTasks"] - batch_data = { - "shematic_reels": [ - "OP_LoadedReel" - ], - "write_pref": write_pref_data, - "handleStart": handle_start, - "handleEnd": handle_end - } + # iterate all tasks from settings + for task_data in add_tasks: + # exclude batch group + if not task_data["create_batch_group"]: + continue + task_name = task_data["name"] + batchgroup_name = "{}_{}".format(asset_name, task_name) + write_pref_data = self._get_write_prefs(instance, task_data) - opfapi.create_batch(asset_name, frame_start, frame_end, batch_data) + batch_data = { + "shematic_reels": [ + "OP_LoadedReel" + ], + "write_pref": write_pref_data, + "handleStart": handle_start, + "handleEnd": handle_end + } + self.log.debug( + "__ batch_data: {}".format(pformat(batch_data))) - def _get_write_prefs(self, instance): - shot_path = instance.data[""] + # create batch with utils + opfapi.create_batch( + batchgroup_name, + frame_start, + frame_end, + batch_data + ) + + def _get_write_prefs(self, instance, task_data): + anatomy_data = instance.data["anatomyData"] + + task_workfile_path = self._get_shot_task_dir_path(instance, task_data) + self.log.debug("__ task_workfile_path: {}".format(task_workfile_path)) + + # TODO: this might be done with template in settings render_dir_path = os.path.join( - shot_path, "work", task, "render", "flame") + task_workfile_path, "render", "flame") + + # TODO: add most of these to `imageio/flame/batch/write_node` + name = "{project[code]}_{asset}_{task[name]}".format( + **anatomy_data + ) + # The path attribute where the rendered clip is exported # /path/to/file.[0001-0010].exr media_path = render_dir_path @@ -83,6 +112,7 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): version_name = "v" return { + "name": name, "media_path": media_path, "media_path_pattern": media_path_pattern, "create_clip": create_clip, @@ -99,3 +129,10 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): "version_mode": version_mode, "version_name": version_name } + + def _get_shot_task_dir_path(self, instance, task_data): + project_doc = instance.data["projectEntity"] + asset_entity = instance.data["assetEntity"] + + return get_workdir( + project_doc, asset_entity, task_data["name"], "flame") From 7a96bfcfbf2199d2c4c0d1c3d5db9cc049018653 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 09:45:36 +0900 Subject: [PATCH 221/854] deal with handle start and end for multiverse usd extractor --- .../hosts/maya/plugins/publish/extract_multiverse_usd.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index d45ceb1932..c64e5b03e2 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -159,13 +159,16 @@ class ExtractMultiverseUsd(openpype.api.Extractor): time_opts = None frame_start = instance.data['frameStart'] frame_end = instance.data['frameEnd'] + handle_start = instance.data['handleStart'] + handle_end = instance.data['handleEnd'] step = instance.data['step'] fps = instance.data['fps'] if frame_end != frame_start: time_opts = multiverse.TimeOptions() time_opts.writeTimeRange = True - time_opts.frameRange = (frame_start, frame_end) + time_opts.frameRange = ( + frame_start - handle_start, frame_end + handle_end) time_opts.frameIncrement = step time_opts.numTimeSamples = instance.data["numTimeSamples"] time_opts.timeSamplesSpan = instance.data["timeSamplesSpan"] From 006c43c6f08106dc7702ca49623dd18e3f1b18c4 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 10:37:17 +0900 Subject: [PATCH 222/854] replace unicode with str and normalize unicode instance data value --- .../plugins/publish/extract_multiverse_usd.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index c64e5b03e2..d7d1337930 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -1,4 +1,5 @@ import os +import six from maya import cmds @@ -32,9 +33,9 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "flattenParentXforms": bool, "writeSparseOverrides": bool, "useMetaPrimPath": bool, - "customRootPath": unicode, - "customAttributes": unicode, - "nodeTypesToIgnore": unicode, + "customRootPath": str, + "customAttributes": str, + "nodeTypesToIgnore": str, "writeMeshes": bool, "writeCurves": bool, "writeParticles": bool, @@ -57,7 +58,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "writeTransformMatrix": bool, "writeUsdAttributes": bool, "timeVaryingTopology": bool, - "customMaterialNamespace": unicode, + "customMaterialNamespace": str, "numTimeSamples": int, "timeSamplesSpan": float } @@ -73,9 +74,9 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "flattenParentXforms": False, "writeSparseOverrides": False, "useMetaPrimPath": False, - "customRootPath": u'', - "customAttributes": u'', - "nodeTypesToIgnore": u'', + "customRootPath": str(), + "customAttributes": str(), + "nodeTypesToIgnore": str(), "writeMeshes": True, "writeCurves": True, "writeParticles": True, @@ -98,7 +99,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): "writeTransformMatrix": True, "writeUsdAttributes": False, "timeVaryingTopology": False, - "customMaterialNamespace": u'', + "customMaterialNamespace": str(), "numTimeSamples": 1, "timeSamplesSpan": 0.0 } @@ -112,6 +113,8 @@ class ExtractMultiverseUsd(openpype.api.Extractor): # Ensure the data is of correct type value = instance.data[key] + if isinstance(value, six.text_type): + value = str(value) if not isinstance(value, self.options[key]): self.log.warning( "Overridden attribute {key} was of " From de8eac521c091914a4a1e7063e792c269b0db162 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 11:00:44 +0900 Subject: [PATCH 223/854] improved multiverse usd composition creator --- .../plugins/create/create_multiverse_usd_comp.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py index 2f57ccec6c..5d216ddb9c 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py @@ -4,7 +4,7 @@ from openpype.hosts.maya.api import plugin, lib class CreateMultiverseUsdComp(plugin.Creator): """Create Multiverse USD Composition""" - name = "usdOverrideMain" + name = "usdCompositionMain" label = "Multiverse USD Composition" family = "usdComposition" icon = "cubes" @@ -17,14 +17,6 @@ class CreateMultiverseUsdComp(plugin.Creator): self.data["flattenContent"] = False self.data["writePendingOverrides"] = False - # The attributes below are about animated cache. - self.data["writeTimeRange"] = True - self.data["timeRangeNumTimeSamples"] = 0 - self.data["timeRangeSamplesSpan"] = 0.0 - + # Add animation data animation_data = lib.collect_animation_data(True) - - self.data["timeRangeStart"] = animation_data["frameStart"] - self.data["timeRangeEnd"] = animation_data["frameEnd"] - self.data["timeRangeIncrement"] = animation_data["step"] - self.data["timeRangeFramesPerSecond"] = animation_data["fps"] + self.data.update(animation_data) From 7320e570cac9e2c5852709e9c37dc9e2dd37f9dd Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 11:37:11 +0900 Subject: [PATCH 224/854] fixed multiverse usd composition extractor --- .../publish/extract_multiverse_usd_comp.py | 98 +++++++++++-------- 1 file changed, 58 insertions(+), 40 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index 449a99e1be..c80a3cce6c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -1,10 +1,10 @@ import os -import avalon.maya -import openpype.api - from maya import cmds +import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection + class ExtractMultiverseUsdComposition(openpype.api.Extractor): """Extractor of Multiverse USD Composition.""" @@ -29,38 +29,43 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): "stripNamespaces": bool, "mergeTransformAndShape": bool, "flattenContent": bool, - "writePendingOverrides": bool, - "writeTimeRange": bool, - "timeRangeStart": int, - "timeRangeEnd": int, - "timeRangeIncrement": int, - "timeRangeNumTimeSamples": int, - "timeRangeSamplesSpan": float, - "timeRangeFramesPerSecond": float + "writePendingOverrides": bool } @property def default_options(self): """The default options for Multiverse USD extraction.""" - start_frame = int(cmds.playbackOptions(query=True, - animationStartTime=True)) - end_frame = int(cmds.playbackOptions(query=True, - animationEndTime=True)) return { "stripNamespaces": False, "mergeTransformAndShape": False, "flattenContent": False, - "writePendingOverrides": False, - "writeTimeRange": True, - "timeRangeStart": start_frame, - "timeRangeEnd": end_frame, - "timeRangeIncrement": 1, - "timeRangeNumTimeSamples": 0, - "timeRangeSamplesSpan": 0.0, - "timeRangeFramesPerSecond": 24.0 + "writePendingOverrides": False } + def parse_overrides(self, instance, options): + """Inspect data of instance to determine overridden options""" + + for key in instance.data: + if key not in self.options: + continue + + # Ensure the data is of correct type + value = instance.data[key] + if not isinstance(value, self.options[key]): + self.log.warning( + "Overridden attribute {key} was of " + "the wrong type: {invalid_type} " + "- should have been {valid_type}".format( + key=key, + invalid_type=type(value).__name__, + valid_type=self.options[key].__name__)) + continue + + options[key] = value + + return options + def process(self, instance): # Load plugin firstly cmds.loadPlugin("MultiverseForMaya", quiet=True) @@ -73,46 +78,59 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): # Parse export options options = self.default_options + options = self.parse_overrides(instance, options) self.log.info("Export options: {0}".format(options)) # Perform extraction self.log.info("Performing extraction ...") - with avalon.maya.maintained_selection(): + with maintained_selection(): members = instance.data("setMembers") members = cmds.ls(members, dag=True, shapes=True, - type=("mvUsdCompoundShape"), + type="mvUsdCompoundShape", noIntermediate=True, long=True) self.log.info('Collected object {}'.format(members)) - # TODO: Deal with asset, composition, overide with options. import multiverse time_opts = None - if options["writeTimeRange"]: + frame_start = instance.data['frameStart'] + frame_end = instance.data['frameEnd'] + handle_start = instance.data['handleStart'] + handle_end = instance.data['handleEnd'] + step = instance.data['step'] + fps = instance.data['fps'] + if frame_end != frame_start: time_opts = multiverse.TimeOptions() time_opts.writeTimeRange = True - - time_range_start = options["timeRangeStart"] - time_range_end = options["timeRangeEnd"] - time_opts.frameRange = (time_range_start, time_range_end) - - time_opts.frameIncrement = options["timeRangeIncrement"] - time_opts.numTimeSamples = options["timeRangeNumTimeSamples"] - time_opts.timeSamplesSpan = options["timeRangeSamplesSpan"] - time_opts.framePerSecond = options["timeRangeFramesPerSecond"] + time_opts.frameRange = ( + frame_start - handle_start, frame_end + handle_end) + time_opts.frameIncrement = step + time_opts.numTimeSamples = instance.data["numTimeSamples"] + time_opts.timeSamplesSpan = instance.data["timeSamplesSpan"] + time_opts.framePerSecond = fps comp_write_opts = multiverse.CompositionWriteOptions() options_items = getattr(options, "iteritems", options.items) - for (k, v) in options_items(): - if k == "writeTimeRange" or k.startswith("timeRange"): + options_discard_keys = [ + 'numTimeSamples', + 'timeSamplesSpan', + 'frameStart', + 'frameEnd', + 'handleStart', + 'handleEnd', + 'step', + 'fps' + ] + for key, value in options_items(): + if key in options_discard_keys: continue - setattr(comp_write_opts, k, v) - comp_write_opts.timeOptions = time_opts + setattr(asset_write_opts, key, value) + multiverse.WriteComposition(file_path, members, comp_write_opts) if "representations" not in instance.data: From 783c315c7eaba812d6b9e16fde5b962c4ebae6d2 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 12:05:49 +0900 Subject: [PATCH 225/854] fixed creator of multiverse usd composition with more arguments --- .../hosts/maya/plugins/create/create_multiverse_usd_comp.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py index 5d216ddb9c..56b8721ce0 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py @@ -16,6 +16,8 @@ class CreateMultiverseUsdComp(plugin.Creator): self.data["mergeTransformAndShape"] = False self.data["flattenContent"] = False self.data["writePendingOverrides"] = False + self.data["numTimeSamples"] = 1 + self.data["timeSamplesSpan"] = 0.0 # Add animation data animation_data = lib.collect_animation_data(True) From d69d7cddf488dd109f9be3fbb38f13578f165bbb Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 12:07:06 +0900 Subject: [PATCH 226/854] fix multiverse composition extractor for arguments --- .../plugins/publish/extract_multiverse_usd_comp.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index c80a3cce6c..3876afb89c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -29,7 +29,9 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): "stripNamespaces": bool, "mergeTransformAndShape": bool, "flattenContent": bool, - "writePendingOverrides": bool + "writePendingOverrides": bool, + "numTimeSamples": int, + "timeSamplesSpan": float } @property @@ -37,10 +39,12 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): """The default options for Multiverse USD extraction.""" return { - "stripNamespaces": False, + "stripNamespaces": True, "mergeTransformAndShape": False, "flattenContent": False, - "writePendingOverrides": False + "writePendingOverrides": False, + "numTimeSamples": 1, + "timeSamplesSpan": 0.0 } def parse_overrides(self, instance, options): @@ -72,7 +76,7 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): # Define output file path staging_dir = self.staging_dir(instance) - file_name = "{}.usda".format(instance.name) + file_name = "{}.usd".format(instance.name) file_path = os.path.join(staging_dir, file_name) file_path = file_path.replace('\\', '/') @@ -129,7 +133,7 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): for key, value in options_items(): if key in options_discard_keys: continue - setattr(asset_write_opts, key, value) + setattr(comp_write_opts, key, value) multiverse.WriteComposition(file_path, members, comp_write_opts) From 3ab9c8c0bf09cf68e74e38684c9ab1dac2367820 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 12:07:37 +0900 Subject: [PATCH 227/854] fixed multiverse usd loader for new api --- .../hosts/maya/plugins/load/load_multiverse_usd.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index dac2244b5f..ce84c0baf8 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -2,8 +2,10 @@ import maya.cmds as cmds import maya.mel as mel -from avalon import api - +from openpype.pipeline import ( + load, + get_representation_path +) from openpype.hosts.maya.api.lib import ( maintained_selection, namespaced, @@ -12,7 +14,7 @@ from openpype.hosts.maya.api.lib import ( from openpype.hosts.maya.api.pipeline import containerise -class MultiverseUsdLoader(api.Loader): +class MultiverseUsdLoader(load.LoaderPlugin): """Load the USD by Multiverse""" families = ["model", "usd", "usdComposition", "usdOverride"] @@ -64,7 +66,7 @@ class MultiverseUsdLoader(api.Loader): shapes = cmds.ls(members, type="mvUsdCompoundShape") assert shapes, "Cannot find mvUsdCompoundShape in container" - path = api.get_representation_path(representation) + path = get_representation_path(representation) import multiverse for shape in shapes: From 498c968805bbaa1097582deee3999499245f03e4 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 13:43:42 +0900 Subject: [PATCH 228/854] use py3 style to iterate option dict --- openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index d7d1337930..29f806375e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -178,7 +178,6 @@ class ExtractMultiverseUsd(openpype.api.Extractor): time_opts.framePerSecond = fps asset_write_opts = multiverse.AssetWriteOptions(time_opts) - options_items = getattr(options, "iteritems", options.items) options_discard_keys = [ 'numTimeSamples', 'timeSamplesSpan', @@ -189,7 +188,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): 'step', 'fps' ] - for key, value in options_items(): + for key, value in options.items(): if key in options_discard_keys: continue setattr(asset_write_opts, key, value) From 0645dc8190db07e70a6f8c5caa12a47b3975fdb2 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 13:49:58 +0900 Subject: [PATCH 229/854] use py3 style to iterate option dict from multiverse usd composition extractor --- .../hosts/maya/plugins/publish/extract_multiverse_usd_comp.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index 3876afb89c..1d764d1221 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -119,7 +119,6 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): time_opts.framePerSecond = fps comp_write_opts = multiverse.CompositionWriteOptions() - options_items = getattr(options, "iteritems", options.items) options_discard_keys = [ 'numTimeSamples', 'timeSamplesSpan', @@ -130,7 +129,7 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): 'step', 'fps' ] - for key, value in options_items(): + for key, value in options.items(): if key in options_discard_keys: continue setattr(comp_write_opts, key, value) From fe2a3e8c2f086bd6dab61d9ac358a8a9ae12f66c Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 13:52:06 +0900 Subject: [PATCH 230/854] use usd as name and ext for representation from multiverse usd composition extractor --- .../hosts/maya/plugins/publish/extract_multiverse_usd_comp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index 1d764d1221..8a26379313 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -140,8 +140,8 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): instance.data["representations"] = [] representation = { - 'name': 'usda', - 'ext': 'usda', + 'name': 'usd', + 'ext': 'usd', 'files': file_name, "stagingDir": staging_dir } From d41d114bd3afcb25c98a1ce15f73322673eae210 Mon Sep 17 00:00:00 2001 From: DMO Date: Thu, 24 Mar 2022 16:49:57 +0900 Subject: [PATCH 231/854] updating create mv overwrite to correct data fields getting used. --- .../plugins/create/create_multiverse_usd_over.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py index bdec96c2ff..9ccf2e45fc 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py @@ -21,15 +21,9 @@ class CreateMultiverseUsdOver(plugin.Creator): self.data["writeVariantsDefinition"] = True self.data["writeActiveState"] = True self.data["writeNamespaces"] = False + self.data["numTimeSamples"] = 1 + self.data["timeSamplesSpan"] = 0.0 - # The attributes below are about animated cache. - self.data["writeTimeRange"] = True - self.data["timeRangeNumTimeSamples"] = 0 - self.data["timeRangeSamplesSpan"] = 0.0 - + # Add animation data animation_data = lib.collect_animation_data(True) - - self.data["timeRangeStart"] = animation_data["frameStart"] - self.data["timeRangeEnd"] = animation_data["frameEnd"] - self.data["timeRangeIncrement"] = animation_data["step"] - self.data["timeRangeFramesPerSecond"] = animation_data["fps"] + self.data.update(animation_data) From 20124bdc836839bea94dc480ef813fa03d828fad Mon Sep 17 00:00:00 2001 From: DMO Date: Thu, 24 Mar 2022 16:50:53 +0900 Subject: [PATCH 232/854] fix import - use direct api vs avalon --- .../hosts/maya/plugins/publish/extract_multiverse_usd_over.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index 406ff8ba11..aa355513f3 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -1,7 +1,7 @@ import os -import avalon.maya import openpype.api +from openpype.hosts.maya.api.lib import maintained_selection from maya import cmds @@ -88,7 +88,7 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): # Perform extraction self.log.info("Performing extraction ...") - with avalon.maya.maintained_selection(): + with maintained_selection(): members = instance.data("setMembers") members = cmds.ls(members, dag=True, From 06dce74a3682bd2f30f4085d628d6e1cd28103dc Mon Sep 17 00:00:00 2001 From: DMO Date: Thu, 24 Mar 2022 16:51:24 +0900 Subject: [PATCH 233/854] use a set instead of a list --- .../hosts/maya/plugins/publish/extract_multiverse_usd.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 29f806375e..2357690160 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -178,7 +178,8 @@ class ExtractMultiverseUsd(openpype.api.Extractor): time_opts.framePerSecond = fps asset_write_opts = multiverse.AssetWriteOptions(time_opts) - options_discard_keys = [ + options_items = getattr(options, "iteritems", options.items) + options_discard_keys = { 'numTimeSamples', 'timeSamplesSpan', 'frameStart', @@ -187,8 +188,8 @@ class ExtractMultiverseUsd(openpype.api.Extractor): 'handleEnd', 'step', 'fps' - ] - for key, value in options.items(): + } + for key, value in options_items(): if key in options_discard_keys: continue setattr(asset_write_opts, key, value) From 1cea33d94ca967a6bc5aaeb7f8b3a10016a71052 Mon Sep 17 00:00:00 2001 From: DMO Date: Thu, 24 Mar 2022 16:52:02 +0900 Subject: [PATCH 234/854] fixing the data getting used for the write options ; standardise on " vs '. --- .../publish/extract_multiverse_usd_over.py | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index aa355513f3..9ee6f99de4 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -35,13 +35,8 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): "writeVariantsDefinition": bool, "writeActiveState": bool, "writeNamespaces": bool, - "writeTimeRange": bool, - "timeRangeStart": int, - "timeRangeEnd": int, - "timeRangeIncrement": int, - "timeRangeNumTimeSamples": int, - "timeRangeSamplesSpan": float, - "timeRangeFramesPerSecond": float + "numTimeSamples": int, + "timeSamplesSpan": float } @property @@ -62,13 +57,8 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): "writeVariantsDefinition": True, "writeActiveState": True, "writeNamespaces": False, - "writeTimeRange": True, - "timeRangeStart": start_frame, - "timeRangeEnd": end_frame, - "timeRangeIncrement": 1, - "timeRangeNumTimeSamples": 0, - "timeRangeSamplesSpan": 0.0, - "timeRangeFramesPerSecond": 24.0 + "numTimeSamples": 1, + "timeSamplesSpan": 0.0 } def process(self, instance): @@ -79,7 +69,7 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): staging_dir = self.staging_dir(instance) file_name = "{}.usda".format(instance.name) file_path = os.path.join(staging_dir, file_name) - file_path = file_path.replace('\\', '/') + file_path = file_path.replace("\\", "/") # Parse export options options = self.default_options @@ -93,36 +83,48 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): members = cmds.ls(members, dag=True, shapes=True, - type=("mvUsdCompoundShape"), + type="mvUsdCompoundShape", noIntermediate=True, long=True) - self.log.info('Collected object {}'.format(members)) + self.log.info("Collected object {}".format(members)) # TODO: Deal with asset, composition, overide with options. import multiverse time_opts = None - if options["writeTimeRange"]: + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] + handle_start = instance.data["handleStart"] + handle_end = instance.data["handleEnd"] + step = instance.data["step"] + fps = instance.data["fps"] + if frame_end != frame_start: time_opts = multiverse.TimeOptions() time_opts.writeTimeRange = True + time_opts.frameRange = ( + frame_start - handle_start, frame_end + handle_end) + time_opts.frameIncrement = step + time_opts.numTimeSamples = instance.data["numTimeSamples"] + time_opts.timeSamplesSpan = instance.data["timeSamplesSpan"] + time_opts.framePerSecond = fps - time_range_start = options["timeRangeStart"] - time_range_end = options["timeRangeEnd"] - time_opts.frameRange = (time_range_start, time_range_end) - - time_opts.frameIncrement = options["timeRangeIncrement"] - time_opts.numTimeSamples = options["timeRangeNumTimeSamples"] - time_opts.timeSamplesSpan = options["timeRangeSamplesSpan"] - time_opts.framePerSecond = options["timeRangeFramesPerSecond"] - - over_write_opts = multiverse.OverridesWriteOptions() + over_write_opts = multiverse.OverridesWriteOptions(time_opts) options_items = getattr(options, "iteritems", options.items) - for (k, v) in options_items(): - if k == "writeTimeRange" or k.startswith("timeRange"): + options_discard_keys = { + "numTimeSamples", + "timeSamplesSpan", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "step", + "fps" + } + for key, value in options_items(): + if key in options_discard_keys: continue - setattr(over_write_opts, k, v) - over_write_opts.timeOptions = time_opts + setattr(over_write_opts, key, value) for member in members: multiverse.WriteOverrides(file_path, member, over_write_opts) @@ -131,9 +133,9 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): instance.data["representations"] = [] representation = { - 'name': 'usda', - 'ext': 'usda', - 'files': file_name, + "name": "usd", + "ext": "usd", + "files": file_name, "stagingDir": staging_dir } instance.data["representations"].append(representation) From 73e691f379765841bf2169b477a62e5eceaa2100 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 17:27:22 +0900 Subject: [PATCH 235/854] use cmds.listRelatives instead of mel at multiverse usd loader --- openpype/hosts/maya/plugins/load/load_multiverse_usd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index ce84c0baf8..5361f7a85b 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import maya.cmds as cmds -import maya.mel as mel from openpype.pipeline import ( load, @@ -44,7 +43,8 @@ class MultiverseUsdLoader(load.LoaderPlugin): with namespaced(namespace, new=False): import multiverse shape = multiverse.CreateUsdCompound(self.fname) - transform = mel.eval('firstParentOf "{}"'.format(shape)) + transform = cmds.listRelatives( + shape, parent=True, fullPath=True)[0] nodes = [transform, shape] self[:] = nodes From 7f18f94abce80194783d3d6032fa39ac834ef577 Mon Sep 17 00:00:00 2001 From: DMO Date: Thu, 24 Mar 2022 17:28:09 +0900 Subject: [PATCH 236/854] removing unused variables. --- .../hosts/maya/plugins/publish/extract_multiverse_usd_over.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index 9ee6f99de4..b26d6421fd 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -42,10 +42,6 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): @property def default_options(self): """The default options for Multiverse USD extraction.""" - start_frame = int(cmds.playbackOptions(query=True, - animationStartTime=True)) - end_frame = int(cmds.playbackOptions(query=True, - animationEndTime=True)) return { "writeAll": False, From 70c317005cf37fc741e269eb1648fb3398871aab Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 09:42:18 +0100 Subject: [PATCH 237/854] add python version specific vendors outside of launch hook --- openpype/hooks/pre_python_2_prelaunch.py | 35 ------------------------ openpype/lib/applications.py | 35 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 35 deletions(-) delete mode 100644 openpype/hooks/pre_python_2_prelaunch.py diff --git a/openpype/hooks/pre_python_2_prelaunch.py b/openpype/hooks/pre_python_2_prelaunch.py deleted file mode 100644 index 84272d2e5d..0000000000 --- a/openpype/hooks/pre_python_2_prelaunch.py +++ /dev/null @@ -1,35 +0,0 @@ -import os -from openpype.lib import PreLaunchHook - - -class PrePython2Vendor(PreLaunchHook): - """Prepend python 2 dependencies for py2 hosts.""" - order = 10 - - def execute(self): - if not self.application.use_python_2: - return - - # Prepare vendor dir path - self.log.info("adding global python 2 vendor") - pype_root = os.getenv("OPENPYPE_REPOS_ROOT") - python_2_vendor = os.path.join( - pype_root, - "openpype", - "vendor", - "python", - "python_2" - ) - - # Add Python 2 modules - python_paths = [ - python_2_vendor - ] - - # Load PYTHONPATH from current launch context - python_path = self.launch_context.env.get("PYTHONPATH") - if python_path: - python_paths.append(python_path) - - # Set new PYTHONPATH to launch context environments - self.launch_context.env["PYTHONPATH"] = os.pathsep.join(python_paths) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index e72585c75a..fcb5226606 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1118,6 +1118,39 @@ class ApplicationLaunchContext: # Return process which is already terminated return process + def _add_python_version_paths(self): + """Add vendor packages specific for a Python version.""" + + # Skip adding if host name is not set + if not self.application.host_name: + return + + # Add Python 2/3 modules + openpype_root = os.getenv("OPENPYPE_REPOS_ROOT") + python_vendor_dir = os.path.join( + openpype_root, + "openpype", + "vendor", + "python" + ) + python_paths = [] + if self.application.use_python_2: + python_paths.append( + os.path.join(python_vendor_dir, "python_2") + ) + else: + python_paths.append( + os.path.join(python_vendor_dir, "python_3") + ) + + # Load PYTHONPATH from current launch context + python_path = self.env.get("PYTHONPATH") + if python_path: + python_paths.append(python_path) + + # Set new PYTHONPATH to launch context environments + self.env["PYTHONPATH"] = os.pathsep.join(python_paths) + def launch(self): """Collect data for new process and then create it. @@ -1130,6 +1163,8 @@ class ApplicationLaunchContext: self.log.warning("Application was already launched.") return + self._add_python_version_paths() + # Discover launch hooks self.discover_launch_hooks() From e64797c4337c97b9119a7eaf704f806c09a46def Mon Sep 17 00:00:00 2001 From: DMO Date: Thu, 24 Mar 2022 17:43:43 +0900 Subject: [PATCH 238/854] switch do more readable options.items() --- .../hosts/maya/plugins/publish/extract_multiverse_usd_over.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index b26d6421fd..83d840d045 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -106,7 +106,6 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): time_opts.framePerSecond = fps over_write_opts = multiverse.OverridesWriteOptions(time_opts) - options_items = getattr(options, "iteritems", options.items) options_discard_keys = { "numTimeSamples", "timeSamplesSpan", @@ -117,7 +116,7 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): "step", "fps" } - for key, value in options_items(): + for key, value in options.items(): if key in options_discard_keys: continue setattr(over_write_opts, key, value) From 600a07237c8412468ce72e7d5951535acedc291b Mon Sep 17 00:00:00 2001 From: DMO Date: Thu, 24 Mar 2022 17:45:10 +0900 Subject: [PATCH 239/854] removed extra space. --- .../hosts/maya/plugins/publish/extract_multiverse_usd_over.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py index 83d840d045..ce0e8a392a 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_over.py @@ -116,7 +116,7 @@ class ExtractMultiverseUsdOverride(openpype.api.Extractor): "step", "fps" } - for key, value in options.items(): + for key, value in options.items(): if key in options_discard_keys: continue setattr(over_write_opts, key, value) From ef832181e4e0a3e6f0e7e06f7d06e800ab17a79a Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 17:46:28 +0900 Subject: [PATCH 240/854] use py3 style to iterate options dict --- openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py index 2357690160..4e4efdc32c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd.py @@ -178,7 +178,6 @@ class ExtractMultiverseUsd(openpype.api.Extractor): time_opts.framePerSecond = fps asset_write_opts = multiverse.AssetWriteOptions(time_opts) - options_items = getattr(options, "iteritems", options.items) options_discard_keys = { 'numTimeSamples', 'timeSamplesSpan', @@ -189,7 +188,7 @@ class ExtractMultiverseUsd(openpype.api.Extractor): 'step', 'fps' } - for key, value in options_items(): + for key, value in options.items(): if key in options_discard_keys: continue setattr(asset_write_opts, key, value) From 014d0d8c53946a494843a4f735a35f1c4a04172f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 09:48:46 +0100 Subject: [PATCH 241/854] moved adding of vendor paths to prepare_app_environments --- openpype/lib/applications.py | 72 ++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index fcb5226606..ad59ae0dbc 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1118,39 +1118,6 @@ class ApplicationLaunchContext: # Return process which is already terminated return process - def _add_python_version_paths(self): - """Add vendor packages specific for a Python version.""" - - # Skip adding if host name is not set - if not self.application.host_name: - return - - # Add Python 2/3 modules - openpype_root = os.getenv("OPENPYPE_REPOS_ROOT") - python_vendor_dir = os.path.join( - openpype_root, - "openpype", - "vendor", - "python" - ) - python_paths = [] - if self.application.use_python_2: - python_paths.append( - os.path.join(python_vendor_dir, "python_2") - ) - else: - python_paths.append( - os.path.join(python_vendor_dir, "python_3") - ) - - # Load PYTHONPATH from current launch context - python_path = self.env.get("PYTHONPATH") - if python_path: - python_paths.append(python_path) - - # Set new PYTHONPATH to launch context environments - self.env["PYTHONPATH"] = os.pathsep.join(python_paths) - def launch(self): """Collect data for new process and then create it. @@ -1163,8 +1130,6 @@ class ApplicationLaunchContext: self.log.warning("Application was already launched.") return - self._add_python_version_paths() - # Discover launch hooks self.discover_launch_hooks() @@ -1354,6 +1319,41 @@ def _merge_env(env, current_env): return result +def _add_python_version_paths(app, env, logger): + """Add vendor packages specific for a Python version.""" + + # Skip adding if host name is not set + if not app.host_name: + return + + # Add Python 2/3 modules + openpype_root = os.getenv("OPENPYPE_REPOS_ROOT") + python_vendor_dir = os.path.join( + openpype_root, + "openpype", + "vendor", + "python" + ) + if app.use_python_2: + pythonpath = os.path.join(python_vendor_dir, "python_2") + else: + pythonpath = os.path.join(python_vendor_dir, "python_3") + + if not os.path.exists(pythonpath): + return + + logger.debug("Adding Python version specific paths to PYTHONPATH") + python_paths = [pythonpath] + + # Load PYTHONPATH from current launch context + python_path = env.get("PYTHONPATH") + if python_path: + python_paths.append(python_path) + + # Set new PYTHONPATH to launch context environments + env["PYTHONPATH"] = os.pathsep.join(python_paths) + + def prepare_app_environments(data, env_group=None, implementation_envs=True): """Modify launch environments based on launched app and context. @@ -1366,6 +1366,8 @@ def prepare_app_environments(data, env_group=None, implementation_envs=True): app = data["app"] log = data["log"] + _add_python_version_paths(app, data["env"], log) + # `added_env_keys` has debug purpose added_env_keys = {app.group.name, app.name} # Environments for application From 6ea037175a03db3efc022a42859120754f8a4b28 Mon Sep 17 00:00:00 2001 From: Bo Zhou Date: Thu, 24 Mar 2022 17:57:18 +0900 Subject: [PATCH 242/854] use set instead of list for discard option keys from multiverse composition extractor --- .../hosts/maya/plugins/publish/extract_multiverse_usd_comp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py index 8a26379313..8fccc412e6 100644 --- a/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/publish/extract_multiverse_usd_comp.py @@ -119,7 +119,7 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): time_opts.framePerSecond = fps comp_write_opts = multiverse.CompositionWriteOptions() - options_discard_keys = [ + options_discard_keys = { 'numTimeSamples', 'timeSamplesSpan', 'frameStart', @@ -128,7 +128,7 @@ class ExtractMultiverseUsdComposition(openpype.api.Extractor): 'handleEnd', 'step', 'fps' - ] + } for key, value in options.items(): if key in options_discard_keys: continue From 2621225046ac8b60942834b03fd6c68655d48c31 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 10:18:31 +0100 Subject: [PATCH 243/854] fix logger import --- openpype/pipeline/plugin_discover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/plugin_discover.py b/openpype/pipeline/plugin_discover.py index b5edda7e9d..fb860fe5f2 100644 --- a/openpype/pipeline/plugin_discover.py +++ b/openpype/pipeline/plugin_discover.py @@ -2,7 +2,7 @@ import os import inspect import traceback -from openpype.lib import Logger +from openpype.api import Logger from openpype.lib.python_module_tools import ( modules_from_path, classes_from_module, From be652995e8d4778f4c4997a6792e4510511a3c89 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 11:21:41 +0100 Subject: [PATCH 244/854] added extractor converting pngs to exr from tvpaint --- .../plugins/publish/extract_convert_to_exr.py | 96 +++++++++++++++++++ .../plugins/publish/extract_sequence.py | 1 - 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py b/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py new file mode 100644 index 0000000000..5fc9be9f42 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py @@ -0,0 +1,96 @@ +"""Plugin converting png files from ExtractSequence into exrs. + +Requires: + ExtractSequence - source of PNG + ExtractReview - review was already created so we can convert to any exr +""" +import os +import json + +import pyblish.api +from openpype.lib import ( + get_oiio_tools_path, + run_subprocess, +) +from openpype.pipeline import KnownPublishError + + +class ExtractConvertToEXR(pyblish.api.InstancePlugin): + # Offset to get after ExtractSequence plugin. + order = pyblish.api.ExtractorOrder + 0.1 + label = "Extract Sequence EXR" + hosts = ["tvpaint"] + families = ["render"] + + active = False + + replace_pngs = True + exr_compression = "DWAA" + + def process(self, instance): + repres = instance.data.get("representations") + if not repres: + return + + oiio_path = get_oiio_tools_path() + # Raise an exception when oiiotool is not available + # - this can currently happen on MacOS machines + if not os.path.exists(oiio_path): + KnownPublishError( + "OpenImageIO tool is not available on this machine." + ) + + new_repres = [] + for repre in repres: + if repre["name"] != "png": + continue + + self.log.info( + "Processing representation: {}".format( + json.dumps(repre, sort_keys=True, indent=4) + ) + ) + + src_filepaths = set() + new_filenames = [] + for src_filename in repre["files"]: + dst_filename = os.path.splitext(src_filename)[0] + ".exr" + new_filenames.append(dst_filename) + + src_filepath = os.path.join(repre["stagingDir"], src_filename) + dst_filepath = os.path.join(repre["stagingDir"], dst_filename) + + src_filepaths.add(src_filepath) + + args = [ + oiio_path, src_filepath, + "--compression", self.exr_compression, + # TODO how to define color conversion? + "--colorconvert", "sRGB", "linear", + "-o", dst_filepath + ] + run_subprocess(args) + + new_repres.append( + { + "name": "exr", + "ext": "exr", + "files": new_filenames, + "stagingDir": repre["stagingDir"], + "tags": list(repre["tags"]) + } + ) + + if self.replace_pngs: + instance.data["representations"].remove(repre) + for filepath in src_filepaths: + os.remove(filepath) + + instance.data["representations"].extend(new_repres) + self.log.info( + "Representations: {}".format( + json.dumps( + instance.data["representations"], sort_keys=True, indent=4 + ) + ) + ) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 729c545545..139dabadee 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -12,7 +12,6 @@ from openpype.hosts.tvpaint.lib import ( fill_reference_frames, composite_rendered_layers, rename_filepaths_by_frame_start, - composite_images ) From 41a66e86775637a9215e6887a24627755d508a4a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 11:22:14 +0100 Subject: [PATCH 245/854] added settings for new plugin --- .../defaults/project_settings/tvpaint.json | 5 +++ .../schema_project_tvpaint.json | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index 528bf6de8e..46beeb85b9 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -28,6 +28,11 @@ "enabled": true, "optional": true, "active": true + }, + "ExtractConvertToEXR": { + "enabled": false, + "replace_pngs": true, + "exr_compression": "ZIP" } }, "load": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index 8286ed1193..97462a8b62 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -78,6 +78,47 @@ "docstring": "Validate if shot on instances metadata is same as workfiles shot" } ] + }, + { + "type": "dict", + "key": "ExtractConvertToEXR", + "label": "Extract Convert To EXR", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "WARNING: This plugin does not work on MacOS (using OIIO tool)." + }, + { + "type": "boolean", + "key": "replace_pngs", + "label": "Replace source PNG" + }, + { + "type": "enum", + "key": "exr_compression", + "label": "EXR Compression", + "multiselection": false, + "enum_items": [ + {"ZIP": "ZIP"}, + {"ZIPS": "ZIPS"}, + {"DWAA": "DWAA"}, + {"DWAB": "DWAB"}, + {"PIZ": "PIZ"}, + {"RLE": "RLE"}, + {"PXR24": "PXR24"}, + {"B44": "B44"}, + {"B44A": "B44A"}, + {"none": "None"} + ] + } + ] } ] }, From aaa4c1d54ce71771bdc9a8608c42a5e5213b6b47 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 24 Mar 2022 11:53:57 +0100 Subject: [PATCH 246/854] Add generic exception handling for Slack notification --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 018a7594bb..c0392b0195 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -210,6 +210,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): # You will get a SlackApiError if "ok" is False error_str = self._enrich_error(str(e.response["error"]), channel) self.log.warning("Error happened {}".format(error_str)) + except Exception as e: + error_str = self._enrich_error(str(e), channel) + self.log.warning("Not SlackAPI error: {}".format(error_str)) return None, [] From 4442ef71289fb6b9fdd9694ed5516dedfb73732d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 24 Mar 2022 11:57:56 +0100 Subject: [PATCH 247/854] Update openpype/modules/slack/plugins/publish/integrate_slack_api.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index c0392b0195..f236662361 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -212,7 +212,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): self.log.warning("Error happened {}".format(error_str)) except Exception as e: error_str = self._enrich_error(str(e), channel) - self.log.warning("Not SlackAPI error: {}".format(error_str)) + self.log.warning("Not SlackAPI error", exc_info=True) return None, [] From 9fb6d7a7230149b9d1c7b273eb8fcc532a84a3fb Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 12:04:29 +0100 Subject: [PATCH 248/854] flame: batchgroup uses duration --- openpype/hosts/flame/api/batch_utils.py | 14 ++++++-------- .../flame/plugins/publish/integrate_batch_group.py | 5 +++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/flame/api/batch_utils.py b/openpype/hosts/flame/api/batch_utils.py index d309c5985d..a47d62a10e 100644 --- a/openpype/hosts/flame/api/batch_utils.py +++ b/openpype/hosts/flame/api/batch_utils.py @@ -1,7 +1,7 @@ import flame -def create_batch(name, frame_start, frame_end, **kwargs): +def create_batch(name, frame_start, frame_duration, **kwargs): """Create Batch Group in active project's Desktop Args: @@ -13,20 +13,18 @@ def create_batch(name, frame_start, frame_end, **kwargs): shelf_reels = kwargs.get("shelf_reels") or ['ShelfReel1'] write_pref = kwargs["write_pref"] - handle_start = kwargs.get("handleStart") - handle_end = kwargs.get("handleEnd") + handle_start = kwargs.get("handleStart") or 0 + handle_end = kwargs.get("handleEnd") or 0 - if handle_start: - frame_start -= handle_start - if handle_end: - frame_end += handle_end + frame_start -= handle_start + frame_duration += handle_start + handle_end # Create batch group with name, start_frame value, duration value, # set of schematic reel names, set of shelf reel names flame.batch.create_batch_group( name, start_frame=frame_start, - duration=frame_end, + duration=frame_duration, reels=schematic_reels, shelf_reels=shelf_reels ) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 808c059816..0a21d6ca2d 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -19,6 +19,7 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): frame_end = instance.data["frameEnd"] handle_start = instance.data["handleStart"] handle_end = instance.data["handleEnd"] + frame_duration = (frame_end - frame_start) + 1 asset_name = instance.data["asset"] add_tasks = instance.data["flameAddTasks"] @@ -46,8 +47,8 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): opfapi.create_batch( batchgroup_name, frame_start, - frame_end, - batch_data + frame_duration, + **batch_data ) def _get_write_prefs(self, instance, task_data): From c68a93b467135fa02f48e41db10b3464ed9379bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 12:10:43 +0100 Subject: [PATCH 249/854] added plugin which prepare keys for ExplicitCleanUp plugin --- .../plugins/publish/collect_cleanup_keys.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 openpype/plugins/publish/collect_cleanup_keys.py diff --git a/openpype/plugins/publish/collect_cleanup_keys.py b/openpype/plugins/publish/collect_cleanup_keys.py new file mode 100644 index 0000000000..635b038387 --- /dev/null +++ b/openpype/plugins/publish/collect_cleanup_keys.py @@ -0,0 +1,21 @@ +""" +Requires: + None +Provides: + context + - cleanupFullPaths (list) + - cleanupEmptyDirs (list) +""" + +import pyblish.api + + +class CollectCleanupKeys(pyblish.api.ContextPlugin): + """Prepare keys for 'ExplicitCleanUp' plugin.""" + + label = "Collect Cleanup Keys" + order = pyblish.api.CollectorOrder + + def process(self, context): + context.data["cleanupFullPaths"] = [] + context.data["cleanupEmptyDirs"] = [] From 62d7bcc0d027f26fc62620a0f670ec32f5fbd399 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 12:11:01 +0100 Subject: [PATCH 250/854] changed default attributes --- .../hosts/tvpaint/plugins/publish/extract_convert_to_exr.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py b/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py index 5fc9be9f42..522173803b 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py @@ -22,10 +22,12 @@ class ExtractConvertToEXR(pyblish.api.InstancePlugin): hosts = ["tvpaint"] families = ["render"] - active = False + enabled = False + # Replace source PNG files or just add replace_pngs = True - exr_compression = "DWAA" + # EXR compression + exr_compression = "ZIP" def process(self, instance): repres = instance.data.get("representations") From 54b0e2ec9cf0e37228c1ece66f4728e2cb53ac79 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 12:11:19 +0100 Subject: [PATCH 251/854] don't remove the files but add to cleanup data --- .../hosts/tvpaint/plugins/publish/extract_convert_to_exr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py b/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py index 522173803b..ab5bbc5e2c 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_convert_to_exr.py @@ -85,8 +85,9 @@ class ExtractConvertToEXR(pyblish.api.InstancePlugin): if self.replace_pngs: instance.data["representations"].remove(repre) + for filepath in src_filepaths: - os.remove(filepath) + instance.context.data["cleanupFullPaths"].append(filepath) instance.data["representations"].extend(new_repres) self.log.info( From a87f778f1e95d16d09097d923ca3f1d519e86126 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 12:56:18 +0100 Subject: [PATCH 252/854] flame: reuse batch groups --- openpype/hosts/flame/api/__init__.py | 4 ++- openpype/hosts/flame/api/lib.py | 9 +++++++ .../plugins/publish/integrate_batch_group.py | 26 ++++++++++++++----- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 97f83ccf07..561aaab3de 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -29,7 +29,8 @@ from .lib import ( get_frame_from_filename, get_padding_from_filename, maintained_object_duplication, - get_clip_segment + get_clip_segment, + get_batch_group_from_desktop ) from .utils import ( setup, @@ -105,6 +106,7 @@ __all__ = [ "get_padding_from_filename", "maintained_object_duplication", "get_clip_segment", + "get_batch_group_from_desktop", # pipeline "install", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index 74d9e7607a..9a6b86209d 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -708,3 +708,12 @@ def get_clip_segment(flame_clip): raise ValueError("Clip `{}` has too many segments!".format(name)) return segments[0] + + +def get_batch_group_from_desktop(name): + project = get_current_project() + project_desktop = project.current_workspace.desktop + + for bgroup in project_desktop.batch_groups: + if bgroup.name.get_value() == name: + return bgroup diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 0a21d6ca2d..3a8173791a 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -43,13 +43,25 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): self.log.debug( "__ batch_data: {}".format(pformat(batch_data))) - # create batch with utils - opfapi.create_batch( - batchgroup_name, - frame_start, - frame_duration, - **batch_data - ) + # check if the batch group already exists + bgroup = opfapi.get_batch_group_from_desktop(batchgroup_name) + + if not bgroup: + self.log.info( + "Creating new batch group: {}".format(batchgroup_name)) + # create batch with utils + opfapi.create_batch( + batchgroup_name, + frame_start, + frame_duration, + **batch_data + ) + else: + self.log.info( + "Updating batch group: {}".format(batchgroup_name)) + # update already created batch group + bgroup.start_frame = frame_start + bgroup.duration = frame_duration def _get_write_prefs(self, instance, task_data): anatomy_data = instance.data["anatomyData"] From c26ff2ab544fadc7121d54ec49a2b35433e6122a Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 12:56:36 +0100 Subject: [PATCH 253/854] flame: fix task name on write file node --- openpype/hosts/flame/plugins/publish/integrate_batch_group.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 3a8173791a..af2b0fad65 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -65,6 +65,8 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): def _get_write_prefs(self, instance, task_data): anatomy_data = instance.data["anatomyData"] + # update task data in anatomy data + anatomy_data.update(task_data) task_workfile_path = self._get_shot_task_dir_path(instance, task_data) self.log.debug("__ task_workfile_path: {}".format(task_workfile_path)) From 543e80e84f94252a6ea99c3fd15a660f92c0e3a8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 13:22:24 +0100 Subject: [PATCH 254/854] added deafult implementation of optional pyblish plugin which will show attributes in new publisher UI --- openpype/pipeline/__init__.py | 4 +- openpype/pipeline/publish/__init__.py | 2 + openpype/pipeline/publish/publish_plugins.py | 58 ++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 6ed307dbc7..511e4c7b94 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -41,7 +41,8 @@ from .publish import ( PublishValidationError, PublishXmlValidationError, KnownPublishError, - OpenPypePyblishPluginMixin + OpenPypePyblishPluginMixin, + OptionalPyblishPluginMixin, ) from .actions import ( @@ -105,6 +106,7 @@ __all__ = ( "PublishXmlValidationError", "KnownPublishError", "OpenPypePyblishPluginMixin", + "OptionalPyblishPluginMixin", # --- Actions --- "LauncherAction", diff --git a/openpype/pipeline/publish/__init__.py b/openpype/pipeline/publish/__init__.py index c2729a46ce..af5d7c4a91 100644 --- a/openpype/pipeline/publish/__init__.py +++ b/openpype/pipeline/publish/__init__.py @@ -3,6 +3,7 @@ from .publish_plugins import ( PublishXmlValidationError, KnownPublishError, OpenPypePyblishPluginMixin, + OptionalPyblishPluginMixin, ) from .lib import ( @@ -18,6 +19,7 @@ __all__ = ( "PublishXmlValidationError", "KnownPublishError", "OpenPypePyblishPluginMixin", + "OptionalPyblishPluginMixin", "DiscoverResult", "publish_plugins_discover", diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index bce64ec709..6b908c3ae3 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -1,3 +1,4 @@ +from openpype.lib import BoolDef from .lib import load_help_content_from_plugin @@ -108,3 +109,60 @@ class OpenPypePyblishPluginMixin: plugin_values[key] ) return attribute_values + + def get_attr_values_from_data(self, data): + """Get attribute values for attribute definitoins from data. + + Args: + data(dict): Data from instance or context. + """ + return ( + data + .get("publish_attributes", {}) + .get(self.__class__.__name__, {}) + ) + + +class OptionalPyblishPluginMixin(OpenPypePyblishPluginMixin): + """Prepare mixin for optional plugins. + + Defined active attribute definition prepared for published and + prepares method which will check if is active or not. + + ``` + def process(self, instance): + # Skip the instance if is not active by data on the instance + if not self.is_active(instance.data): + return + ``` + """ + + @classmethod + def get_attribute_defs(cls): + """Attribute definitions based on plugin's optional attribute.""" + + # Empty list if plugin is not optional + if not getattr(cls, "optional", None): + return [] + + # Get active value from class as default value + active = getattr(cls, "active", True) + # Return boolean stored under 'active' key with label of the class name + return [ + BoolDef("active", default=active, label=cls.__name__) + ] + + def is_active(self, data): + """Check if plugins is active for instance/context based on their data. + + Args: + data(dict): Data from instance or context. + """ + # Skip if is not optional and return True + if not getattr(self, "optional", None): + return True + attr_values = self.get_attr_values_from_data(data) + active = attr_values.get("active") + if active is None: + active = getattr(self, "active", True) + return active From d0c4f188c75edaab4e9b998c105fe541af8ff003 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 13:25:24 +0100 Subject: [PATCH 255/854] added better example in docstring --- openpype/pipeline/publish/publish_plugins.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index 6b908c3ae3..be5efa34c1 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -130,10 +130,13 @@ class OptionalPyblishPluginMixin(OpenPypePyblishPluginMixin): prepares method which will check if is active or not. ``` - def process(self, instance): - # Skip the instance if is not active by data on the instance - if not self.is_active(instance.data): - return + class ValidateScene( + pyblish.api.InstancePlugin, OptionalPyblishPluginMixin + ): + def process(self, instance): + # Skip the instance if is not active by data on the instance + if not self.is_active(instance.data): + return ``` """ From 3a2603d8f735395f5925ca4de67fee7a12b4c13d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 13:26:55 +0100 Subject: [PATCH 256/854] changed label --- openpype/pipeline/publish/publish_plugins.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index be5efa34c1..83d6c717d0 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -151,8 +151,9 @@ class OptionalPyblishPluginMixin(OpenPypePyblishPluginMixin): # Get active value from class as default value active = getattr(cls, "active", True) # Return boolean stored under 'active' key with label of the class name + label = cls.label or cls.__name__ return [ - BoolDef("active", default=active, label=cls.__name__) + BoolDef("active", default=active, label=label) ] def is_active(self, data): From 5402a99634f932e9a423d073fbc6cb53baf30283 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 13:31:38 +0100 Subject: [PATCH 257/854] fix imports in scene inventory --- openpype/tools/sceneinventory/switch_dialog.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index 252f5cde4c..bb3e2615ac 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -4,11 +4,12 @@ from Qt import QtWidgets, QtCore import qtawesome from bson.objectid import ObjectId -from avalon import io, pipeline -from openpype.pipeline import ( +from avalon import io +from openpype.pipeline.load import ( discover_loader_plugins, switch_container, get_repres_contexts, + loaders_from_repre_context, ) from .widgets import ( @@ -370,7 +371,7 @@ class SwitchAssetDialog(QtWidgets.QDialog): loaders = None for repre_context in repre_contexts.values(): - _loaders = set(pipeline.loaders_from_repre_context( + _loaders = set(loaders_from_repre_context( available_loaders, repre_context )) if loaders is None: From cf40d52d4d4c1e201e928d0d77730fe18d7f8e3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 13:31:45 +0100 Subject: [PATCH 258/854] removed debug prints --- openpype/pipeline/actions.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/pipeline/actions.py b/openpype/pipeline/actions.py index a045c92aa7..141e277db3 100644 --- a/openpype/pipeline/actions.py +++ b/openpype/pipeline/actions.py @@ -115,10 +115,8 @@ def discover_inventory_actions(): filtered_actions = [] for action in actions: if action is not InventoryAction: - print("DISCOVERED", action) filtered_actions.append(action) - else: - print("GOT SOURCE") + return filtered_actions From 3a54f370b8b86279f345f5058e468a87149c6a72 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 24 Mar 2022 13:46:04 +0100 Subject: [PATCH 259/854] Fix docstring Co-authored-by: Roy Nieterau --- openpype/pipeline/publish/publish_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index 83d6c717d0..2402a005c2 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -111,7 +111,7 @@ class OpenPypePyblishPluginMixin: return attribute_values def get_attr_values_from_data(self, data): - """Get attribute values for attribute definitoins from data. + """Get attribute values for attribute definitions from data. Args: data(dict): Data from instance or context. From ce4caeabd3f02262374e5bff7121741602e526ff Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 24 Mar 2022 13:47:07 +0100 Subject: [PATCH 260/854] Added configurable maximum file size of review upload to Slack --- .../plugins/publish/collect_slack_family.py | 29 +++++++++++-------- .../plugins/publish/integrate_slack_api.py | 18 ++++++++++-- .../projects_schema/schema_project_slack.json | 9 ++++++ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/collect_slack_family.py b/openpype/modules/slack/plugins/publish/collect_slack_family.py index 6c965b04cd..7475bdc89e 100644 --- a/openpype/modules/slack/plugins/publish/collect_slack_family.py +++ b/openpype/modules/slack/plugins/publish/collect_slack_family.py @@ -35,20 +35,25 @@ class CollectSlackFamilies(pyblish.api.InstancePlugin): return # make slack publishable - if profile: - self.log.info("Found profile: {}".format(profile)) - if instance.data.get('families'): - instance.data['families'].append('slack') - else: - instance.data['families'] = ['slack'] + if not profile: + return - instance.data["slack_channel_message_profiles"] = \ - profile["channel_messages"] + self.log.info("Found profile: {}".format(profile)) + if instance.data.get('families'): + instance.data['families'].append('slack') + else: + instance.data['families'] = ['slack'] - slack_token = (instance.context.data["project_settings"] - ["slack"] - ["token"]) - instance.data["slack_token"] = slack_token + selected_profiles = profile["channel_messages"] + for prof in selected_profiles: + prof["review_upload_limit"] = profile.get("review_upload_limit", + 50) + instance.data["slack_channel_message_profiles"] = selected_profiles + + slack_token = (instance.context.data["project_settings"] + ["slack"] + ["token"]) + instance.data["slack_token"] = slack_token def main_family_from_instance(self, instance): # TODO yank from integrate """Returns main family of entered instance.""" diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 018a7594bb..8fde25b42d 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -35,7 +35,7 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): message = self._get_filled_message(message_profile["message"], instance, review_path) - self.log.info("message:: {}".format(message)) + self.log.debug("message:: {}".format(message)) if not message: return @@ -43,7 +43,8 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): publish_files.add(thumbnail_path) if message_profile["upload_review"] and review_path: - publish_files.add(review_path) + message, publish_files = self._handle_review_upload( + message, message_profile, publish_files, review_path) project = instance.context.data["anatomyData"]["project"]["code"] for channel in message_profile["channels"]: @@ -75,6 +76,19 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): dbcon = mongo_client[database_name]["notification_messages"] dbcon.insert_one(msg) + def _handle_review_upload(self, message, message_profile, publish_files, + review_path): + """Check if uploaded file is not too large""" + review_file_size_MB = os.path.getsize(review_path) / 1024 / 1024 + file_limit = message_profile.get("review_upload_limit", 50) + if review_file_size_MB > file_limit: + if review_path not in message: + message += "\n Review upload omitted because of " + \ + "file size, file located at: {}".format(review_path) + else: + publish_files.add(review_path) + return message, publish_files + def _get_filled_message(self, message_templ, instance, review_path=None): """Use message_templ and data from instance to get message content. diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 14814d8b01..9ca7c35e10 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -75,6 +75,15 @@ "type": "list", "object_type": "text" }, + { + "type": "number", + "key": "review_upload_limit", + "label": "Upload review of maximally size (MB)", + "decimal": 2, + "default": 50, + "minimum": 0, + "maximum": 1000000 + }, { "type": "separator" }, From 7d3053c6c358539795626db69f8a463283356fbc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 14:22:11 +0100 Subject: [PATCH 261/854] removed deprecated argument 'options' --- openpype/hosts/testhost/plugins/create/auto_creator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/testhost/plugins/create/auto_creator.py b/openpype/hosts/testhost/plugins/create/auto_creator.py index d5935602a0..4c22eea9dd 100644 --- a/openpype/hosts/testhost/plugins/create/auto_creator.py +++ b/openpype/hosts/testhost/plugins/create/auto_creator.py @@ -30,7 +30,7 @@ class MyAutoCreator(AutoCreator): def update_instances(self, update_list): pipeline.update_instances(update_list) - def create(self, options=None): + def create(self): existing_instance = None for instance in self.create_context.instances: if instance.family == self.family: From 590e966a7d18a4c1f7dea0e08a3056a202607670 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 14:29:49 +0100 Subject: [PATCH 262/854] flame: updating anatomy data with correct task data --- .../plugins/publish/integrate_batch_group.py | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index af2b0fad65..c54eeec05c 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -1,4 +1,5 @@ import os +import copy from pprint import pformat import pyblish from openpype.lib import get_workdir @@ -63,10 +64,28 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): bgroup.start_frame = frame_start bgroup.duration = frame_duration - def _get_write_prefs(self, instance, task_data): - anatomy_data = instance.data["anatomyData"] + def _get_anamoty_data_with_current_task(self, instance, task_data): + anatomy_data = copy.deepcopy(instance.data["anatomyData"]) + task_name = task_data["name"] + task_type = task_data["type"] + anatomy_obj = instance.context.data["anatomy"] + # update task data in anatomy data - anatomy_data.update(task_data) + project_task_types = anatomy_obj["tasks"] + task_code = project_task_types.get(task_type, {}).get("short_name") + anatomy_data.update({ + "task": { + "name": task_name, + "type": task_type, + "short": task_code + } + }) + return anatomy_data + + def _get_write_prefs(self, instance, task_data): + # update task in anatomy data + anatomy_data = self._get_anamoty_data_with_current_task( + instance, task_data) task_workfile_path = self._get_shot_task_dir_path(instance, task_data) self.log.debug("__ task_workfile_path: {}".format(task_workfile_path)) From 18d883ff0f4c44b6f5d3f46e3b1d26b985766493 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 14:31:57 +0100 Subject: [PATCH 263/854] anatomy data with correct task short key --- 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 05d2ffd821..b4e6abb72d 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1705,7 +1705,7 @@ def _get_task_context_data_for_anatomy( "task": { "name": task_name, "type": task_type, - "short_name": project_task_type_data["short_name"] + "short": project_task_type_data["short_name"] } } From c6cfdfbd3aac60dd8469048354b423f39edd4e9a Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Thu, 24 Mar 2022 16:41:39 +0300 Subject: [PATCH 264/854] Added new file `flagging.py` to new farm directory --- openpype/pipeline/farm/flagging.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 openpype/pipeline/farm/flagging.py diff --git a/openpype/pipeline/farm/flagging.py b/openpype/pipeline/farm/flagging.py new file mode 100644 index 0000000000..e69de29bb2 From 1aa56e10d4f0dc2b23dc1512268d6c3ae3e64da7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 14:47:23 +0100 Subject: [PATCH 265/854] nuke: python3 compatibility issue with `iteritems` --- openpype/hosts/nuke/plugins/load/load_effects.py | 6 +++--- openpype/hosts/nuke/plugins/load/load_effects_ip.py | 6 +++--- openpype/hosts/nuke/plugins/load/load_gizmo_ip.py | 2 +- .../nuke/plugins/publish/validate_write_deadline_tab.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 68c3952942..675ac9d46f 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -72,7 +72,7 @@ class LoadEffects(load.LoaderPlugin): # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) - for key, value in json.load(f).iteritems()} + for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) @@ -188,7 +188,7 @@ class LoadEffects(load.LoaderPlugin): # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) - for key, value in json.load(f).iteritems()} + for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) @@ -330,7 +330,7 @@ class LoadEffects(load.LoaderPlugin): if isinstance(input, dict): return {self.byteify(key): self.byteify(value) - for key, value in input.iteritems()} + for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] elif isinstance(input, unicode): diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index 9c4fd4c2c6..91f5685920 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -74,7 +74,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) - for key, value in json.load(f).iteritems()} + for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) @@ -194,7 +194,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): # getting data from json file with unicode conversion with open(file, "r") as f: json_f = {self.byteify(key): self.byteify(value) - for key, value in json.load(f).iteritems()} + for key, value in json.load(f).items()} # get correct order of nodes by positions on track and subtrack nodes_order = self.reorder_nodes(json_f) @@ -350,7 +350,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): if isinstance(input, dict): return {self.byteify(key): self.byteify(value) - for key, value in input.iteritems()} + for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] elif isinstance(input, unicode): diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index 87bebce15b..df52a22364 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -240,7 +240,7 @@ class LoadGizmoInputProcess(load.LoaderPlugin): if isinstance(input, dict): return {self.byteify(key): self.byteify(value) - for key, value in input.iteritems()} + for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] elif isinstance(input, unicode): diff --git a/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py b/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py index 5ee93403d0..907577a97d 100644 --- a/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py +++ b/openpype/hosts/nuke/plugins/publish/validate_write_deadline_tab.py @@ -25,7 +25,7 @@ class RepairNukeWriteDeadlineTab(pyblish.api.Action): # Remove existing knobs. knob_names = openpype.hosts.nuke.lib.get_deadline_knob_names() - for name, knob in group_node.knobs().iteritems(): + for name, knob in group_node.knobs().items(): if name in knob_names: group_node.removeKnob(knob) From c0d27f47d236dee4ed793682c8fef6e9c9f9de3c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 15:20:53 +0100 Subject: [PATCH 266/854] don't refresh log viewer on initialization but on first show --- openpype/modules/log_viewer/tray/app.py | 9 +++++++++ openpype/modules/log_viewer/tray/widgets.py | 11 +++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/openpype/modules/log_viewer/tray/app.py b/openpype/modules/log_viewer/tray/app.py index 1e8d6483cd..71827fcac9 100644 --- a/openpype/modules/log_viewer/tray/app.py +++ b/openpype/modules/log_viewer/tray/app.py @@ -26,3 +26,12 @@ class LogsWindow(QtWidgets.QWidget): self.log_detail = log_detail self.setStyleSheet(style.load_stylesheet()) + + self._frist_show = True + + def showEvent(self, event): + super(LogsWindow, self).showEvent(event) + + if self._frist_show: + self._frist_show = False + self.logs_widget.refresh() diff --git a/openpype/modules/log_viewer/tray/widgets.py b/openpype/modules/log_viewer/tray/widgets.py index ff77405de5..ed08e62109 100644 --- a/openpype/modules/log_viewer/tray/widgets.py +++ b/openpype/modules/log_viewer/tray/widgets.py @@ -155,6 +155,11 @@ class LogsWidget(QtWidgets.QWidget): QtCore.Qt.DescendingOrder ) + refresh_triggered_timer = QtCore.QTimer() + refresh_triggered_timer.setSingleShot(True) + refresh_triggered_timer.setInterval(200) + + refresh_triggered_timer.timeout.connect(self._on_refresh_timeout) view.selectionModel().selectionChanged.connect(self._on_index_change) refresh_btn.clicked.connect(self._on_refresh_clicked) @@ -169,10 +174,12 @@ class LogsWidget(QtWidgets.QWidget): self.detail_widget = detail_widget self.refresh_btn = refresh_btn - # prepare - self.refresh() + self._refresh_triggered_timer = refresh_triggered_timer def refresh(self): + self._refresh_triggered_timer.start() + + def _on_refresh_timeout(self): self.model.refresh() self.detail_widget.refresh() From d5521ae8407a9f9bb4d1f05e8a1ef048700acf45 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 15:37:05 +0100 Subject: [PATCH 267/854] flame: adding loading of plate into integrator --- openpype/hosts/flame/api/batch_utils.py | 4 +- .../plugins/publish/integrate_batch_group.py | 87 +++++++++++-------- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/openpype/hosts/flame/api/batch_utils.py b/openpype/hosts/flame/api/batch_utils.py index a47d62a10e..99e053faf1 100644 --- a/openpype/hosts/flame/api/batch_utils.py +++ b/openpype/hosts/flame/api/batch_utils.py @@ -21,7 +21,7 @@ def create_batch(name, frame_start, frame_duration, **kwargs): # Create batch group with name, start_frame value, duration value, # set of schematic reel names, set of shelf reel names - flame.batch.create_batch_group( + bgroup = flame.batch.create_batch_group( name, start_frame=frame_start, duration=frame_duration, @@ -61,3 +61,5 @@ def create_batch(name, frame_start, frame_duration, **kwargs): # sort batch nodes flame.batch.organize() + + return bgroup diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index c54eeec05c..97b456c18c 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -16,12 +16,6 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): families = ["clip"] def process(self, instance): - frame_start = instance.data["frameStart"] - frame_end = instance.data["frameEnd"] - handle_start = instance.data["handleStart"] - handle_end = instance.data["handleEnd"] - frame_duration = (frame_end - frame_start) + 1 - asset_name = instance.data["asset"] add_tasks = instance.data["flameAddTasks"] # iterate all tasks from settings @@ -29,40 +23,59 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): # exclude batch group if not task_data["create_batch_group"]: continue - task_name = task_data["name"] - batchgroup_name = "{}_{}".format(asset_name, task_name) - write_pref_data = self._get_write_prefs(instance, task_data) - batch_data = { - "shematic_reels": [ - "OP_LoadedReel" - ], - "write_pref": write_pref_data, - "handleStart": handle_start, - "handleEnd": handle_end - } - self.log.debug( - "__ batch_data: {}".format(pformat(batch_data))) + # create or get already created batch group + bgroup = self._get_batch_group(instance, task_data) - # check if the batch group already exists - bgroup = opfapi.get_batch_group_from_desktop(batchgroup_name) + # load plate to batch group + self.log.info("Loading subset `{}` into batch `{}`".format( + instance.data["subset"], bgroup.name.get_value() + )) - if not bgroup: - self.log.info( - "Creating new batch group: {}".format(batchgroup_name)) - # create batch with utils - opfapi.create_batch( - batchgroup_name, - frame_start, - frame_duration, - **batch_data - ) - else: - self.log.info( - "Updating batch group: {}".format(batchgroup_name)) - # update already created batch group - bgroup.start_frame = frame_start - bgroup.duration = frame_duration + def _get_batch_group(self, instance, task_data): + frame_start = instance.data["frameStart"] + frame_end = instance.data["frameEnd"] + handle_start = instance.data["handleStart"] + handle_end = instance.data["handleEnd"] + frame_duration = (frame_end - frame_start) + 1 + asset_name = instance.data["asset"] + + task_name = task_data["name"] + batchgroup_name = "{}_{}".format(asset_name, task_name) + write_pref_data = self._get_write_prefs(instance, task_data) + + batch_data = { + "shematic_reels": [ + "OP_LoadedReel" + ], + "write_pref": write_pref_data, + "handleStart": handle_start, + "handleEnd": handle_end + } + self.log.debug( + "__ batch_data: {}".format(pformat(batch_data))) + + # check if the batch group already exists + bgroup = opfapi.get_batch_group_from_desktop(batchgroup_name) + + if not bgroup: + self.log.info( + "Creating new batch group: {}".format(batchgroup_name)) + # create batch with utils + bgroup = opfapi.create_batch( + batchgroup_name, + frame_start, + frame_duration, + **batch_data + ) + else: + self.log.info( + "Updating batch group: {}".format(batchgroup_name)) + # update already created batch group + bgroup.start_frame = frame_start + bgroup.duration = frame_duration + + return bgroup def _get_anamoty_data_with_current_task(self, instance, task_data): anatomy_data = copy.deepcopy(instance.data["anatomyData"]) From 4767bd7f5e75102cea504cc7b66600430bf1420b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 15:41:04 +0100 Subject: [PATCH 268/854] fix import in tray publisher --- .../hosts/traypublisher/plugins/create/create_workfile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py index 2db4770bbc..5e0af350f0 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_workfile.py +++ b/openpype/hosts/traypublisher/plugins/create/create_workfile.py @@ -1,8 +1,8 @@ from openpype.hosts.traypublisher.api import pipeline +from openpype.lib import FileDef from openpype.pipeline import ( Creator, - CreatedInstance, - lib + CreatedInstance ) @@ -80,7 +80,7 @@ class WorkfileCreator(Creator): def get_instance_attr_defs(self): output = [ - lib.FileDef( + FileDef( "filepath", folders=False, extensions=self.extensions, From abf26fcaa47e067e4437bc2c11ac35bd1508e417 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 15:41:18 +0100 Subject: [PATCH 269/854] changed Name to Variant in creator dialog --- openpype/tools/publisher/widgets/create_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index 27ce97955a..7d98609c2c 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -271,7 +271,7 @@ class CreateDialog(QtWidgets.QDialog): create_btn.setEnabled(False) form_layout = QtWidgets.QFormLayout() - form_layout.addRow("Name:", variant_layout) + form_layout.addRow("Variant:", variant_layout) form_layout.addRow("Subset:", subset_name_input) mid_widget = QtWidgets.QWidget(self) From db66fe98fd0fc7bfdc9096a0e40fc380f8479aa4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 15:41:43 +0100 Subject: [PATCH 270/854] fixed typo --- openpype/modules/log_viewer/tray/app.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/log_viewer/tray/app.py b/openpype/modules/log_viewer/tray/app.py index 71827fcac9..def319e0e3 100644 --- a/openpype/modules/log_viewer/tray/app.py +++ b/openpype/modules/log_viewer/tray/app.py @@ -27,11 +27,11 @@ class LogsWindow(QtWidgets.QWidget): self.setStyleSheet(style.load_stylesheet()) - self._frist_show = True + self._first_show = True def showEvent(self, event): super(LogsWindow, self).showEvent(event) - if self._frist_show: - self._frist_show = False + if self._first_show: + self._first_show = False self.logs_widget.refresh() From 57feaee44b8bff1eb7156b25bad70c978b715fec Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 15:44:50 +0100 Subject: [PATCH 271/854] added informative logs about not found icons --- openpype/tools/utils/lib.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 93b156bef8..f4ba2106f2 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -17,6 +17,8 @@ from openpype.lib import filter_profiles from openpype.style import get_objected_colors from openpype.resources import get_image_path +log = Logger.get_logger(__name__) + def center_window(window): """Move window to center of it's screen.""" @@ -111,13 +113,23 @@ def get_qta_icon_by_name_and_color(icon_name, icon_color): variants.append("{0}.{1}".format(key, icon_name)) icon = None + used_variant = None for variant in variants: try: icon = qtawesome.icon(variant, color=icon_color) + used_variant = variant break except Exception: pass + if used_variant is None: + log.info("Didn't find icon \"{}\"".format(icon_name)) + + elif used_variant != icon_name: + log.info("Icon \"{}\" was not found \"{}\" is used instead".format( + icon_name, used_variant + )) + SharedObjects.icons[full_icon_name] = icon return icon From d772236278b643e748e446db35b4200eb4b9eb48 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 16:07:11 +0100 Subject: [PATCH 272/854] changed info log level to debug --- openpype/tools/utils/lib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index f4ba2106f2..d069088932 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -126,7 +126,7 @@ def get_qta_icon_by_name_and_color(icon_name, icon_color): log.info("Didn't find icon \"{}\"".format(icon_name)) elif used_variant != icon_name: - log.info("Icon \"{}\" was not found \"{}\" is used instead".format( + log.debug("Icon \"{}\" was not found \"{}\" is used instead".format( icon_name, used_variant )) From 1c775b382fdb9d864e97bcfb557d2d2ad2383799 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 16:24:40 +0100 Subject: [PATCH 273/854] use explicit icon names for asset --- openpype/tools/utils/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index d069088932..422d0f5389 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -152,8 +152,8 @@ def get_asset_icon_name(asset_doc, has_children=True): return icon_name if has_children: - return "folder" - return "folder-o" + return "fa.folder" + return "fa.folder-o" def get_asset_icon_color(asset_doc): From 38268bc83102964c22db15129516a32ba5d5f455 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 16:28:46 +0100 Subject: [PATCH 274/854] flame: let extractor drive loading to batch group --- .../settings/defaults/project_settings/flame.json | 4 +++- .../projects_schema/schema_project_flame.json | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index 939752c778..a2b9bef103 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -62,7 +62,9 @@ "ignore_comment_attrs": false, "colorspace_out": "ACES - ACEScg", "representation_add_range": true, - "representation_tags": [] + "representation_tags": [], + "load_to_batch_group": true, + "batch_group_loader_name": "LoadClip" } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index 8057b07d9c..c991577799 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -302,6 +302,20 @@ "type": "text", "multiline": false } + }, + { + "type": "separator" + }, + { + "type": "boolean", + "key": "load_to_batch_group", + "label": "Load to batch group reel", + "default": false + }, + { + "type": "text", + "key": "batch_group_loader_name", + "label": "Use loader name" } ] } From 9ae5474a929bba3553af854fef332ef623600133 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 16:47:37 +0100 Subject: [PATCH 275/854] fix drop files in files widget --- openpype/widgets/attribute_defs/files_widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index 87b98e2378..34f7d159ad 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -641,5 +641,6 @@ class SingleFileWidget(QtWidgets.QWidget): filepaths.append(filepath) # TODO filter check if len(filepaths) == 1: - self.set_value(filepaths[0], False) + self._filepath_input.setText(filepaths[0]) + event.accept() From 3534e9c448f30bc8c04f8bfce0837fb5f5ea80f6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 24 Mar 2022 17:17:38 +0100 Subject: [PATCH 276/854] Fix plugin label --- .../hosts/traypublisher/plugins/publish/validate_workfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py index 88339d2aac..e8eeb46065 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py @@ -6,7 +6,7 @@ from openpype.pipeline import PublishValidationError class ValidateWorkfilePath(pyblish.api.InstancePlugin): """Validate existence of workfile instance existence.""" - label = "Collect Workfile" + label = "Validate Workfile" order = pyblish.api.ValidatorOrder - 0.49 families = ["workfile"] hosts = ["traypublisher"] From 0407465ee1a2438d8a84d9d0704bb38dd56c1a2c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 17:24:21 +0100 Subject: [PATCH 277/854] flame: add loadable arguments to extracted repres --- .../hosts/flame/plugins/publish/extract_subset_resources.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 32f6b9508f..7c29bcf944 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -211,7 +211,11 @@ class ExtractSubsetResources(openpype.api.Extractor): "tags": repre_tags, "data": { "colorspace": color_out - } + }, + "load_to_batch_group": preset_config.get( + "load_to_batch_group"), + "batch_group_loader_name": preset_config.get( + "batch_group_loader_name") } # collect all available content of export dir From 638864150493e0f46b2fd41a6fbe0609434dc536 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 17:24:43 +0100 Subject: [PATCH 278/854] flame: finalize loading procedure in batch integrator --- .../plugins/publish/integrate_batch_group.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 97b456c18c..62211d7ace 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -4,6 +4,7 @@ from pprint import pformat import pyblish from openpype.lib import get_workdir import openpype.hosts.flame.api as opfapi +import openpype.pipeline as op_pipeline @pyblish.api.log @@ -15,6 +16,9 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): hosts = ["flame"] families = ["clip"] + # settings + default_loader = "LoadClip" + def process(self, instance): add_tasks = instance.data["flameAddTasks"] @@ -31,6 +35,77 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): self.log.info("Loading subset `{}` into batch `{}`".format( instance.data["subset"], bgroup.name.get_value() )) + self._load_clip_to_context(instance, bgroup) + + def _load_clip_to_context(self, instance, bgroup): + # get all loaders for host + loaders = op_pipeline.discover_loader_plugins() + + # get all published representations + published_representations = instance.data["published_representations"] + + # get all loadable representations + representations = instance.data["representations"] + + # get repre_id for the loadable representations + loadable_representations = [ + { + "name": _repr["name"], + "loader": _repr.get("batch_group_loader_name"), + # match loader to the loadable representation + "_id": next( + ( + id + for id, repr in published_representations.items() + if repr["representation"]["name"] == _repr["name"] + ), + None + ) + } + for _repr in representations + if _repr.get("load_to_batch_group") is not None + ] + + # get representation context from the repre_id + representation_ids = [ + repre["_id"] + for repre in loadable_representations + if repre["_id"] is not None + ] + repre_contexts = op_pipeline.load.get_repres_contexts( + representation_ids) + + # loop all returned repres from repre_context dict + for repre_id, repre_context in repre_contexts.items(): + # get loader name by representation id + loader_name = next( + ( + repr["loader"] + for repr in loadable_representations + if repr["_id"] == repre_id + ), + self.default_loader + ) + # get loader plugin + Loader = next( + ( + loader_plugin + for loader_plugin in loaders + if loader_plugin.__name__ == loader_name + ), + None + ) + if Loader: + # load to flame by representation context + op_pipeline.load.load_with_repre_context(Loader, repre_context) + else: + self.log.warning( + "Something got wrong and there is not Loader found for " + "following data: {}".format( + pformat(loadable_representations)) + ) + + def _get_batch_group(self, instance, task_data): frame_start = instance.data["frameStart"] From cde1caaa9180fcc7e4165995e9760429f2a55e07 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 17:40:27 +0100 Subject: [PATCH 279/854] flame: clean args types --- .../publish/extract_subset_resources.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 7c29bcf944..00b87c05a0 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -108,6 +108,18 @@ class ExtractSubsetResources(openpype.api.Extractor): ignore_comment_attrs = preset_config["ignore_comment_attrs"] color_out = preset_config["colorspace_out"] + # get attribures related loading in integrate_batch_group + load_to_batch_group = preset_config.get( + "load_to_batch_group") + batch_group_loader_name = preset_config.get( + "batch_group_loader_name") + + # convert to None if empty string + if batch_group_loader_name: + batch_group_loader_name = str(batch_group_loader_name) + if batch_group_loader_name == "": + batch_group_loader_name = None + # get frame range with handles for representation range frame_start_handle = frame_start - handle_start source_duration_handles = ( @@ -212,10 +224,8 @@ class ExtractSubsetResources(openpype.api.Extractor): "data": { "colorspace": color_out }, - "load_to_batch_group": preset_config.get( - "load_to_batch_group"), - "batch_group_loader_name": preset_config.get( - "batch_group_loader_name") + "load_to_batch_group": load_to_batch_group, + "batch_group_loader_name": batch_group_loader_name } # collect all available content of export dir From 4cfd22b6393b3a4d7e5c18046f7d0340ce124e27 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 17:47:06 +0100 Subject: [PATCH 280/854] flame: improving loading with exception --- .../hosts/flame/plugins/publish/integrate_batch_group.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 62211d7ace..08632c3018 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -97,7 +97,13 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): ) if Loader: # load to flame by representation context - op_pipeline.load.load_with_repre_context(Loader, repre_context) + try: + op_pipeline.load.load_with_repre_context( + Loader, repre_context) + except op_pipeline.load.IncompatibleLoaderError as msg: + self.log.error( + "Check allowed representations for Loader `{}` " + "in settings > error: {}".format(Loader.__name__, msg)) else: self.log.warning( "Something got wrong and there is not Loader found for " From a4f8cdb76962f6f4c9e4efef49079c3926e486e6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 24 Mar 2022 17:51:22 +0100 Subject: [PATCH 281/854] flame: better logging for loading fail --- .../hosts/flame/plugins/publish/integrate_batch_group.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 08632c3018..f1049e4697 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -103,7 +103,14 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): except op_pipeline.load.IncompatibleLoaderError as msg: self.log.error( "Check allowed representations for Loader `{}` " - "in settings > error: {}".format(Loader.__name__, msg)) + "in settings > error: {}".format( + Loader.__name__, msg)) + self.log.error( + "Representaton context >>{}<< is not compatible " + "with loader `{}`".format( + pformat(repre_context), Loader.__name__ + ) + ) else: self.log.warning( "Something got wrong and there is not Loader found for " From bb8bd9042778ad4293682993832242cb8b57e200 Mon Sep 17 00:00:00 2001 From: DMO Date: Fri, 25 Mar 2022 11:08:53 +0900 Subject: [PATCH 282/854] add the animation data first to maintain order. --- .../hosts/maya/plugins/create/create_multiverse_usd.py | 7 +++---- .../maya/plugins/create/create_multiverse_usd_comp.py | 7 +++---- .../maya/plugins/create/create_multiverse_usd_over.py | 7 +++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py index c06c764f95..b2266e5a57 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd.py @@ -12,6 +12,9 @@ class CreateMultiverseUsd(plugin.Creator): def __init__(self, *args, **kwargs): super(CreateMultiverseUsd, self).__init__(*args, **kwargs) + # Add animation data first, since it maintains order. + self.data.update(lib.collect_animation_data(True)) + self.data["stripNamespaces"] = False self.data["mergeTransformAndShape"] = False self.data["writeAncestors"] = True @@ -46,7 +49,3 @@ class CreateMultiverseUsd(plugin.Creator): self.data["customMaterialNamespace"] = '' self.data["numTimeSamples"] = 1 self.data["timeSamplesSpan"] = 0.0 - - # Add animation data - animation_data = lib.collect_animation_data(True) - self.data.update(animation_data) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py index 56b8721ce0..77b808c459 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_comp.py @@ -12,13 +12,12 @@ class CreateMultiverseUsdComp(plugin.Creator): def __init__(self, *args, **kwargs): super(CreateMultiverseUsdComp, self).__init__(*args, **kwargs) + # Add animation data first, since it maintains order. + self.data.update(lib.collect_animation_data(True)) + self.data["stripNamespaces"] = False self.data["mergeTransformAndShape"] = False self.data["flattenContent"] = False self.data["writePendingOverrides"] = False self.data["numTimeSamples"] = 1 self.data["timeSamplesSpan"] = 0.0 - - # Add animation data - animation_data = lib.collect_animation_data(True) - self.data.update(animation_data) diff --git a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py index 9ccf2e45fc..bb82ab2039 100644 --- a/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py +++ b/openpype/hosts/maya/plugins/create/create_multiverse_usd_over.py @@ -12,6 +12,9 @@ class CreateMultiverseUsdOver(plugin.Creator): def __init__(self, *args, **kwargs): super(CreateMultiverseUsdOver, self).__init__(*args, **kwargs) + # Add animation data first, since it maintains order. + self.data.update(lib.collect_animation_data(True)) + self.data["writeAll"] = False self.data["writeTransforms"] = True self.data["writeVisibility"] = True @@ -23,7 +26,3 @@ class CreateMultiverseUsdOver(plugin.Creator): self.data["writeNamespaces"] = False self.data["numTimeSamples"] = 1 self.data["timeSamplesSpan"] = 0.0 - - # Add animation data - animation_data = lib.collect_animation_data(True) - self.data.update(animation_data) From 9f75f217a9ef0f5df5994d7cec1b5ccf079b2e22 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 09:49:13 +0100 Subject: [PATCH 283/854] Update openpype/settings/entities/schemas/projects_schema/schema_project_slack.json Co-authored-by: Roy Nieterau --- .../entities/schemas/projects_schema/schema_project_slack.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json index 9ca7c35e10..1a9804cd4f 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_slack.json @@ -78,7 +78,7 @@ { "type": "number", "key": "review_upload_limit", - "label": "Upload review of maximally size (MB)", + "label": "Upload review maximum file size (MB)", "decimal": 2, "default": 50, "minimum": 0, From 5adf3966106f528bdfc38ccc2f7d1a1171efcb5b Mon Sep 17 00:00:00 2001 From: DMO Date: Fri, 25 Mar 2022 18:07:07 +0900 Subject: [PATCH 284/854] Adding "pointcache" & "animation" to the list of families, since it's supported. --- openpype/hosts/maya/plugins/load/load_multiverse_usd.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index 5361f7a85b..eafad535eb 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -16,7 +16,8 @@ from openpype.hosts.maya.api.pipeline import containerise class MultiverseUsdLoader(load.LoaderPlugin): """Load the USD by Multiverse""" - families = ["model", "usd", "usdComposition", "usdOverride"] + families = ["model", "usd", "usdComposition", "usdOverride", + "pointcache", "animation"] representations = ["usd", "usda", "usdc", "usdz", "abc"] label = "Read USD by Multiverse" From deedc893bf82248149e652422270c27c16518ac8 Mon Sep 17 00:00:00 2001 From: DMO Date: Fri, 25 Mar 2022 18:07:28 +0900 Subject: [PATCH 285/854] Lock the shape after creating to avoid deletion. --- openpype/hosts/maya/plugins/load/load_multiverse_usd.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py index eafad535eb..c03f2c5d92 100644 --- a/openpype/hosts/maya/plugins/load/load_multiverse_usd.py +++ b/openpype/hosts/maya/plugins/load/load_multiverse_usd.py @@ -47,6 +47,9 @@ class MultiverseUsdLoader(load.LoaderPlugin): transform = cmds.listRelatives( shape, parent=True, fullPath=True)[0] + # Lock the shape node so the user cannot delete it. + cmds.lockNode(shape, lock=True) + nodes = [transform, shape] self[:] = nodes From ac7b35698d8288febe55759e8f231bf238ae63cf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 10:08:40 +0100 Subject: [PATCH 286/854] Updated Slack notification message --- openpype/modules/slack/plugins/publish/integrate_slack_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/slack/plugins/publish/integrate_slack_api.py b/openpype/modules/slack/plugins/publish/integrate_slack_api.py index 22cf4cdf93..10bde7d4c0 100644 --- a/openpype/modules/slack/plugins/publish/integrate_slack_api.py +++ b/openpype/modules/slack/plugins/publish/integrate_slack_api.py @@ -82,9 +82,9 @@ class IntegrateSlackAPI(pyblish.api.InstancePlugin): review_file_size_MB = os.path.getsize(review_path) / 1024 / 1024 file_limit = message_profile.get("review_upload_limit", 50) if review_file_size_MB > file_limit: + message += "\nReview upload omitted because of file size." if review_path not in message: - message += "\n Review upload omitted because of " + \ - "file size, file located at: {}".format(review_path) + message += "\nFile located at: {}".format(review_path) else: publish_files.add(review_path) return message, publish_files From 76bc7799f1dbcc993b5dbd2868f9309bd3e7e234 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 25 Mar 2022 10:52:54 +0100 Subject: [PATCH 287/854] add top node validator --- .../publish/extract_unreal_staticmesh.py | 5 +-- .../help/validate_skeletalmesh_hierarchy.xml | 14 ++++++++ .../validate_skeletalmesh_hierarchy.py | 36 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 openpype/hosts/maya/plugins/publish/help/validate_skeletalmesh_hierarchy.xml create mode 100644 openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index c3cc322a29..92fa1b5933 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -22,8 +22,9 @@ class ExtractUnrealStaticMesh(openpype.api.Extractor): families = ["staticMesh"] def process(self, instance): - geo = instance.data.get("geometryMembers", []) - members = geo + instance.data.get("collisionMembers", []) + members = instance.data.get("geometryMembers", []) + if instance.data.get("collisionMembers"): + members = members + instance.data.get("collisionMembers") fbx_exporter = fbx.FBXExtractor(log=self.log) diff --git a/openpype/hosts/maya/plugins/publish/help/validate_skeletalmesh_hierarchy.xml b/openpype/hosts/maya/plugins/publish/help/validate_skeletalmesh_hierarchy.xml new file mode 100644 index 0000000000..d30c4cb69d --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/help/validate_skeletalmesh_hierarchy.xml @@ -0,0 +1,14 @@ + + + +Skeletal Mesh Top Node +## Skeletal meshes needs common root + +Skeletal meshes and their joints must be under one common root. + +### How to repair? + +Make sure all geometry and joints resides under same root. + + + diff --git a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py new file mode 100644 index 0000000000..dda7e063f6 --- /dev/null +++ b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +import pyblish.api +import openpype.api +from openpype.pipeline import PublishXmlValidationError + +from maya import cmds + + +class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin): + """Adheres to the content of 'model' family + + - Must have one top group. (configurable) + - Must only contain: transforms, meshes and groups + + """ + + order = openpype.api.ValidateContentsOrder + hosts = ["maya"] + families = ["skeletalMesh"] + label = "Skeletal Mesh Top Node" + + def process(self, instance): + geo = instance.data.get("geometry") + joints = instance.data.get("joints") + joints_parents = cmds.ls(joints, long=True)[0].split("|")[1:-1] + geo_parents = cmds.ls(geo, long=True)[0].split("|")[1:-1] + + self.log.info(joints_parents) + self.log.info(geo_parents) + self.log.info(set(joints_parents + geo_parents)) + + if len(set(joints_parents + geo_parents)) != 1: + raise PublishXmlValidationError( + self, + "Multiple roots on geometry or joints." + ) From ed5dadc6308c5d406e6d717ddf70272fc5efff77 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Mar 2022 10:59:15 +0100 Subject: [PATCH 288/854] nuke: remove unicode type --- openpype/hosts/nuke/plugins/load/load_effects.py | 4 ++-- openpype/hosts/nuke/plugins/load/load_effects_ip.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 675ac9d46f..1ed32996e1 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -333,8 +333,8 @@ class LoadEffects(load.LoaderPlugin): for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] - elif isinstance(input, unicode): - return input.encode('utf-8') + elif isinstance(input, str): + return str(input) else: return input diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index 91f5685920..383776111f 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -353,8 +353,8 @@ class LoadEffectsInputProcess(load.LoaderPlugin): for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] - elif isinstance(input, unicode): - return input.encode('utf-8') + elif isinstance(input, str): + return str(input) else: return input From 41d54727529b8f2b8a1580fd455616cbe5905da7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 11:50:32 +0100 Subject: [PATCH 289/854] OP-2765 - implemented support for optional validation in new publisher --- .../plugins/publish/validate_scene_settings.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py index 0753e3c09a..14e224fdc2 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -5,11 +5,15 @@ import re import pyblish.api -from openpype.pipeline import PublishXmlValidationError +from openpype.pipeline import ( + PublishXmlValidationError, + OptionalPyblishPluginMixin +) from openpype.hosts.aftereffects.api import get_asset_settings -class ValidateSceneSettings(pyblish.api.InstancePlugin): +class ValidateSceneSettings(OptionalPyblishPluginMixin, + pyblish.api.InstancePlugin): """ Ensures that Composition Settings (right mouse on comp) are same as in FTrack on task. @@ -59,6 +63,10 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): def process(self, instance): """Plugin entry point.""" + # Skip the instance if is not active by data on the instance + if not self.is_active(instance.data): + return + expected_settings = get_asset_settings() self.log.info("config from DB::{}".format(expected_settings)) From e5f605b1236893c9917a3ea2931f6f3e75650f27 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 11:51:57 +0100 Subject: [PATCH 290/854] OP-2765 - render.farm is in families not in family Better handling of potentially multiple instances. (Still requiring that there is only one publishable composition at the moment.) --- openpype/hosts/aftereffects/plugins/publish/collect_audio.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_audio.py b/openpype/hosts/aftereffects/plugins/publish/collect_audio.py index 80679725e6..8647ba498b 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_audio.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_audio.py @@ -17,12 +17,11 @@ class CollectAudio(pyblish.api.ContextPlugin): def process(self, context): for instance in context: - if instance.data["family"] == 'render.farm': + if 'render.farm' in instance.data.get("families", []): comp_id = instance.data["comp_id"] if not comp_id: self.log.debug("No comp_id filled in instance") - # @iLLiCiTiT QUESTION Should return or continue? - return + continue context.data["audioFile"] = os.path.normpath( get_stub().get_audio_url(comp_id) ).replace("\\", "/") From 71cd7a3fb0aad57e191fb0c520b09921d668d542 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 11:53:32 +0100 Subject: [PATCH 291/854] OP-2765 - added support for optional validations Asset and Task should be ALWAYS on instance, not on context. (Publishable instance might allow different context than "real context".) --- .../plugins/publish/collect_render.py | 18 +++++++++++------- openpype/lib/abstract_collect_render.py | 1 + 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index aa5bc58ac2..24d08b343e 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -22,6 +22,7 @@ class AERenderInstance(RenderInstance): projectEntity = attr.ib(default=None) stagingDir = attr.ib(default=None) app_version = attr.ib(default=None) + publish_attributes = attr.ib(default=None) class CollectAERender(abstract_collect_render.AbstractCollectRender): @@ -50,16 +51,21 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): current_file = context.data["currentFile"] version = context.data["version"] - asset_entity = context.data["assetEntity"] + project_entity = context.data["projectEntity"] compositions = CollectAERender.get_stub().get_items(True) compositions_by_id = {item.id: item for item in compositions} for inst in context: + if not inst.data["active"]: + continue + family = inst.data["family"] if family not in ["render", "renderLocal"]: # legacy continue + asset_entity = inst.data["assetEntity"] + item_id = inst.data["members"][0] work_area_info = CollectAERender.get_stub().get_work_area( @@ -78,9 +84,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): fps = work_area_info.frameRate # TODO add resolution when supported by extension - if not inst.data["active"]: - continue - subset_name = inst.data["subset"] instance = AERenderInstance( family=family, @@ -90,7 +93,8 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): source=current_file, label="{} - {}".format(subset_name, family), subset=subset_name, - asset=context.data["assetEntity"]["name"], + asset=inst.data["asset"], + task=inst.data["task"], attachTo=False, setMembers='', publish=True, @@ -112,8 +116,8 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): toBeRenderedOn='deadline', fps=fps, app_version=app_version, - anatomyData=deepcopy(context.data["anatomyData"]), - context=context + anatomyData=deepcopy(inst.data["anatomyData"]), + publish_attributes=inst.data.get("publish_attributes") ) comp = compositions_by_id.get(int(item_id)) diff --git a/openpype/lib/abstract_collect_render.py b/openpype/lib/abstract_collect_render.py index 029bd3ec39..cce161b51c 100644 --- a/openpype/lib/abstract_collect_render.py +++ b/openpype/lib/abstract_collect_render.py @@ -30,6 +30,7 @@ class RenderInstance(object): source = attr.ib() # path to source scene file label = attr.ib() # label to show in GUI subset = attr.ib() # subset name + task = attr.ib() # task name asset = attr.ib() # asset name (AVALON_ASSET) attachTo = attr.ib() # subset name to attach render to setMembers = attr.ib() # list of nodes/members producing render output From 0506c38e00008d26eb8ce7b8391b6f53844efed3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 11:54:13 +0100 Subject: [PATCH 292/854] OP-2765 - cleaned up workfile collector --- .../plugins/publish/collect_workfile.py | 66 +++++++++---------- .../plugins/publish/submit_publish_job.py | 12 +++- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index f285ae49e4..ac552a6a5f 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -17,16 +17,37 @@ class CollectWorkfile(pyblish.api.ContextPlugin): existing_instance = instance break - task = api.Session["AVALON_TASK"] current_file = context.data["currentFile"] staging_dir = os.path.dirname(current_file) scene_file = os.path.basename(current_file) + if existing_instance is None: # old publish + instance = self._get_new_instance(context, scene_file) + else: + instance = existing_instance + + # creating representation + representation = { + 'name': 'aep', + 'ext': 'aep', + 'files': scene_file, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation) + + def _get_new_instance(self, context, scene_file): + task = api.Session["AVALON_TASK"] version = context.data["version"] asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] - shared_instance_data = { + # workfile instance + family = "workfile" + subset = family + task.capitalize() # TOOD use method + + instance_data = { "asset": asset_entity["name"], + "task": task, "frameStart": asset_entity["data"]["frameStart"], "frameEnd": asset_entity["data"]["frameEnd"], "handleStart": asset_entity["data"]["handleStart"], @@ -40,37 +61,16 @@ class CollectWorkfile(pyblish.api.ContextPlugin): project_entity["data"]["resolutionHeight"]), "pixelAspect": 1, "step": 1, - "version": version + "version": version, + "subset": subset, + "label": scene_file, + "family": family, + "families": [family], + "representations": list() } - # workfile instance - family = "workfile" - subset = family + task.capitalize() - if existing_instance is None: # old publish - # Create instance - instance = context.create_instance(subset) + # Create instance + instance = context.create_instance(subset) + instance.data.update(instance_data) - # creating instance data - instance.data.update({ - "subset": subset, - "label": scene_file, - "family": family, - "families": [family], - "representations": list() - }) - - # adding basic script data - instance.data.update(shared_instance_data) - else: - instance = existing_instance - instance.data["publish"] = True # for DL - - # creating representation - representation = { - 'name': 'aep', - 'ext': 'aep', - 'files': scene_file, - "stagingDir": staging_dir, - } - - instance.data["representations"].append(representation) + return instance \ No newline at end of file diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index fad4d14ea0..f624f40635 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -392,6 +392,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): list of instances """ + self.log.info("!!!!! _create_instances_for_aov") task = os.environ["AVALON_TASK"] subset = instance_data["subset"] cameras = instance_data.get("cameras", []) @@ -454,6 +455,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): break if instance_data.get("multipartExr"): + self.log.info("!!!!! _create_instances_for_aov add multipartExr") preview = True new_instance = copy(instance_data) @@ -519,9 +521,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): """ representations = [] collections, remainders = clique.assemble(exp_files) - + self.log.info("!!!!! _get_representations") # create representation for every collected sequento ce for collection in collections: + self.log.info("!!!!! collection") ext = collection.tail.lstrip(".") preview = False # if filtered aov name is found in filename, toggle it for @@ -533,6 +536,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): aov, list(collection)[0] ): + self.log.info("!!!!! add preview") preview = True break @@ -582,6 +586,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # add reminders as representations for remainder in remainders: + self.log.info("!!!!! remainder") ext = remainder.split(".")[-1] staging = os.path.dirname(remainder) @@ -602,7 +607,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "files": os.path.basename(remainder), "stagingDir": os.path.dirname(remainder), } - if "render" in instance.get("families"): + is_render_type = set(["render"]).\ + intersection(instance.get("families")) + if is_render_type: + self.log.info("!!!!! is_render_type") rep.update({ "fps": instance.get("fps"), "tags": ["review"] From 2c20f6832dadcc85c1ae4fda23d952b7ae7d2c92 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 11:59:53 +0100 Subject: [PATCH 293/854] Revert "OP-2765 - cleaned up workfile collector" This reverts commit 0506c38e --- .../plugins/publish/collect_workfile.py | 66 +++++++++---------- .../plugins/publish/submit_publish_job.py | 12 +--- 2 files changed, 35 insertions(+), 43 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index ac552a6a5f..f285ae49e4 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -17,37 +17,16 @@ class CollectWorkfile(pyblish.api.ContextPlugin): existing_instance = instance break + task = api.Session["AVALON_TASK"] current_file = context.data["currentFile"] staging_dir = os.path.dirname(current_file) scene_file = os.path.basename(current_file) - if existing_instance is None: # old publish - instance = self._get_new_instance(context, scene_file) - else: - instance = existing_instance - - # creating representation - representation = { - 'name': 'aep', - 'ext': 'aep', - 'files': scene_file, - "stagingDir": staging_dir, - } - - instance.data["representations"].append(representation) - - def _get_new_instance(self, context, scene_file): - task = api.Session["AVALON_TASK"] version = context.data["version"] asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] - # workfile instance - family = "workfile" - subset = family + task.capitalize() # TOOD use method - - instance_data = { + shared_instance_data = { "asset": asset_entity["name"], - "task": task, "frameStart": asset_entity["data"]["frameStart"], "frameEnd": asset_entity["data"]["frameEnd"], "handleStart": asset_entity["data"]["handleStart"], @@ -61,16 +40,37 @@ class CollectWorkfile(pyblish.api.ContextPlugin): project_entity["data"]["resolutionHeight"]), "pixelAspect": 1, "step": 1, - "version": version, - "subset": subset, - "label": scene_file, - "family": family, - "families": [family], - "representations": list() + "version": version } - # Create instance - instance = context.create_instance(subset) - instance.data.update(instance_data) + # workfile instance + family = "workfile" + subset = family + task.capitalize() + if existing_instance is None: # old publish + # Create instance + instance = context.create_instance(subset) - return instance \ No newline at end of file + # creating instance data + instance.data.update({ + "subset": subset, + "label": scene_file, + "family": family, + "families": [family], + "representations": list() + }) + + # adding basic script data + instance.data.update(shared_instance_data) + else: + instance = existing_instance + instance.data["publish"] = True # for DL + + # creating representation + representation = { + 'name': 'aep', + 'ext': 'aep', + 'files': scene_file, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index f624f40635..fad4d14ea0 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -392,7 +392,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): list of instances """ - self.log.info("!!!!! _create_instances_for_aov") task = os.environ["AVALON_TASK"] subset = instance_data["subset"] cameras = instance_data.get("cameras", []) @@ -455,7 +454,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): break if instance_data.get("multipartExr"): - self.log.info("!!!!! _create_instances_for_aov add multipartExr") preview = True new_instance = copy(instance_data) @@ -521,10 +519,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): """ representations = [] collections, remainders = clique.assemble(exp_files) - self.log.info("!!!!! _get_representations") + # create representation for every collected sequento ce for collection in collections: - self.log.info("!!!!! collection") ext = collection.tail.lstrip(".") preview = False # if filtered aov name is found in filename, toggle it for @@ -536,7 +533,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): aov, list(collection)[0] ): - self.log.info("!!!!! add preview") preview = True break @@ -586,7 +582,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # add reminders as representations for remainder in remainders: - self.log.info("!!!!! remainder") ext = remainder.split(".")[-1] staging = os.path.dirname(remainder) @@ -607,10 +602,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "files": os.path.basename(remainder), "stagingDir": os.path.dirname(remainder), } - is_render_type = set(["render"]).\ - intersection(instance.get("families")) - if is_render_type: - self.log.info("!!!!! is_render_type") + if "render" in instance.get("families"): rep.update({ "fps": instance.get("fps"), "tags": ["review"] From 349827b3a20a718130c214057081f0fdcaa9e41f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 12:00:37 +0100 Subject: [PATCH 294/854] OP-2765 - cleaned up workfile collector --- .../plugins/publish/collect_workfile.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index f285ae49e4..93c7a448c6 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -17,16 +17,37 @@ class CollectWorkfile(pyblish.api.ContextPlugin): existing_instance = instance break - task = api.Session["AVALON_TASK"] current_file = context.data["currentFile"] staging_dir = os.path.dirname(current_file) scene_file = os.path.basename(current_file) + if existing_instance is None: # old publish + instance = self._get_new_instance(context, scene_file) + else: + instance = existing_instance + + # creating representation + representation = { + 'name': 'aep', + 'ext': 'aep', + 'files': scene_file, + "stagingDir": staging_dir, + } + + instance.data["representations"].append(representation) + + def _get_new_instance(self, context, scene_file): + task = api.Session["AVALON_TASK"] version = context.data["version"] asset_entity = context.data["assetEntity"] project_entity = context.data["projectEntity"] - shared_instance_data = { + # workfile instance + family = "workfile" + subset = family + task.capitalize() # TOOD use method + + instance_data = { "asset": asset_entity["name"], + "task": task, "frameStart": asset_entity["data"]["frameStart"], "frameEnd": asset_entity["data"]["frameEnd"], "handleStart": asset_entity["data"]["handleStart"], @@ -40,37 +61,16 @@ class CollectWorkfile(pyblish.api.ContextPlugin): project_entity["data"]["resolutionHeight"]), "pixelAspect": 1, "step": 1, - "version": version + "version": version, + "subset": subset, + "label": scene_file, + "family": family, + "families": [family], + "representations": list() } - # workfile instance - family = "workfile" - subset = family + task.capitalize() - if existing_instance is None: # old publish - # Create instance - instance = context.create_instance(subset) + # Create instance + instance = context.create_instance(subset) + instance.data.update(instance_data) - # creating instance data - instance.data.update({ - "subset": subset, - "label": scene_file, - "family": family, - "families": [family], - "representations": list() - }) - - # adding basic script data - instance.data.update(shared_instance_data) - else: - instance = existing_instance - instance.data["publish"] = True # for DL - - # creating representation - representation = { - 'name': 'aep', - 'ext': 'aep', - 'files': scene_file, - "stagingDir": staging_dir, - } - - instance.data["representations"].append(representation) + return instance From 102b65b5d478b7e66f20a62ecc8232da22e73cd6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 12:28:22 +0100 Subject: [PATCH 295/854] Added MongoDB requirements --- website/docs/dev_requirements.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/website/docs/dev_requirements.md b/website/docs/dev_requirements.md index bbf3b1fb5b..6c87054ba0 100644 --- a/website/docs/dev_requirements.md +++ b/website/docs/dev_requirements.md @@ -33,6 +33,8 @@ It can be built and ran on all common platforms. We develop and test on the foll ## Database +Database version should be at least **MongoDB 4.4**. + Pype needs site-wide installation of **MongoDB**. It should be installed on reliable server, that all workstations (and possibly render nodes) can connect. This server holds **Avalon** database that is at the core of everything From c210d3914efd9cd9d3657b054c01ec3dd28ce4d1 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 25 Mar 2022 14:51:59 +0300 Subject: [PATCH 296/854] Refactor function for matching AOV into new file --- openpype/pipeline/farm/patterning.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 openpype/pipeline/farm/patterning.py diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py new file mode 100644 index 0000000000..3138dd6873 --- /dev/null +++ b/openpype/pipeline/farm/patterning.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +import os +import re + +def match_aov_pattern(self, app, render_file_name): + """Matching against a AOV pattern in the render files + In order to match the AOV name + we must compare against the render filename string + that we are grabbing the render filename string + from the collection that we have grabbed from exp_files. + """ + + if app in self.aov_filter.keys(): + for aov_pattern in self.aov_filter[app]: + if re.match(aov_pattern, render_file_name): + preview = True + return preview \ No newline at end of file From b9f5bb3a7be9c0f167694115c51ad8fdc84bd357 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 25 Mar 2022 14:54:32 +0300 Subject: [PATCH 297/854] Cleanup placement --- .../plugins/publish/submit_publish_job.py | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index b1f6f9a485..7f65011864 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -8,6 +8,7 @@ from copy import copy, deepcopy import requests import clique import openpype.api +from openpype.pipeline.farm.patterning import match_aov_pattern from avalon import api, io @@ -446,21 +447,16 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): app = os.environ.get("AVALON_APP", "") preview = False - if app in self.aov_filter.keys(): - for aov_pattern in self.aov_filter[app]: - # Matching against the AOV pattern in the render files - # In order to match the AOV name - # we must compare against the render filename string - # We are grabbing the render filename string - # from the collection that we have grabbed from exp_files - if isinstance(col, list): - render_file_name = os.path.basename(col[0]) - else: - render_file_name = os.path.basename(col) - if re.match(aov_pattern, render_file_name): - preview = True - break + if isinstance(col, list): + render_file_name = os.path.basename(col[0]) + else: + render_file_name = os.path.basename(col) + + preview = match_aov_pattern(self, app, render_file_name) + + + if instance_data.get("multipartExr"): preview = True @@ -532,18 +528,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): for collection in collections: ext = collection.tail.lstrip(".") preview = False + render_file_name = list(collection[0]) + app = os.environ.get("AVALON_APP", "") # if filtered aov name is found in filename, toggle it for # preview video rendering - for app in self.aov_filter.keys(): - if os.environ.get("AVALON_APP", "") == app: - for aov in self.aov_filter[app]: - if re.match( - aov, - list(collection)[0] - ): - preview = True - break - + preview = match_aov_pattern(self, app, render_file_name) # toggle preview on if multipart is on if instance.get("multipartExr", False): preview = True From f175d77d006d145b1d2a88cdb71e78b26882c9af Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 25 Mar 2022 14:57:50 +0300 Subject: [PATCH 298/854] remove unused import --- openpype/pipeline/farm/patterning.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 3138dd6873..e62362b0ba 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -import os import re def match_aov_pattern(self, app, render_file_name): @@ -9,7 +8,8 @@ def match_aov_pattern(self, app, render_file_name): that we are grabbing the render filename string from the collection that we have grabbed from exp_files. """ - + + if app in self.aov_filter.keys(): for aov_pattern in self.aov_filter[app]: if re.match(aov_pattern, render_file_name): From d40429d5e7d6a70a6402bc89e2d8616d1abaf2d2 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 25 Mar 2022 14:58:30 +0300 Subject: [PATCH 299/854] remove empty lines --- openpype/pipeline/farm/patterning.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index e62362b0ba..7e717a9fff 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -8,8 +8,6 @@ def match_aov_pattern(self, app, render_file_name): that we are grabbing the render filename string from the collection that we have grabbed from exp_files. """ - - if app in self.aov_filter.keys(): for aov_pattern in self.aov_filter[app]: if re.match(aov_pattern, render_file_name): From 911cfb2a94f1b330531b2905782500c5e8ecc5e2 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 25 Mar 2022 15:04:03 +0300 Subject: [PATCH 300/854] adds empty line --- openpype/pipeline/farm/patterning.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 7e717a9fff..0ee8499e73 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- import re + def match_aov_pattern(self, app, render_file_name): """Matching against a AOV pattern in the render files In order to match the AOV name we must compare against the render filename string that we are grabbing the render filename string - from the collection that we have grabbed from exp_files. + from the collection that we have grabbed from exp_files. """ if app in self.aov_filter.keys(): for aov_pattern in self.aov_filter[app]: From 2429e5c07f792d998fc4def930e6b8288e3ed340 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 25 Mar 2022 15:24:03 +0300 Subject: [PATCH 301/854] style fixes --- openpype/pipeline/farm/patterning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 0ee8499e73..308546a1c9 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -5,7 +5,7 @@ import re def match_aov_pattern(self, app, render_file_name): """Matching against a AOV pattern in the render files In order to match the AOV name - we must compare against the render filename string + we must compare against the render filename string that we are grabbing the render filename string from the collection that we have grabbed from exp_files. """ From a029e55d3ce434fafb8c1d5984723691a413aecb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 13:47:27 +0100 Subject: [PATCH 302/854] OP-2832 - added transparency into review creator for Maya --- openpype/hosts/maya/plugins/create/create_review.py | 9 +++++++++ openpype/hosts/maya/plugins/publish/extract_playblast.py | 5 +++++ 2 files changed, 14 insertions(+) diff --git a/openpype/hosts/maya/plugins/create/create_review.py b/openpype/hosts/maya/plugins/create/create_review.py index 14a21d28ca..fbf3399f61 100644 --- a/openpype/hosts/maya/plugins/create/create_review.py +++ b/openpype/hosts/maya/plugins/create/create_review.py @@ -15,6 +15,14 @@ class CreateReview(plugin.Creator): keepImages = False isolate = False imagePlane = True + transparency = [ + "preset", + "simple", + "object sorting", + "weighted average", + "depth peeling", + "alpha cut" + ] def __init__(self, *args, **kwargs): super(CreateReview, self).__init__(*args, **kwargs) @@ -28,5 +36,6 @@ class CreateReview(plugin.Creator): data["isolate"] = self.isolate data["keepImages"] = self.keepImages data["imagePlane"] = self.imagePlane + data["transparency"] = self.transparency self.data = data diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index b233a57453..bb1ecf279d 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -73,6 +73,11 @@ class ExtractPlayblast(openpype.api.Extractor): pm.currentTime(refreshFrameInt - 1, edit=True) pm.currentTime(refreshFrameInt, edit=True) + # Override transparency if requested. + transparency = instance.data.get("transparency", 0) + if transparency != 0: + preset["viewport2_options"]["transparencyAlgorithm"] = transparency + # Isolate view is requested by having objects in the set besides a # camera. if preset.pop("isolate_view", False) and instance.data.get("isolate"): From 6e70c412e9866da08b859751088f6265371cd3ef Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Fri, 25 Mar 2022 16:12:16 +0300 Subject: [PATCH 303/854] Remove unused file --- openpype/pipeline/farm/flagging.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 openpype/pipeline/farm/flagging.py diff --git a/openpype/pipeline/farm/flagging.py b/openpype/pipeline/farm/flagging.py deleted file mode 100644 index e69de29bb2..0000000000 From 8b424f0b013b07c66a17e33d71aee2737c4effb4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 14:58:44 +0100 Subject: [PATCH 304/854] OP-2764 - fixed missed keys for old publishing in AE --- .../hosts/aftereffects/plugins/publish/collect_render.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py index 24d08b343e..d64e7abc5f 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py @@ -57,7 +57,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): compositions = CollectAERender.get_stub().get_items(True) compositions_by_id = {item.id: item for item in compositions} for inst in context: - if not inst.data["active"]: + if not inst.data.get("active", True): continue family = inst.data["family"] @@ -84,6 +84,9 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): fps = work_area_info.frameRate # TODO add resolution when supported by extension + task_name = (inst.data.get("task") or + list(asset_entity["data"]["tasks"].keys())[0]) # lega + subset_name = inst.data["subset"] instance = AERenderInstance( family=family, @@ -94,7 +97,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender): label="{} - {}".format(subset_name, family), subset=subset_name, asset=inst.data["asset"], - task=inst.data["task"], + task=task_name, attachTo=False, setMembers='', publish=True, From 585d53deee223f359e6732620ea0188f8d00ec5c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Mar 2022 15:21:22 +0100 Subject: [PATCH 305/854] flame: improving loading in integrate batch plugin --- .../plugins/publish/extract_subset_resources.py | 6 ++---- .../flame/plugins/publish/integrate_batch_group.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py index 00b87c05a0..31f7b6d574 100644 --- a/openpype/hosts/flame/plugins/publish/extract_subset_resources.py +++ b/openpype/hosts/flame/plugins/publish/extract_subset_resources.py @@ -115,10 +115,8 @@ class ExtractSubsetResources(openpype.api.Extractor): "batch_group_loader_name") # convert to None if empty string - if batch_group_loader_name: - batch_group_loader_name = str(batch_group_loader_name) - if batch_group_loader_name == "": - batch_group_loader_name = None + if batch_group_loader_name == "": + batch_group_loader_name = None # get frame range with handles for representation range frame_start_handle = frame_start - handle_start diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index f1049e4697..81b304ff0b 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -66,6 +66,9 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): if _repr.get("load_to_batch_group") is not None ] + self.log.debug("__ loadable_representations: {}".format(pformat( + loadable_representations))) + # get representation context from the repre_id representation_ids = [ repre["_id"] @@ -75,17 +78,20 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): repre_contexts = op_pipeline.load.get_repres_contexts( representation_ids) + self.log.debug("__ repre_contexts: {}".format(pformat( + repre_contexts))) + # loop all returned repres from repre_context dict for repre_id, repre_context in repre_contexts.items(): + self.log.debug("__ repre_id: {}".format(repre_id)) # get loader name by representation id loader_name = next( ( repr["loader"] for repr in loadable_representations if repr["_id"] == repre_id - ), - self.default_loader - ) + )) or self.default_loader + # get loader plugin Loader = next( ( @@ -118,8 +124,6 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): pformat(loadable_representations)) ) - - def _get_batch_group(self, instance, task_data): frame_start = instance.data["frameStart"] frame_end = instance.data["frameEnd"] From 4dcf12ee4c7c77af12c1620c756f4453b31c40c6 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 25 Mar 2022 15:30:28 +0100 Subject: [PATCH 306/854] OP-2764 - scene should be always saved --- .../aftereffects/plugins/publish/extract_save_scene.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/extract_save_scene.py b/openpype/hosts/aftereffects/plugins/publish/extract_save_scene.py index e20598b311..eb2977309f 100644 --- a/openpype/hosts/aftereffects/plugins/publish/extract_save_scene.py +++ b/openpype/hosts/aftereffects/plugins/publish/extract_save_scene.py @@ -1,15 +1,16 @@ +import pyblish.api + import openpype.api from openpype.hosts.aftereffects.api import get_stub -class ExtractSaveScene(openpype.api.Extractor): +class ExtractSaveScene(pyblish.api.ContextPlugin): """Save scene before extraction.""" order = openpype.api.Extractor.order - 0.48 label = "Extract Save Scene" hosts = ["aftereffects"] - families = ["workfile"] - def process(self, instance): + def process(self, context): stub = get_stub() stub.save() From ed4388184ad768dbf00ce050efff8eaf11d3cf7c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Mar 2022 15:56:12 +0100 Subject: [PATCH 307/854] flame: adding clip loader to current batch --- .../flame/plugins/load/load_clip_batch.py | 135 ++++++++++++++++++ .../defaults/project_settings/flame.json | 22 +++ .../projects_schema/schema_project_flame.json | 42 ++++++ 3 files changed, 199 insertions(+) create mode 100644 openpype/hosts/flame/plugins/load/load_clip_batch.py diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py new file mode 100644 index 0000000000..81af34744e --- /dev/null +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -0,0 +1,135 @@ +import os +import flame +from pprint import pformat +import openpype.hosts.flame.api as opfapi + + +class LoadClipBatch(opfapi.ClipLoader): + """Load a subset to timeline as clip + + Place clip to timeline on its asset origin timings collected + during conforming to project + """ + + families = ["render2d", "source", "plate", "render", "review"] + representations = ["exr", "dpx", "jpg", "jpeg", "png", "h264"] + + label = "Load as clip to current batch" + order = -10 + icon = "code-fork" + color = "orange" + + # settings + reel_name = "OP_LoadedReel" + clip_name_template = "{asset}_{subset}_{representation}" + + def load(self, context, name, namespace, options): + + # get flame objects + self.batch = flame.batch + + # load clip to timeline and get main variables + namespace = namespace + version = context['version'] + version_data = version.get("data", {}) + version_name = version.get("name", None) + colorspace = version_data.get("colorspace", None) + clip_name = self.clip_name_template.format( + **context["representation"]["context"]) + + # todo: settings in imageio + # convert colorspace with ocio to flame mapping + # in imageio flame section + colorspace = colorspace + + # create workfile path + workfile_dir = os.environ["AVALON_WORKDIR"] + openclip_dir = os.path.join( + workfile_dir, clip_name + ) + openclip_path = os.path.join( + openclip_dir, clip_name + ".clip" + ) + if not os.path.exists(openclip_dir): + os.makedirs(openclip_dir) + + # prepare clip data from context ad send it to openClipLoader + loading_context = { + "path": self.fname.replace("\\", "/"), + "colorspace": colorspace, + "version": "v{:0>3}".format(version_name), + "logger": self.log + + } + self.log.debug(pformat( + loading_context + )) + self.log.debug(openclip_path) + + # make openpype clip file + opfapi.OpenClipSolver(openclip_path, loading_context).make() + + # prepare Reel group in actual desktop + opc = self._get_clip( + clip_name, + openclip_path + ) + + # add additional metadata from the version to imprint Avalon knob + add_keys = [ + "frameStart", "frameEnd", "source", "author", + "fps", "handleStart", "handleEnd" + ] + + # move all version data keys to tag data + data_imprint = { + key: version_data.get(key, str(None)) + for key in add_keys + } + # add variables related to version context + data_imprint.update({ + "version": version_name, + "colorspace": colorspace, + "objectName": clip_name + }) + + # TODO: finish the containerisation + # opc_segment = opfapi.get_clip_segment(opc) + + # return opfapi.containerise( + # opc_segment, + # name, namespace, context, + # self.__class__.__name__, + # data_imprint) + + return opc + + def _get_clip(self, name, clip_path): + reel = self._get_reel() + + # with maintained openclip as opc + matching_clip = next( + ( + cl for cl in reel.clips + if cl.name.get_value() == name + ) + ) + + if not matching_clip: + created_clips = flame.import_clips(str(clip_path), reel) + return created_clips.pop() + + return matching_clip + + def _get_reel(self): + + matching_reel = [ + rg for rg in self.batch.reels + if rg.name.get_value() == self.reel_name + ] + + return ( + matching_reel.pop() + if matching_reel + else self.batch.create_reel(str(self.reel_name)) + ) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index a2b9bef103..afd0834c9d 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -92,6 +92,28 @@ "reel_group_name": "OpenPype_Reels", "reel_name": "Loaded", "clip_name_template": "{asset}_{subset}_{representation}" + }, + "LoadClipBatch": { + "enabled": true, + "families": [ + "render2d", + "source", + "plate", + "render", + "review" + ], + "representations": [ + "exr", + "dpx", + "jpg", + "jpeg", + "png", + "h264", + "mov", + "mp4" + ], + "reel_name": "OP_LoadedReel", + "clip_name_template": "{asset}_{subset}_{representation}" } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json index c991577799..fe11d63ac2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_flame.json @@ -376,6 +376,48 @@ "label": "Clip name template" } ] + }, + { + "type": "dict", + "collapsible": true, + "key": "LoadClipBatch", + "label": "Load as clip to current batch", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "list", + "key": "families", + "label": "Families", + "object_type": "text" + }, + { + "type": "list", + "key": "representations", + "label": "Representations", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "reel_name", + "label": "Reel name" + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "clip_name_template", + "label": "Clip name template" + } + ] } ] } From 267a3e04ed4fda8c3837af28379e3a8812312fb2 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Mar 2022 16:52:03 +0100 Subject: [PATCH 308/854] flame: improving batch attributes --- openpype/hosts/flame/api/batch_utils.py | 1 + openpype/hosts/flame/plugins/load/load_clip_batch.py | 11 +++++------ .../flame/plugins/publish/integrate_batch_group.py | 4 +++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/flame/api/batch_utils.py b/openpype/hosts/flame/api/batch_utils.py index 99e053faf1..43742c6e4f 100644 --- a/openpype/hosts/flame/api/batch_utils.py +++ b/openpype/hosts/flame/api/batch_utils.py @@ -56,6 +56,7 @@ def create_batch(name, frame_start, frame_duration, **kwargs): write_node.frame_padding = write_pref["frame_padding"] write_node.version_mode = write_pref["version_mode"] write_node.version_name = write_pref["version_name"] + write_node.version_padding = write_pref["version_padding"] flame.batch.connect_nodes(comp_node, "Result", write_node, "Front") diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index 81af34744e..bf0bbb5168 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -108,12 +108,11 @@ class LoadClipBatch(opfapi.ClipLoader): reel = self._get_reel() # with maintained openclip as opc - matching_clip = next( - ( - cl for cl in reel.clips - if cl.name.get_value() == name - ) - ) + matching_clip = None + for cl in reel.clips: + if cl.name.get_value() != name: + continue + matching_clip = cl if not matching_clip: created_clips = flame.import_clips(str(clip_path), reel) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 81b304ff0b..536bf0d807 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -249,6 +249,7 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): # Only available if create_clip = True. version_mode = "Follow Iteration" version_name = "v" + version_padding = 3 return { "name": name, @@ -266,7 +267,8 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): "frame_index_mode": frame_index_mode, "frame_padding": frame_padding, "version_mode": version_mode, - "version_name": version_name + "version_name": version_name, + "version_padding": version_padding } def _get_shot_task_dir_path(self, instance, task_data): From f8e99f38c97cf37b8001e4c5848d93e00a7f9107 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 25 Mar 2022 16:53:47 +0100 Subject: [PATCH 309/854] flame: make dirs for batch renders add one more directory layer for renders --- .../hosts/flame/plugins/publish/integrate_batch_group.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 536bf0d807..eaab429111 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -199,6 +199,9 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): render_dir_path = os.path.join( task_workfile_path, "render", "flame") + if not os.path.exists(render_dir_path): + os.makedirs(render_dir_path, mode=0o777) + # TODO: add most of these to `imageio/flame/batch/write_node` name = "{project[code]}_{asset}_{task[name]}".format( **anatomy_data @@ -208,7 +211,7 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): # /path/to/file.[0001-0010].exr media_path = render_dir_path # name of file represented by tokens - media_path_pattern = "_v." + media_path_pattern = "_v/_v." # The Create Open Clip attribute of the Write File node. \ # Determines if an Open Clip is created by the Write File node. create_clip = True From dc86a0d2bc452546e9f99662090c78321222c0ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Mar 2022 01:37:37 +0000 Subject: [PATCH 310/854] Bump node-forge from 1.2.1 to 1.3.0 in /website Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.2.1 to 1.3.0. - [Release notes](https://github.com/digitalbazaar/forge/releases) - [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md) - [Commits](https://github.com/digitalbazaar/forge/compare/v1.2.1...v1.3.0) --- updated-dependencies: - dependency-name: node-forge dependency-type: indirect ... Signed-off-by: dependabot[bot] --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 7f677aaed7..0dd96f63ba 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -5207,9 +5207,9 @@ node-fetch@2.6.7: whatwg-url "^5.0.0" node-forge@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.2.1.tgz#82794919071ef2eb5c509293325cec8afd0fd53c" - integrity sha512-Fcvtbb+zBcZXbTTVwqGA5W+MKBj56UjVRevvchv5XrcyXbmNdesfZL37nlcWOfpgHhgmxApw3tQbTr4CqNmX4w== + version "1.3.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.0.tgz#37a874ea723855f37db091e6c186e5b67a01d4b2" + integrity sha512-08ARB91bUi6zNKzVmaj3QO7cr397uiDT2nJ63cHjyNtCTWIgvS47j3eT0WfzUwS9+6Z5YshRaoasFkXCKrIYbA== node-releases@^2.0.1: version "2.0.2" From 91d9eb57981946a3ee948ec3058c71da7e832ac1 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 26 Mar 2022 03:36:43 +0000 Subject: [PATCH 311/854] [Automated] Bump version --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++---- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f20276cbd7..abe9eaa3ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,24 +1,49 @@ # Changelog -## [3.9.2-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.2-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...HEAD) +### πŸ“– Documentation + +- Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951) + **πŸš€ Enhancements** +- Slack: Added configurable maximum file size of review upload to Slack [\#2945](https://github.com/pypeclub/OpenPype/pull/2945) +- NewPublisher: Prepared implementation of optional pyblish plugin [\#2943](https://github.com/pypeclub/OpenPype/pull/2943) +- Workfiles: Open published workfiles [\#2925](https://github.com/pypeclub/OpenPype/pull/2925) - CI: change the version bump logic [\#2919](https://github.com/pypeclub/OpenPype/pull/2919) - Deadline: Add headless argument [\#2916](https://github.com/pypeclub/OpenPype/pull/2916) +- Nuke: Add no-audio Tag [\#2911](https://github.com/pypeclub/OpenPype/pull/2911) - Ftrack: Fill workfile in custom attribute [\#2906](https://github.com/pypeclub/OpenPype/pull/2906) +- Nuke: improving readability [\#2903](https://github.com/pypeclub/OpenPype/pull/2903) - Settings UI: Add simple tooltips for settings entities [\#2901](https://github.com/pypeclub/OpenPype/pull/2901) **πŸ› Bug fixes** +- LogViewer: Don't refresh on initialization [\#2949](https://github.com/pypeclub/OpenPype/pull/2949) +- General: anatomy data with correct task short key [\#2947](https://github.com/pypeclub/OpenPype/pull/2947) +- SceneInventory: Fix imports in UI [\#2944](https://github.com/pypeclub/OpenPype/pull/2944) +- Slack: add generic exception [\#2941](https://github.com/pypeclub/OpenPype/pull/2941) +- General: Python specific vendor paths on env injection [\#2939](https://github.com/pypeclub/OpenPype/pull/2939) +- General: More fail safe delete old versions [\#2936](https://github.com/pypeclub/OpenPype/pull/2936) +- Settings UI: Collapsed of collapsible wrapper works as expected [\#2934](https://github.com/pypeclub/OpenPype/pull/2934) +- General: Don't print log record on OSError [\#2926](https://github.com/pypeclub/OpenPype/pull/2926) +- Hiero: Fix import of 'register\_event\_callback' [\#2924](https://github.com/pypeclub/OpenPype/pull/2924) - Ftrack: Missing Ftrack id after editorial publish [\#2905](https://github.com/pypeclub/OpenPype/pull/2905) -- AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) **πŸ”€ Refactored code** +- General: Move Attribute Definitions from pipeline [\#2931](https://github.com/pypeclub/OpenPype/pull/2931) +- General: Removed silo references and terminal splash [\#2927](https://github.com/pypeclub/OpenPype/pull/2927) +- General: Move pipeline constants to OpenPype [\#2918](https://github.com/pypeclub/OpenPype/pull/2918) - General: Move formatting and workfile functions [\#2914](https://github.com/pypeclub/OpenPype/pull/2914) +- General: Move remaining plugins from avalon [\#2912](https://github.com/pypeclub/OpenPype/pull/2912) + +**Merged pull requests:** + +- Maya: Do not pass `set` to maya commands \(fixes support for older maya versions\) [\#2932](https://github.com/pypeclub/OpenPype/pull/2932) ## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) @@ -42,6 +67,7 @@ - General: Remove forgotten use of avalon Creator [\#2885](https://github.com/pypeclub/OpenPype/pull/2885) - General: Avoid circular import [\#2884](https://github.com/pypeclub/OpenPype/pull/2884) - Fixes for attaching loaded containers \(\#2837\) [\#2874](https://github.com/pypeclub/OpenPype/pull/2874) +- Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826) **πŸ”€ Refactored code** @@ -73,11 +99,11 @@ - General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) - global: letter box calculated on output as last process [\#2812](https://github.com/pypeclub/OpenPype/pull/2812) - Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) -- Manager: Update all to latest button [\#2805](https://github.com/pypeclub/OpenPype/pull/2805) **πŸ› Bug fixes** - General: Missing time function [\#2877](https://github.com/pypeclub/OpenPype/pull/2877) +- AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) - Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) - Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) - General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) @@ -94,7 +120,6 @@ - Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) - Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828) - Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827) -- Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826) - Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) - Deadline: more detailed temp file name for environment json [\#2824](https://github.com/pypeclub/OpenPype/pull/2824) - General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821) diff --git a/openpype/version.py b/openpype/version.py index 2390309e76..84ea02fd08 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.2-nightly.1" +__version__ = "3.9.2-nightly.2" diff --git a/pyproject.toml b/pyproject.toml index 90e264d456..46515b4785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.2-nightly.1" # OpenPype +version = "3.9.2-nightly.2" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 4a68b1b00aa6c3c3476e97cd99badec073967799 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 26 Mar 2022 03:37:21 +0000 Subject: [PATCH 312/854] Bump minimist from 1.2.5 to 1.2.6 in /website Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6. - [Release notes](https://github.com/substack/minimist/releases) - [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6) --- updated-dependencies: - dependency-name: minimist dependency-type: indirect ... Signed-off-by: dependabot[bot] --- website/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/yarn.lock b/website/yarn.lock index 7f677aaed7..ed416a5cc4 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -5125,9 +5125,9 @@ minimatch@^3.0.4: brace-expansion "^1.1.7" minimist@^1.2.0, minimist@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" - integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + version "1.2.6" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" + integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== mkdirp@^0.5.5: version "0.5.5" From cc602f1da0829aaa7226a1d1dc9c36111464fc7c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 26 Mar 2022 14:37:01 +0100 Subject: [PATCH 313/854] added implementation of overlay messages --- openpype/tools/utils/overlay_messages.py | 315 +++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 openpype/tools/utils/overlay_messages.py diff --git a/openpype/tools/utils/overlay_messages.py b/openpype/tools/utils/overlay_messages.py new file mode 100644 index 0000000000..ade037817a --- /dev/null +++ b/openpype/tools/utils/overlay_messages.py @@ -0,0 +1,315 @@ +import uuid + +from Qt import QtWidgets, QtCore, QtGui + +from .lib import set_style_property + + +class CloseButton(QtWidgets.QFrame): + """Close button drawed manually.""" + + clicked = QtCore.Signal() + + def __init__(self, parent): + super(CloseButton, self).__init__(parent) + self._mouse_pressed = False + policy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, + QtWidgets.QSizePolicy.Fixed + ) + self.setSizePolicy(policy) + + def sizeHint(self): + size = self.fontMetrics().height() + return QtCore.QSize(size, size) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(CloseButton, 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(CloseButton, self).mouseReleaseEvent(event) + + def paintEvent(self, event): + rect = self.rect() + painter = QtGui.QPainter(self) + painter.setClipRect(event.rect()) + pen = QtGui.QPen() + pen.setWidth(2) + pen.setColor(QtGui.QColor(255, 255, 255)) + pen.setStyle(QtCore.Qt.SolidLine) + pen.setCapStyle(QtCore.Qt.RoundCap) + painter.setPen(pen) + offset = int(rect.height() / 4) + top = rect.top() + offset + left = rect.left() + offset + right = rect.right() - offset + bottom = rect.bottom() - offset + painter.drawLine( + left, top, + right, bottom + ) + painter.drawLine( + left, bottom, + right, top + ) + + +class MessageWidget(QtWidgets.QFrame): + """Message widget showed as overlay. + + Message is hidden after timeout but can be overriden by mouse hover. + Mouse hover can add additional 2 seconds of widget's visibility. + + Args: + message_id (str): Unique identifier of message widget for + 'MessageOverlayObject'. + message (str): Text shown in message. + parent (QWidget): Parent widget where message is visible. + timeout (int): Timeout of message's visibility (default 5000). + message_type (str): Property which can be used in styles for specific + kid of message. + """ + + close_requested = QtCore.Signal(str) + _default_timeout = 5000 + + def __init__( + self, message_id, message, parent, timeout=None, message_type=None + ): + super(MessageWidget, self).__init__(parent) + self.setObjectName("OverlayMessageWidget") + + if message_type: + set_style_property(self, "type", message_type) + + if not timeout: + timeout = self._default_timeout + timeout_timer = QtCore.QTimer() + timeout_timer.setInterval(timeout) + timeout_timer.setSingleShot(True) + + hover_timer = QtCore.QTimer() + hover_timer.setInterval(2000) + hover_timer.setSingleShot(True) + + label_widget = QtWidgets.QLabel(message, self) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + label_widget.setWordWrap(True) + close_btn = CloseButton(self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 5, 0, 5) + layout.addWidget(label_widget, 1) + layout.addWidget(close_btn, 0) + + close_btn.clicked.connect(self._on_close_clicked) + timeout_timer.timeout.connect(self._on_timer_timeout) + hover_timer.timeout.connect(self._on_hover_timeout) + + self._label_widget = label_widget + self._message_id = message_id + self._timeout_timer = timeout_timer + self._hover_timer = hover_timer + + def size_hint_without_word_wrap(self): + """Size hint in cases that word wrap of label is disabled.""" + self._label_widget.setWordWrap(False) + size_hint = self.sizeHint() + self._label_widget.setWordWrap(True) + return size_hint + + def showEvent(self, event): + """Start timeout on show.""" + super(MessageWidget, self).showEvent(event) + self._timeout_timer.start() + + def _on_timer_timeout(self): + """On message timeout.""" + # Skip closing if hover timer is active + if not self._hover_timer.isActive(): + self._close_message() + + def _on_hover_timeout(self): + """Hover timer timed out.""" + # Check if is still under widget + if self.underMouse(): + self._hover_timer.start() + else: + self._close_message() + + def _on_close_clicked(self): + self._close_message() + + def _close_message(self): + """Emmit close request to 'MessageOverlayObject'.""" + self.close_requested.emit(self._message_id) + + def enterEvent(self, event): + """Start hover timer on hover.""" + super(MessageWidget, self).enterEvent(event) + self._hover_timer.start() + + def leaveEvent(self, event): + """Start hover timer on hover leave.""" + super(MessageWidget, self).leaveEvent(event) + self._hover_timer.start() + + +class MessageOverlayObject(QtCore.QObject): + """Object that can be used to add overlay messages. + + Args: + widget (QWidget): + """ + + def __init__(self, widget): + super(MessageOverlayObject, self).__init__() + + widget.installEventFilter(self) + + # Timer which triggers recalculation of message positions + recalculate_timer = QtCore.QTimer() + recalculate_timer.setInterval(10) + + recalculate_timer.timeout.connect(self._recalculate_positions) + + self._widget = widget + self._recalculate_timer = recalculate_timer + + self._messages_order = [] + self._closing_messages = set() + self._messages = {} + self._spacing = 5 + self._move_size = 4 + self._move_size_remove = 8 + + def add_message(self, message, timeout=None, message_type=None): + """Add single message into overlay. + + Args: + message (str): Message that will be shown. + timeout (int): Message timeout. + message_type (str): Message type can be used as property in + stylesheets. + """ + # Skip empty messages + if not message: + return + + # Create unique id of message + label_id = str(uuid.uuid4()) + # Create message widget + widget = MessageWidget( + label_id, message, self._widget, timeout, message_type + ) + widget.close_requested.connect(self._on_message_close_request) + widget.show() + + # Move widget outside of window + pos = widget.pos() + pos.setY(pos.y() - widget.height()) + widget.move(pos) + # Store message + self._messages[label_id] = widget + self._messages_order.append(label_id) + # Trigger recalculation timer + self._recalculate_timer.start() + + def _on_message_close_request(self, label_id): + """Message widget requested removement.""" + + widget = self._messages.get(label_id) + if widget is not None: + # Add message to closing messages and start recalculation + self._closing_messages.add(label_id) + self._recalculate_timer.start() + + def _recalculate_positions(self): + """Recalculate positions of widgets.""" + + # Skip if there are no messages to process + if not self._messages_order: + self._recalculate_timer.stop() + return + + # All message widgets are in expected positions + all_at_place = True + # Starting y position + pos_y = self._spacing + # Current widget width + widget_width = self._widget.width() + max_width = widget_width - (2 * self._spacing) + widget_half_width = widget_width / 2 + + # Store message ids that should be removed + message_ids_to_remove = set() + for message_id in reversed(self._messages_order): + widget = self._messages[message_id] + pos = widget.pos() + # Messages to remove are moved upwards + if message_id in self._closing_messages: + bottom = pos.y() + widget.height() + # Add message to remove if is not visible + if bottom < 0 or self._move_size_remove < 1: + message_ids_to_remove.add(message_id) + continue + + # Calculate new y position of message + dst_pos_y = pos.y() - self._move_size_remove + + else: + # Calculate y position of message + # - use y position of previous message widget and add + # move size if is not in final destination yet + if widget.underMouse(): + dst_pos_y = pos.y() + elif pos.y() == pos_y or self._move_size < 1: + dst_pos_y = pos_y + elif pos.y() < pos_y: + dst_pos_y = min(pos_y, pos.y() + self._move_size) + else: + dst_pos_y = max(pos_y, pos.y() - self._move_size) + + # Store if widget is in place where should be + if all_at_place and dst_pos_y != pos_y: + all_at_place = False + + # Calculate ideal width and height of message widget + height = widget.heightForWidth(max_width) + w_size_hint = widget.size_hint_without_word_wrap() + widget.resize(min(max_width, w_size_hint.width()), height) + + # Center message widget + size = widget.size() + pos_x = widget_half_width - (size.width() / 2) + # Move widget to destination position + widget.move(pos_x, dst_pos_y) + + # Add message widget height and spacing for next message widget + pos_y += size.height() + self._spacing + + # Remove widgets to remove + for message_id in message_ids_to_remove: + self._messages_order.remove(message_id) + self._closing_messages.remove(message_id) + widget = self._messages.pop(message_id) + widget.hide() + widget.deleteLater() + + # Stop recalculation timer if all widgets are where should be + if all_at_place: + self._recalculate_timer.stop() + + def eventFilter(self, source, event): + # Trigger recalculation of timer on resize of widget + if source is self._widget and event.type() == QtCore.QEvent.Resize: + self._recalculate_timer.start() + + return super(MessageOverlayObject, self).eventFilter(source, event) From 8bc010a4f409a66f5536fc8bdc39dd4094dee05d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Sat, 26 Mar 2022 14:54:08 +0100 Subject: [PATCH 314/854] define default styles for overlay messages --- openpype/style/data.json | 6 +++++- openpype/style/style.css | 20 +++++++++++++++++++ openpype/tools/utils/overlay_messages.py | 25 ++++++++++++++---------- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/openpype/style/data.json b/openpype/style/data.json index a76a77015b..15d9472e3e 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -61,7 +61,11 @@ "icon-entity-default": "#bfccd6", "icon-entity-disabled": "#808080", "font-entity-deprecated": "#666666", - + "overlay-messages": { + "close-btn": "#D3D8DE", + "bg-success": "#458056", + "bg-success-hover": "#55a066" + }, "tab-widget": { "bg": "#21252B", "bg-selected": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index df83600973..4d83e39780 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -687,6 +687,26 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } +/* Messages overlay */ +#OverlayMessageWidget { + border-radius: 0.2em; + background: {color:bg-buttons}; +} + +#OverlayMessageWidget:hover { + background: {color:bg-button-hover}; +} +#OverlayMessageWidget[type="success"] { + background: {color:overlay-messages:bg-success}; +} +#OverlayMessageWidget[type="success"]:hover { + background: {color:overlay-messages:bg-success-hover}; +} + +#OverlayMessageWidget QWidget { + background: transparent; +} + /* Password dialog*/ #PasswordBtn { border: none; diff --git a/openpype/tools/utils/overlay_messages.py b/openpype/tools/utils/overlay_messages.py index ade037817a..93082b9fb7 100644 --- a/openpype/tools/utils/overlay_messages.py +++ b/openpype/tools/utils/overlay_messages.py @@ -2,6 +2,8 @@ import uuid from Qt import QtWidgets, QtCore, QtGui +from openpype.style import get_objected_colors + from .lib import set_style_property @@ -12,6 +14,9 @@ class CloseButton(QtWidgets.QFrame): def __init__(self, parent): super(CloseButton, self).__init__(parent) + colors = get_objected_colors() + close_btn_color = colors["overlay-messages"]["close-btn"] + self._color = close_btn_color.get_qcolor() self._mouse_pressed = False policy = QtWidgets.QSizePolicy( QtWidgets.QSizePolicy.Fixed, @@ -42,7 +47,7 @@ class CloseButton(QtWidgets.QFrame): painter.setClipRect(event.rect()) pen = QtGui.QPen() pen.setWidth(2) - pen.setColor(QtGui.QColor(255, 255, 255)) + pen.setColor(self._color) pen.setStyle(QtCore.Qt.SolidLine) pen.setCapStyle(QtCore.Qt.RoundCap) painter.setPen(pen) @@ -61,7 +66,7 @@ class CloseButton(QtWidgets.QFrame): ) -class MessageWidget(QtWidgets.QFrame): +class OverlayMessageWidget(QtWidgets.QFrame): """Message widget showed as overlay. Message is hidden after timeout but can be overriden by mouse hover. @@ -81,9 +86,9 @@ class MessageWidget(QtWidgets.QFrame): _default_timeout = 5000 def __init__( - self, message_id, message, parent, timeout=None, message_type=None + self, message_id, message, parent, message_type=None, timeout=None ): - super(MessageWidget, self).__init__(parent) + super(OverlayMessageWidget, self).__init__(parent) self.setObjectName("OverlayMessageWidget") if message_type: @@ -127,7 +132,7 @@ class MessageWidget(QtWidgets.QFrame): def showEvent(self, event): """Start timeout on show.""" - super(MessageWidget, self).showEvent(event) + super(OverlayMessageWidget, self).showEvent(event) self._timeout_timer.start() def _on_timer_timeout(self): @@ -153,12 +158,12 @@ class MessageWidget(QtWidgets.QFrame): def enterEvent(self, event): """Start hover timer on hover.""" - super(MessageWidget, self).enterEvent(event) + super(OverlayMessageWidget, self).enterEvent(event) self._hover_timer.start() def leaveEvent(self, event): """Start hover timer on hover leave.""" - super(MessageWidget, self).leaveEvent(event) + super(OverlayMessageWidget, self).leaveEvent(event) self._hover_timer.start() @@ -190,7 +195,7 @@ class MessageOverlayObject(QtCore.QObject): self._move_size = 4 self._move_size_remove = 8 - def add_message(self, message, timeout=None, message_type=None): + def add_message(self, message, message_type=None, timeout=None): """Add single message into overlay. Args: @@ -206,8 +211,8 @@ class MessageOverlayObject(QtCore.QObject): # Create unique id of message label_id = str(uuid.uuid4()) # Create message widget - widget = MessageWidget( - label_id, message, self._widget, timeout, message_type + widget = OverlayMessageWidget( + label_id, message, self._widget, message_type, timeout ) widget.close_requested.connect(self._on_message_close_request) widget.show() From e631218ee44c36f9f7fabdcf666d77daccd771be Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:29:26 +0300 Subject: [PATCH 315/854] refactor function, fix comments --- .../plugins/publish/submit_publish_job.py | 9 +++--- openpype/pipeline/farm/patterning.py | 30 +++++++++++-------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 7f65011864..0a374a75b6 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -452,11 +452,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): render_file_name = os.path.basename(col[0]) else: render_file_name = os.path.basename(col) - - preview = match_aov_pattern(self, app, render_file_name) + aov_patterns = self.aov_filter.keys() + preview = match_aov_pattern(app, aov_patterns, render_file_name) - + # toggle preview on if multipart is on if instance_data.get("multipartExr"): preview = True @@ -530,9 +530,10 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): preview = False render_file_name = list(collection[0]) app = os.environ.get("AVALON_APP", "") + aov_patterns = self.aov_filter.keys() # if filtered aov name is found in filename, toggle it for # preview video rendering - preview = match_aov_pattern(self, app, render_file_name) + preview = match_aov_pattern(app, aov_patterns, render_file_name) # toggle preview on if multipart is on if instance.get("multipartExr", False): preview = True diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 308546a1c9..0ad7e682fa 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -1,16 +1,22 @@ # -*- coding: utf-8 -*- import re - -def match_aov_pattern(self, app, render_file_name): - """Matching against a AOV pattern in the render files - In order to match the AOV name - we must compare against the render filename string - that we are grabbing the render filename string - from the collection that we have grabbed from exp_files. +def match_aov_pattern(app, aov_patterns, render_file_name): + """Matching against a `AOV` pattern in the render files. + + In order to match the AOV name we must compare + against the render filename string that we are + grabbing the render filename string from the collection + that we have grabbed from `exp_files`. + + Args: + app (str): Host name. + aov_patterns (list): List of AOV patterns from AOV filters. + render_file_name (str): Incoming file name to match against. + + Returns: + bool: Review state for rendered file (render_file_name). """ - if app in self.aov_filter.keys(): - for aov_pattern in self.aov_filter[app]: - if re.match(aov_pattern, render_file_name): - preview = True - return preview \ No newline at end of file + aov_pattern = aov_patterns.get(app, []) + if aov_pattern: + return any(re.match(aov_pattern, render_file_name) for aov_pattern in aov_patterns) From cc86482f028b8fa34ab248ead2075db9af9983a4 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:30:50 +0300 Subject: [PATCH 316/854] style fixes --- openpype/pipeline/farm/patterning.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 0ad7e682fa..60467d47fa 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- import re + def match_aov_pattern(app, aov_patterns, render_file_name): """Matching against a `AOV` pattern in the render files. - In order to match the AOV name we must compare - against the render filename string that we are - grabbing the render filename string from the collection + In order to match the AOV name we must compare + against the render filename string that we are + grabbing the render filename string from the collection that we have grabbed from `exp_files`. Args: From 6cd0423e5f8f0d3cacd9f24d86508c2280d7e90e Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:31:58 +0300 Subject: [PATCH 317/854] remove whitespace --- openpype/pipeline/farm/patterning.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 60467d47fa..d0a25f8e77 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -4,17 +4,17 @@ import re def match_aov_pattern(app, aov_patterns, render_file_name): """Matching against a `AOV` pattern in the render files. - + In order to match the AOV name we must compare against the render filename string that we are grabbing the render filename string from the collection that we have grabbed from `exp_files`. - + Args: app (str): Host name. aov_patterns (list): List of AOV patterns from AOV filters. render_file_name (str): Incoming file name to match against. - + Returns: bool: Review state for rendered file (render_file_name). """ From 72b6ae620a5a69a312976f6821c3b443d9f27478 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:33:37 +0300 Subject: [PATCH 318/854] remove extra line and whitespace --- .../modules/deadline/plugins/publish/submit_publish_job.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 0a374a75b6..a4e07a0684 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -454,8 +454,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): render_file_name = os.path.basename(col) aov_patterns = self.aov_filter.keys() preview = match_aov_pattern(app, aov_patterns, render_file_name) - - + # toggle preview on if multipart is on if instance_data.get("multipartExr"): preview = True From d45a7fdb3d849b11ff526d94c00fc9d6a4c60f01 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:36:22 +0300 Subject: [PATCH 319/854] Fix line length --- openpype/pipeline/farm/patterning.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index d0a25f8e77..ad59ecb509 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -20,4 +20,5 @@ def match_aov_pattern(app, aov_patterns, render_file_name): """ aov_pattern = aov_patterns.get(app, []) if aov_pattern: - return any(re.match(aov_pattern, render_file_name) for aov_pattern in aov_patterns) + return any(re.match(aov_pattern, render_file_name) + for aov_pattern in aov_patterns) From 071ae5876571252e1c025fdd9dcc01cf24fcbd00 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:37:49 +0300 Subject: [PATCH 320/854] fix over indentation --- openpype/pipeline/farm/patterning.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index ad59ecb509..e92078b27c 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -20,5 +20,5 @@ def match_aov_pattern(app, aov_patterns, render_file_name): """ aov_pattern = aov_patterns.get(app, []) if aov_pattern: - return any(re.match(aov_pattern, render_file_name) - for aov_pattern in aov_patterns) + return any(re.match(aov_pattern, render_file_name) + for aov_pattern in aov_patterns) From b54bba9b0f492908d33f3f6e77f63adadf224663 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:39:15 +0300 Subject: [PATCH 321/854] fix under indentaiton --- openpype/pipeline/farm/patterning.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index e92078b27c..f853b77601 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -21,4 +21,4 @@ def match_aov_pattern(app, aov_patterns, render_file_name): aov_pattern = aov_patterns.get(app, []) if aov_pattern: return any(re.match(aov_pattern, render_file_name) - for aov_pattern in aov_patterns) + for aov_pattern in aov_patterns) From b11d73671f7adc94d9a439f76b2f00d2172ed422 Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:48:19 +0300 Subject: [PATCH 322/854] Fix function error, assuming one aov_pattern --- openpype/pipeline/farm/patterning.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index f853b77601..e534ed7506 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -20,5 +20,4 @@ def match_aov_pattern(app, aov_patterns, render_file_name): """ aov_pattern = aov_patterns.get(app, []) if aov_pattern: - return any(re.match(aov_pattern, render_file_name) - for aov_pattern in aov_patterns) + return any(re.match(aov_pattern, render_file_name)) From 340afab7d468cae8d4b30d7b90315b8ef3a9883a Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 21:55:13 +0300 Subject: [PATCH 323/854] remove unneeded any() --- openpype/pipeline/farm/patterning.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index e534ed7506..4703f4999d 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -20,4 +20,6 @@ def match_aov_pattern(app, aov_patterns, render_file_name): """ aov_pattern = aov_patterns.get(app, []) if aov_pattern: - return any(re.match(aov_pattern, render_file_name)) + if re.match(aov_pattern, render_file_name): + preview = True + return preview From 087f939aa6e4ea19a3addce828cb86753fb27cdb Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 22:46:57 +0300 Subject: [PATCH 324/854] add missing else statement --- openpype/pipeline/farm/patterning.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 4703f4999d..e1c05df77f 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -23,3 +23,5 @@ def match_aov_pattern(app, aov_patterns, render_file_name): if re.match(aov_pattern, render_file_name): preview = True return preview + else: + return False From 67f5f69f00b7ad14e5d7d37c515eb80f43520bbb Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Sat, 26 Mar 2022 22:57:51 +0300 Subject: [PATCH 325/854] fix passing keys only to matching function --- .../modules/deadline/plugins/publish/submit_publish_job.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index a4e07a0684..16078fc236 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -452,7 +452,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): render_file_name = os.path.basename(col[0]) else: render_file_name = os.path.basename(col) - aov_patterns = self.aov_filter.keys() + aov_patterns = self.aov_filter preview = match_aov_pattern(app, aov_patterns, render_file_name) # toggle preview on if multipart is on @@ -529,7 +529,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): preview = False render_file_name = list(collection[0]) app = os.environ.get("AVALON_APP", "") - aov_patterns = self.aov_filter.keys() + aov_patterns = self.aov_filter # if filtered aov name is found in filename, toggle it for # preview video rendering preview = match_aov_pattern(app, aov_patterns, render_file_name) From b711ba51745768809cdbc0c13c8fad0d67da71b9 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Sun, 27 Mar 2022 23:48:24 +0200 Subject: [PATCH 326/854] fix validator --- .../publish/validate_skeletalmesh_hierarchy.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py index dda7e063f6..cffbd13834 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py @@ -22,14 +22,17 @@ class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin): def process(self, instance): geo = instance.data.get("geometry") joints = instance.data.get("joints") - joints_parents = cmds.ls(joints, long=True)[0].split("|")[1:-1] - geo_parents = cmds.ls(geo, long=True)[0].split("|")[1:-1] + # joints_parents = cmds.ls(joints, long=True)[0].split("|")[1:-1] + # geo_parents = cmds.ls(geo, long=True)[0].split("|")[1:-1] - self.log.info(joints_parents) - self.log.info(geo_parents) - self.log.info(set(joints_parents + geo_parents)) + joints_parents = cmds.ls(joints, long=True) + geo_parents = cmds.ls(geo, long=True) - if len(set(joints_parents + geo_parents)) != 1: + parents_set = { + parent.split("|")[1] for parent in (joints_parents + geo_parents) + } + + if len(set(parents_set)) != 1: raise PublishXmlValidationError( self, "Multiple roots on geometry or joints." From 3a55b806345ec823177f30796ba018d860aea33d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 28 Mar 2022 00:07:15 +0200 Subject: [PATCH 327/854] fix docstring and remove unused code --- .../plugins/publish/validate_skeletalmesh_hierarchy.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py index cffbd13834..54a86d27cf 100644 --- a/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py +++ b/openpype/hosts/maya/plugins/publish/validate_skeletalmesh_hierarchy.py @@ -7,12 +7,7 @@ from maya import cmds class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin): - """Adheres to the content of 'model' family - - - Must have one top group. (configurable) - - Must only contain: transforms, meshes and groups - - """ + """Validates that nodes has common root.""" order = openpype.api.ValidateContentsOrder hosts = ["maya"] @@ -22,8 +17,6 @@ class ValidateSkeletalMeshHierarchy(pyblish.api.InstancePlugin): def process(self, instance): geo = instance.data.get("geometry") joints = instance.data.get("joints") - # joints_parents = cmds.ls(joints, long=True)[0].split("|")[1:-1] - # geo_parents = cmds.ls(geo, long=True)[0].split("|")[1:-1] joints_parents = cmds.ls(joints, long=True) geo_parents = cmds.ls(geo, long=True) From 228d3cfc004b21398ee0fb381ed8ea5fa28e6826 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 28 Mar 2022 01:54:40 +0200 Subject: [PATCH 328/854] fix hierarchy --- .../publish/extract_unreal_skeletalmesh.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index 5b0eb5a3bc..98dbf117dc 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -66,20 +66,22 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): instance.data.get("variant", "") ) + joints_parents = cmds.ls(joints, long=True) + geo_parents = cmds.ls(geo, long=True) + + parent_node = { + parent.split("|")[1] for parent in (joints_parents + geo_parents) + }.pop() + renamed_to_extract = [] for node in to_extract: node_path = node.split("|") node_path[1] = parent renamed_to_extract.append("|".join(node_path)) - with renamed(joints_parent, parent): - with parent_nodes(renamed_to_extract, parent=parent): - rooted = [ - "{}|{}".format(parent, i.split("|")[-1]) - for i in renamed_to_extract - ] - self.log.info("Un-parenting: {}".format(rooted, path)) - fbx_exporter.export(rooted, path) + with renamed(parent_node, parent): + self.log.info("Extracting: {}".format(renamed_to_extract, path)) + fbx_exporter.export(renamed_to_extract, path) if "representations" not in instance.data: instance.data["representations"] = [] From 1a01be7ec094585810079c50d4755ca265355dd4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Mar 2022 11:06:50 +0200 Subject: [PATCH 329/854] added option to not log invalid types in base class --- openpype/settings/entities/base_entity.py | 37 +++++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index 76700d605d..21ee44ae77 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -173,6 +173,10 @@ class BaseItemEntity(BaseEntity): # Entity has set `_project_override_value` (is not NOT_SET) self.had_project_override = False + self._default_log_invalid_types = True + self._studio_log_invalid_types = True + self._project_log_invalid_types = True + # Callbacks that are called on change. # - main current purspose is to register GUI callbacks self.on_change_callbacks = [] @@ -419,7 +423,7 @@ class BaseItemEntity(BaseEntity): raise InvalidValueType(self.valid_value_types, type(value), self.path) # TODO convert to private method - def _check_update_value(self, value, value_source): + def _check_update_value(self, value, value_source, log_invalid_types=True): """Validation of value on update methods. Update methods update data from currently saved settings so it is @@ -447,16 +451,17 @@ class BaseItemEntity(BaseEntity): if new_value is not NOT_SET: return new_value - # Warning log about invalid value type. - self.log.warning( - ( - "{} Got invalid value type for {} values." - " Expected types: {} | Got Type: {} | Value: \"{}\"" - ).format( - self.path, value_source, - self.valid_value_types, type(value), str(value) + if log_invalid_types: + # Warning log about invalid value type. + self.log.warning( + ( + "{} Got invalid value type for {} values." + " Expected types: {} | Got Type: {} | Value: \"{}\"" + ).format( + self.path, value_source, + self.valid_value_types, type(value), str(value) + ) ) - ) return NOT_SET def available_for_role(self, role_name=None): @@ -985,7 +990,7 @@ class ItemEntity(BaseItemEntity): return self.root_item.get_entity_from_path(path) @abstractmethod - def update_default_value(self, parent_values): + def update_default_value(self, parent_values, log_invalid_types=True): """Fill default values on startup or on refresh. Default values stored in `openpype` repository should update all items @@ -995,11 +1000,13 @@ class ItemEntity(BaseItemEntity): Args: parent_values (dict): Values of parent's item. But in case item is used as widget, `parent_values` contain value for item. + log_invalid_types (bool): Log invalid type of value. Used when + entity can have children with same keys and different types. """ pass @abstractmethod - def update_studio_value(self, parent_values): + def update_studio_value(self, parent_values, log_invalid_types=True): """Fill studio override values on startup or on refresh. Set studio value if is not set to NOT_SET, in that case studio @@ -1008,11 +1015,13 @@ class ItemEntity(BaseItemEntity): Args: parent_values (dict): Values of parent's item. But in case item is used as widget, `parent_values` contain value for item. + log_invalid_types (bool): Log invalid type of value. Used when + entity can have children with same keys and different types. """ pass @abstractmethod - def update_project_value(self, parent_values): + def update_project_value(self, parent_values, log_invalid_types=True): """Fill project override values on startup, refresh or project change. Set project value if is not set to NOT_SET, in that case project @@ -1021,5 +1030,7 @@ class ItemEntity(BaseItemEntity): Args: parent_values (dict): Values of parent's item. But in case item is used as widget, `parent_values` contain value for item. + log_invalid_types (bool): Log invalid type of value. Used when + entity can have children with same keys and different types. """ pass From 2d73df190a124f72dc0ba28110fb43e3c79a8949 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Mar 2022 11:15:36 +0200 Subject: [PATCH 330/854] specify places when to log and not invalid values --- .../settings/entities/dict_conditional.py | 86 ++++++++++++------ .../entities/dict_immutable_keys_entity.py | 88 +++++++++++++------ .../entities/dict_mutable_keys_entity.py | 38 +++++--- openpype/settings/entities/input_entities.py | 37 +++++--- openpype/settings/entities/item_entities.py | 75 ++++++++++------ openpype/settings/entities/list_entity.py | 32 +++++-- 6 files changed, 242 insertions(+), 114 deletions(-) diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py index 19f326aea7..88d2dc8296 100644 --- a/openpype/settings/entities/dict_conditional.py +++ b/openpype/settings/entities/dict_conditional.py @@ -518,12 +518,18 @@ class DictConditionalEntity(ItemEntity): output.update(self._current_metadata) return output - def _prepare_value(self, value): + def _prepare_value(self, value, log_invalid_types): if value is NOT_SET or self.enum_key not in value: return NOT_SET, NOT_SET enum_value = value.get(self.enum_key) if enum_value not in self.non_gui_children: + if log_invalid_types: + self.log.warning( + "{} Unknown enum key in default values: {}".format( + self.path, enum_value + ) + ) return NOT_SET, NOT_SET # Create copy of value before poping values @@ -551,22 +557,25 @@ class DictConditionalEntity(ItemEntity): return value, metadata - def update_default_value(self, value): + def update_default_value(self, value, log_invalid_types=True): """Update default values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "default") + self._default_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "default", log_invalid_types + ) self.has_default_value = value is not NOT_SET # TODO add value validation - value, metadata = self._prepare_value(value) + value, metadata = self._prepare_value(value, log_invalid_types) self._default_metadata = metadata if value is NOT_SET: - self.enum_entity.update_default_value(value) + self.enum_entity.update_default_value(value, log_invalid_types) for children_by_key in self.non_gui_children.values(): for child_obj in children_by_key.values(): - child_obj.update_default_value(value) + child_obj.update_default_value(value, log_invalid_types) return value_keys = set(value.keys()) @@ -574,7 +583,7 @@ class DictConditionalEntity(ItemEntity): expected_keys = set(self.non_gui_children[enum_value].keys()) expected_keys.add(self.enum_key) unknown_keys = value_keys - expected_keys - if unknown_keys: + if unknown_keys and log_invalid_types: self.log.warning( "{} Unknown keys in default values: {}".format( self.path, @@ -582,28 +591,37 @@ class DictConditionalEntity(ItemEntity): ) ) - self.enum_entity.update_default_value(enum_value) - for children_by_key in self.non_gui_children.values(): + self.enum_entity.update_default_value(enum_value, log_invalid_types) + + for enum_key, children_by_key in self.non_gui_children.items(): + _log_invalid_types = log_invalid_types + if _log_invalid_types: + _log_invalid_types = enum_key == enum_value + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): child_value = value_copy.get(key, NOT_SET) - child_obj.update_default_value(child_value) + child_obj.update_default_value(child_value, _log_invalid_types) - def update_studio_value(self, value): + def update_studio_value(self, value, log_invalid_types=True): """Update studio override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "studio override") - value, metadata = self._prepare_value(value) + + self._studio_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "studio override", log_invalid_types + ) + value, metadata = self._prepare_value(value, log_invalid_types) self._studio_override_metadata = metadata self.had_studio_override = metadata is not NOT_SET if value is NOT_SET: - self.enum_entity.update_studio_value(value) + self.enum_entity.update_studio_value(value, log_invalid_types) for children_by_key in self.non_gui_children.values(): for child_obj in children_by_key.values(): - child_obj.update_studio_value(value) + child_obj.update_studio_value(value, log_invalid_types) return value_keys = set(value.keys()) @@ -611,7 +629,7 @@ class DictConditionalEntity(ItemEntity): expected_keys = set(self.non_gui_children[enum_value]) expected_keys.add(self.enum_key) unknown_keys = value_keys - expected_keys - if unknown_keys: + if unknown_keys and log_invalid_types: self.log.warning( "{} Unknown keys in studio overrides: {}".format( self.path, @@ -619,28 +637,36 @@ class DictConditionalEntity(ItemEntity): ) ) - self.enum_entity.update_studio_value(enum_value) - for children_by_key in self.non_gui_children.values(): + self.enum_entity.update_studio_value(enum_value, log_invalid_types) + for enum_key, children_by_key in self.non_gui_children.items(): + _log_invalid_types = log_invalid_types + if _log_invalid_types: + _log_invalid_types = enum_key == enum_value + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): child_value = value_copy.get(key, NOT_SET) - child_obj.update_studio_value(child_value) + child_obj.update_studio_value(child_value, _log_invalid_types) - def update_project_value(self, value): + def update_project_value(self, value, log_invalid_types=True): """Update project override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "project override") - value, metadata = self._prepare_value(value) + + self._project_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "project override", log_invalid_types + ) + value, metadata = self._prepare_value(value, log_invalid_types) self._project_override_metadata = metadata self.had_project_override = metadata is not NOT_SET if value is NOT_SET: - self.enum_entity.update_project_value(value) + self.enum_entity.update_project_value(value, log_invalid_types) for children_by_key in self.non_gui_children.values(): for child_obj in children_by_key.values(): - child_obj.update_project_value(value) + child_obj.update_project_value(value, log_invalid_types) return value_keys = set(value.keys()) @@ -648,7 +674,7 @@ class DictConditionalEntity(ItemEntity): expected_keys = set(self.non_gui_children[enum_value]) expected_keys.add(self.enum_key) unknown_keys = value_keys - expected_keys - if unknown_keys: + if unknown_keys and log_invalid_types: self.log.warning( "{} Unknown keys in project overrides: {}".format( self.path, @@ -656,12 +682,16 @@ class DictConditionalEntity(ItemEntity): ) ) - self.enum_entity.update_project_value(enum_value) - for children_by_key in self.non_gui_children.values(): + self.enum_entity.update_project_value(enum_value, log_invalid_types) + for enum_key, children_by_key in self.non_gui_children.items(): + _log_invalid_types = log_invalid_types + if _log_invalid_types: + _log_invalid_types = enum_key == enum_value + value_copy = copy.deepcopy(value) for key, child_obj in children_by_key.items(): child_value = value_copy.get(key, NOT_SET) - child_obj.update_project_value(child_value) + child_obj.update_project_value(child_value, _log_invalid_types) def _discard_changes(self, on_change_trigger): self._ignore_child_changes = True diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py index 060f8d522e..0209681e95 100644 --- a/openpype/settings/entities/dict_immutable_keys_entity.py +++ b/openpype/settings/entities/dict_immutable_keys_entity.py @@ -414,12 +414,16 @@ class DictImmutableKeysEntity(ItemEntity): return value, metadata - def update_default_value(self, value): + def update_default_value(self, value, log_invalid_types=True): """Update default values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "default") + + self._default_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "default", log_invalid_types + ) self.has_default_value = value is not NOT_SET # TODO add value validation value, metadata = self._prepare_value(value) @@ -427,13 +431,13 @@ class DictImmutableKeysEntity(ItemEntity): if value is NOT_SET: for child_obj in self.non_gui_children.values(): - child_obj.update_default_value(value) + child_obj.update_default_value(value, log_invalid_types) return value_keys = set(value.keys()) expected_keys = set(self.non_gui_children) unknown_keys = value_keys - expected_keys - if unknown_keys: + if unknown_keys and log_invalid_types: self.log.warning( "{} Unknown keys in default values: {}".format( self.path, @@ -443,27 +447,31 @@ class DictImmutableKeysEntity(ItemEntity): for key, child_obj in self.non_gui_children.items(): child_value = value.get(key, NOT_SET) - child_obj.update_default_value(child_value) + child_obj.update_default_value(child_value, log_invalid_types) - def update_studio_value(self, value): + def update_studio_value(self, value, log_invalid_types=True): """Update studio override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "studio override") + + self._studio_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "studio override", log_invalid_types + ) value, metadata = self._prepare_value(value) self._studio_override_metadata = metadata self.had_studio_override = metadata is not NOT_SET if value is NOT_SET: for child_obj in self.non_gui_children.values(): - child_obj.update_studio_value(value) + child_obj.update_studio_value(value, log_invalid_types) return value_keys = set(value.keys()) expected_keys = set(self.non_gui_children) unknown_keys = value_keys - expected_keys - if unknown_keys: + if unknown_keys and log_invalid_types: self.log.warning( "{} Unknown keys in studio overrides: {}".format( self.path, @@ -472,27 +480,31 @@ class DictImmutableKeysEntity(ItemEntity): ) for key, child_obj in self.non_gui_children.items(): child_value = value.get(key, NOT_SET) - child_obj.update_studio_value(child_value) + child_obj.update_studio_value(child_value, log_invalid_types) - def update_project_value(self, value): + def update_project_value(self, value, log_invalid_types=True): """Update project override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "project override") + + self._project_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "project override", log_invalid_types + ) value, metadata = self._prepare_value(value) self._project_override_metadata = metadata self.had_project_override = metadata is not NOT_SET if value is NOT_SET: for child_obj in self.non_gui_children.values(): - child_obj.update_project_value(value) + child_obj.update_project_value(value, log_invalid_types) return value_keys = set(value.keys()) expected_keys = set(self.non_gui_children) unknown_keys = value_keys - expected_keys - if unknown_keys: + if unknown_keys and log_invalid_types: self.log.warning( "{} Unknown keys in project overrides: {}".format( self.path, @@ -502,7 +514,7 @@ class DictImmutableKeysEntity(ItemEntity): for key, child_obj in self.non_gui_children.items(): child_value = value.get(key, NOT_SET) - child_obj.update_project_value(child_value) + child_obj.update_project_value(child_value, log_invalid_types) def _discard_changes(self, on_change_trigger): self._ignore_child_changes = True @@ -694,37 +706,48 @@ class RootsDictEntity(DictImmutableKeysEntity): self._metadata_are_modified = False self._current_metadata = {} - def update_default_value(self, value): + def update_default_value(self, value, log_invalid_types=True): """Update default values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "default") + + self._default_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "default", log_invalid_types + ) value, _ = self._prepare_value(value) self._default_value = value self._default_metadata = {} self.has_default_value = value is not NOT_SET - def update_studio_value(self, value): + def update_studio_value(self, value, log_invalid_types=True): """Update studio override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "studio override") + + self._studio_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "studio override", log_invalid_types + ) value, _ = self._prepare_value(value) self._studio_value = value self._studio_override_metadata = {} self.had_studio_override = value is not NOT_SET - def update_project_value(self, value): + def update_project_value(self, value, log_invalid_types=True): """Update project override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "project override") + self._project_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "project override", log_invalid_types + ) value, _metadata = self._prepare_value(value) self._project_value = value @@ -886,37 +909,48 @@ class SyncServerSites(DictImmutableKeysEntity): self._metadata_are_modified = False self._current_metadata = {} - def update_default_value(self, value): + def update_default_value(self, value, log_invalid_types=True): """Update default values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "default") + + self._default_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "default", log_invalid_types + ) value, _ = self._prepare_value(value) self._default_value = value self._default_metadata = {} self.has_default_value = value is not NOT_SET - def update_studio_value(self, value): + def update_studio_value(self, value, log_invalid_types=True): """Update studio override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "studio override") + + self._studio_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "studio override", log_invalid_types + ) value, _ = self._prepare_value(value) self._studio_value = value self._studio_override_metadata = {} self.had_studio_override = value is not NOT_SET - def update_project_value(self, value): + def update_project_value(self, value, log_invalid_types=True): """Update project override values. Not an api method, should be called by parent. """ - value = self._check_update_value(value, "project override") + self._project_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "project override", log_invalid_types + ) value, _metadata = self._prepare_value(value) self._project_value = value diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index 6b9c0bc7ed..a0c93b97a7 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -393,11 +393,15 @@ class DictMutableKeysEntity(EndpointEntity): value = self.value_on_not_set using_values_from_state = False + log_invalid_types = True if state is OverrideState.PROJECT: + log_invalid_types = self._project_log_invalid_types using_values_from_state = using_project_overrides elif state is OverrideState.STUDIO: + log_invalid_types = self._studio_log_invalid_types using_values_from_state = using_studio_overrides elif state is OverrideState.DEFAULTS: + log_invalid_types = self._default_log_invalid_types using_values_from_state = using_default_values new_value = copy.deepcopy(value) @@ -437,11 +441,11 @@ class DictMutableKeysEntity(EndpointEntity): if not label: label = metadata_labels.get(new_key) - child_entity.update_default_value(_value) + child_entity.update_default_value(_value, log_invalid_types) if using_project_overrides: - child_entity.update_project_value(_value) + child_entity.update_project_value(_value, log_invalid_types) elif using_studio_overrides: - child_entity.update_studio_value(_value) + child_entity.update_studio_value(_value, log_invalid_types) if label: children_label_by_id[child_entity.id] = label @@ -598,8 +602,11 @@ class DictMutableKeysEntity(EndpointEntity): metadata[key] = value.pop(key) return value, metadata - def update_default_value(self, value): - value = self._check_update_value(value, "default") + def update_default_value(self, value, log_invalid_types=True): + self._default_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "default", log_invalid_types + ) has_default_value = value is not NOT_SET if has_default_value: for required_key in self.required_keys: @@ -611,15 +618,21 @@ class DictMutableKeysEntity(EndpointEntity): self._default_value = value self._default_metadata = metadata - def update_studio_value(self, value): - value = self._check_update_value(value, "studio override") + def update_studio_value(self, value, log_invalid_types=True): + self._studio_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "studio override", log_invalid_types + ) value, metadata = self._prepare_value(value) self._studio_override_value = value self._studio_override_metadata = metadata self.had_studio_override = value is not NOT_SET - def update_project_value(self, value): - value = self._check_update_value(value, "project override") + def update_project_value(self, value, log_invalid_types=True): + self._project_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "project override", log_invalid_types + ) value, metadata = self._prepare_value(value) self._project_override_value = value self._project_override_metadata = metadata @@ -686,9 +699,12 @@ class DictMutableKeysEntity(EndpointEntity): if not self._can_remove_from_project_override: return + log_invalid_types = True if self._has_studio_override: + log_invalid_types = self._studio_log_invalid_types value = self._studio_override_value elif self.has_default_value: + log_invalid_types = self._default_log_invalid_types value = self._default_value else: value = self.value_on_not_set @@ -709,9 +725,9 @@ class DictMutableKeysEntity(EndpointEntity): for _key, _value in new_value.items(): new_key = self._convert_to_regex_valid_key(_key) child_entity = self._add_key(new_key) - child_entity.update_default_value(_value) + child_entity.update_default_value(_value, log_invalid_types) if self._has_studio_override: - child_entity.update_studio_value(_value) + child_entity.update_studio_value(_value, log_invalid_types) label = metadata_labels.get(_key) if label: diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py index 7512d7bfcc..3dcd238672 100644 --- a/openpype/settings/entities/input_entities.py +++ b/openpype/settings/entities/input_entities.py @@ -90,18 +90,27 @@ class EndpointEntity(ItemEntity): def require_restart(self): return self.has_unsaved_changes - def update_default_value(self, value): - value = self._check_update_value(value, "default") + def update_default_value(self, value, log_invalid_types=True): + self._default_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "default", log_invalid_types + ) self._default_value = value self.has_default_value = value is not NOT_SET - def update_studio_value(self, value): - value = self._check_update_value(value, "studio override") + def update_studio_value(self, value, log_invalid_types=True): + self._studio_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "studio override", log_invalid_types + ) self._studio_override_value = value self.had_studio_override = bool(value is not NOT_SET) - def update_project_value(self, value): - value = self._check_update_value(value, "project override") + def update_project_value(self, value, log_invalid_types=True): + self._project_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "project override", log_invalid_types + ) self._project_override_value = value self.had_project_override = bool(value is not NOT_SET) @@ -590,22 +599,26 @@ class RawJsonEntity(InputEntity): metadata[key] = value.pop(key) return value, metadata - def update_default_value(self, value): - value = self._check_update_value(value, "default") + def update_default_value(self, value, log_invalid_types=True): + value = self._check_update_value(value, "default", log_invalid_types) self.has_default_value = value is not NOT_SET value, metadata = self._prepare_value(value) self._default_value = value self.default_metadata = metadata - def update_studio_value(self, value): - value = self._check_update_value(value, "studio override") + def update_studio_value(self, value, log_invalid_types=True): + value = self._check_update_value( + value, "studio override", log_invalid_types + ) self.had_studio_override = value is not NOT_SET value, metadata = self._prepare_value(value) self._studio_override_value = value self.studio_override_metadata = metadata - def update_project_value(self, value): - value = self._check_update_value(value, "project override") + def update_project_value(self, value, log_invalid_types=True): + value = self._check_update_value( + value, "project override", log_invalid_types + ) self.had_project_override = value is not NOT_SET value, metadata = self._prepare_value(value) self._project_override_value = value diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index 9c6f428b97..4cba0b42d9 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -173,14 +173,17 @@ class PathEntity(ItemEntity): self._ignore_missing_defaults = ignore_missing_defaults self.child_obj.set_override_state(state, ignore_missing_defaults) - def update_default_value(self, value): - self.child_obj.update_default_value(value) + def update_default_value(self, value, log_invalid_types=True): + self._default_log_invalid_types = log_invalid_types + self.child_obj.update_default_value(value, log_invalid_types) - def update_project_value(self, value): - self.child_obj.update_project_value(value) + def update_project_value(self, value, log_invalid_types=True): + self._studio_log_invalid_types = log_invalid_types + self.child_obj.update_project_value(value, log_invalid_types) - def update_studio_value(self, value): - self.child_obj.update_studio_value(value) + def update_studio_value(self, value, log_invalid_types=True): + self._project_log_invalid_types = log_invalid_types + self.child_obj.update_studio_value(value, log_invalid_types) def _discard_changes(self, *args, **kwargs): self.child_obj.discard_changes(*args, **kwargs) @@ -472,9 +475,9 @@ class ListStrictEntity(ItemEntity): self._has_project_override = False - def _check_update_value(self, value, value_type): + def _check_update_value(self, value, value_type, log_invalid_types=True): value = super(ListStrictEntity, self)._check_update_value( - value, value_type + value, value_type, log_invalid_types ) if value is NOT_SET: return value @@ -484,15 +487,16 @@ class ListStrictEntity(ItemEntity): if value_len == child_len: return value - self.log.warning( - ( - "{} Amount of strict list items in {} values is" - " not same as expected. Expected {} items. Got {} items. {}" - ).format( - self.path, value_type, - child_len, value_len, str(value) + if log_invalid_types: + self.log.warning( + ( + "{} Amount of strict list items in {} values is" + " not same as expected. Expected {} items. Got {} items. {}" + ).format( + self.path, value_type, + child_len, value_len, str(value) + ) ) - ) if value_len < child_len: # Fill missing values with NOT_SET @@ -504,36 +508,51 @@ class ListStrictEntity(ItemEntity): value.pop(child_len) return value - def update_default_value(self, value): - value = self._check_update_value(value, "default") + def update_default_value(self, value, log_invalid_types=True): + self._default_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "default", log_invalid_types + ) self.has_default_value = value is not NOT_SET if value is NOT_SET: for child_obj in self.children: - child_obj.update_default_value(value) + child_obj.update_default_value(value, log_invalid_types) else: for idx, item_value in enumerate(value): - self.children[idx].update_default_value(item_value) + self.children[idx].update_default_value( + item_value, log_invalid_types + ) - def update_studio_value(self, value): - value = self._check_update_value(value, "studio override") + def update_studio_value(self, value, log_invalid_types=True): + self._studio_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "studio override", log_invalid_types + ) if value is NOT_SET: for child_obj in self.children: - child_obj.update_studio_value(value) + child_obj.update_studio_value(value, log_invalid_types) else: for idx, item_value in enumerate(value): - self.children[idx].update_studio_value(item_value) + self.children[idx].update_studio_value( + item_value, log_invalid_types + ) - def update_project_value(self, value): - value = self._check_update_value(value, "project override") + def update_project_value(self, value, log_invalid_types=True): + self._project_log_invalid_types = log_invalid_types + value = self._check_update_value( + value, "project override", log_invalid_types + ) if value is NOT_SET: for child_obj in self.children: - child_obj.update_project_value(value) + child_obj.update_project_value(value, log_invalid_types) else: for idx, item_value in enumerate(value): - self.children[idx].update_project_value(item_value) + self.children[idx].update_project_value( + item_value, log_invalid_types + ) def reset_callbacks(self): super(ListStrictEntity, self).reset_callbacks() diff --git a/openpype/settings/entities/list_entity.py b/openpype/settings/entities/list_entity.py index 0268c208bb..5d6a64b3ea 100644 --- a/openpype/settings/entities/list_entity.py +++ b/openpype/settings/entities/list_entity.py @@ -325,16 +325,24 @@ class ListEntity(EndpointEntity): for item in value: child_obj = self._add_new_item() - child_obj.update_default_value(item) + child_obj.update_default_value( + item, self._default_log_invalid_types + ) if self._override_state is OverrideState.PROJECT: if self.had_project_override: - child_obj.update_project_value(item) + child_obj.update_project_value( + item, self._project_log_invalid_types + ) elif self.had_studio_override: - child_obj.update_studio_value(item) + child_obj.update_studio_value( + item, self._studio_log_invalid_types + ) elif self._override_state is OverrideState.STUDIO: if self.had_studio_override: - child_obj.update_studio_value(item) + child_obj.update_studio_value( + item, self._studio_log_invalid_types + ) for child_obj in self.children: child_obj.set_override_state( @@ -466,16 +474,24 @@ class ListEntity(EndpointEntity): for item in value: child_obj = self._add_new_item() - child_obj.update_default_value(item) + child_obj.update_default_value( + item, self._default_log_invalid_types + ) if self._override_state is OverrideState.PROJECT: if self.had_project_override: - child_obj.update_project_value(item) + child_obj.update_project_value( + item, self._project_log_invalid_types + ) elif self.had_studio_override: - child_obj.update_studio_value(item) + child_obj.update_studio_value( + item, self._studio_log_invalid_types + ) elif self._override_state is OverrideState.STUDIO: if self.had_studio_override: - child_obj.update_studio_value(item) + child_obj.update_studio_value( + item, self._studio_log_invalid_types + ) child_obj.set_override_state( self._override_state, self._ignore_missing_defaults From 5acdf2ab4f8b6cf81510e2893d9b7b18befe852f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Mar 2022 11:22:49 +0200 Subject: [PATCH 331/854] fix line length --- openpype/settings/entities/item_entities.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py index 4cba0b42d9..3b756e4ede 100644 --- a/openpype/settings/entities/item_entities.py +++ b/openpype/settings/entities/item_entities.py @@ -490,8 +490,8 @@ class ListStrictEntity(ItemEntity): if log_invalid_types: self.log.warning( ( - "{} Amount of strict list items in {} values is" - " not same as expected. Expected {} items. Got {} items. {}" + "{} Amount of strict list items in {} values is not same" + " as expected. Expected {} items. Got {} items. {}" ).format( self.path, value_type, child_len, value_len, str(value) From 9b097d40e2d3a9830d9fa49bd587d5b363ae8be2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 28 Mar 2022 12:10:36 +0200 Subject: [PATCH 332/854] create a sequence output when converting sequence --- openpype/lib/transcoding.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/lib/transcoding.py b/openpype/lib/transcoding.py index 6bab6a8160..8e79aba0ae 100644 --- a/openpype/lib/transcoding.py +++ b/openpype/lib/transcoding.py @@ -478,8 +478,14 @@ def convert_for_ffmpeg( oiio_cmd.extend(["--eraseattrib", attr_name]) # Add last argument - path to output - base_file_name = os.path.basename(first_input_path) - output_path = os.path.join(output_dir, base_file_name) + if is_sequence: + ext = os.path.splitext(first_input_path)[1] + base_filename = "tmp.%{:0>2}d{}".format( + len(str(input_frame_end)), ext + ) + else: + base_filename = os.path.basename(first_input_path) + output_path = os.path.join(output_dir, base_filename) oiio_cmd.extend([ "-o", output_path ]) From 75f3762e48ab8dd472e8c1c5ebeabe2aac476f02 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 28 Mar 2022 12:34:39 +0200 Subject: [PATCH 333/854] Added current commit of acre That commit was fixing missing license file --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index ee7b839b8d..ed2b0dd3c2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,7 +11,7 @@ develop = false type = "git" url = "https://github.com/pypeclub/acre.git" reference = "master" -resolved_reference = "55a7c331e6dc5f81639af50ca4a8cc9d73e9273d" +resolved_reference = "126f7a188cfe36718f707f42ebbc597e86aa86c3" [[package]] name = "aiohttp" From 08f80ecf15911f1e96808fd8c6032b55d4f596e7 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Mar 2022 13:29:16 +0200 Subject: [PATCH 334/854] flame: make sure only one clip in xml --- openpype/hosts/flame/api/plugin.py | 59 ++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 4c9d3c5383..3a322e5208 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -1,24 +1,22 @@ +import itertools import os import re import shutil import sys -from xml.etree import ElementTree as ET -import six -import qargparse -from Qt import QtWidgets, QtCore -import openpype.api as openpype -from openpype.pipeline import ( - LegacyCreator, - LoaderPlugin, -) -from openpype import style -from . import ( - lib as flib, - pipeline as fpipeline, - constants -) - +import xml.etree.cElementTree as cET from copy import deepcopy +from xml.etree import ElementTree as ET + +import openpype.api as openpype +import qargparse +import six +from openpype import style +from openpype.pipeline import LegacyCreator, LoaderPlugin +from Qt import QtCore, QtWidgets + +from . import constants +from . import lib as flib +from . import pipeline as fpipeline log = openpype.Logger.get_logger(__name__) @@ -749,10 +747,39 @@ class OpenClipSolver: # execute creation of clip xml template data try: openpype.run_subprocess(cmd_args) + self._make_single_clip_media_info() except TypeError: self.log.error("Error creating self.tmp_file") six.reraise(*sys.exc_info()) + def _make_single_clip_media_info(self): + with open(self.tmp_file) as f: + lines = f.readlines() + _added_root = itertools.chain( + "", deepcopy(lines)[1:], "") + new_root = ET.fromstringlist(_added_root) + + # find the clip which is matching to my input name + xml_clips = new_root.findall("clip") + matching_clip = None + for xml_clip in xml_clips: + if xml_clip.find("name").text == self.feed_basename: + matching_clip = xml_clip + + if not matching_clip: + # return warning there is missing clip + raise ET.ParseError( + "Missing clip in `{}`. Available clips {}".format( + self.feed_basename, [ + xml_clip.find("name").text + for xml_clip in xml_clips + ] + )) + # save it as new file + tree = cET.ElementTree(matching_clip) + tree.write(self.tmp_file, xml_declaration=True, + method='xml', encoding='UTF-8') + def _clear_tmp_file(self): if os.path.isfile(self.tmp_file): os.remove(self.tmp_file) From 34a65cb646e5d267899f3f3df5eed4de72ac2074 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Mar 2022 13:29:40 +0200 Subject: [PATCH 335/854] flame: ignore clip file with zero lines --- openpype/hosts/flame/api/plugin.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 3a322e5208..949e8ad406 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -708,19 +708,32 @@ class OpenClipSolver: self.feed_dir = os.path.dirname(feed_path) self.feed_ext = os.path.splitext(self.feed_basename)[1][1:].lower() - if not os.path.isfile(openclip_file_path): + if not self._is_valid_tmp_file(openclip_file_path): # openclip does not exist yet and will be created self.tmp_file = self.out_file = openclip_file_path self.create_new_clip = True else: + # update already created clip # output a temp file self.out_file = openclip_file_path self.tmp_file = os.path.join(self.feed_dir, self.tmp_name) + + # remove previously generated temp files + # it will be regenerated self._clear_tmp_file() self.log.info("Temp File: {}".format(self.tmp_file)) + def _is_valid_tmp_file(self, file): + # check if file exists + if os.path.isfile(file): + with open(self.tmp_file) as f: + lines = f.readlines() + if len(lines) < 1: + self._clear_tmp_file() + return False + def make(self): self._generate_media_info_file() From 1c6ab37f351e87a0a4a01a93eb14de47668333f6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Mar 2022 20:02:58 +0200 Subject: [PATCH 336/854] flame: improving tmp file validation --- openpype/hosts/flame/api/plugin.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 949e8ad406..ab60bbad11 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -730,9 +730,12 @@ class OpenClipSolver: if os.path.isfile(file): with open(self.tmp_file) as f: lines = f.readlines() - if len(lines) < 1: - self._clear_tmp_file() - return False + if len(lines) > 2: + return True + + # file is probably corrupted + self._clear_tmp_file() + return False def make(self): self._generate_media_info_file() From 0abc8ae61367a3ee03a896704557b324ffc1e1bd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Mar 2022 20:18:59 +0200 Subject: [PATCH 337/854] flame: rework xml write file --- openpype/hosts/flame/api/plugin.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index ab60bbad11..0eba06a86d 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -791,10 +791,8 @@ class OpenClipSolver: for xml_clip in xml_clips ] )) - # save it as new file - tree = cET.ElementTree(matching_clip) - tree.write(self.tmp_file, xml_declaration=True, - method='xml', encoding='UTF-8') + + self._write_result_xml_to_file(self.tmp_file, matching_clip) def _clear_tmp_file(self): if os.path.isfile(self.tmp_file): @@ -901,7 +899,7 @@ class OpenClipSolver: self.log.info("Adding feed version: {}".format( self.feed_version_name)) - self._write_result_xml_to_file(xml_data) + self._write_result_xml_to_file(self.out_file, xml_data) self.log.info("openClip Updated: {}".format(self.out_file)) @@ -940,9 +938,11 @@ class OpenClipSolver: self._clear_handler(xml_root) return ET.tostring(xml_root).decode('utf-8') - def _write_result_xml_to_file(self, xml_data): - with open(self.out_file, "w") as f: - f.write(xml_data) + def _write_result_xml_to_file(self, file, xml_data): + # save it as new file + tree = cET.ElementTree(xml_data) + tree.write(file, xml_declaration=True, + method='xml', encoding='UTF-8') def _create_openclip_backup_file(self, file): bck_file = "{}.bak".format(file) From ae36d089690f9acb078cc185e5315667523669dc Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Mar 2022 20:54:12 +0200 Subject: [PATCH 338/854] flame: little fixes of loading --- openpype/hosts/flame/api/plugin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 0eba06a86d..3673dc6671 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -728,13 +728,14 @@ class OpenClipSolver: def _is_valid_tmp_file(self, file): # check if file exists if os.path.isfile(file): - with open(self.tmp_file) as f: + # test also if file is not empty + with open(file) as f: lines = f.readlines() if len(lines) > 2: return True # file is probably corrupted - self._clear_tmp_file() + os.remove(file) return False def make(self): @@ -779,7 +780,7 @@ class OpenClipSolver: xml_clips = new_root.findall("clip") matching_clip = None for xml_clip in xml_clips: - if xml_clip.find("name").text == self.feed_basename: + if xml_clip.find("name").text in self.feed_basename: matching_clip = xml_clip if not matching_clip: From 2bf75d270a3fbfa0054d750159439a52e1f0369f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Mar 2022 21:05:35 +0200 Subject: [PATCH 339/854] flame: fix loaded name to `output` make condition for fix if output is not in context data --- openpype/hosts/flame/plugins/load/load_clip.py | 2 +- openpype/hosts/flame/plugins/load/load_clip_batch.py | 7 ++++++- openpype/settings/defaults/project_settings/flame.json | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index 8980f72cb8..b27600db1f 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -22,7 +22,7 @@ class LoadClip(opfapi.ClipLoader): # settings reel_group_name = "OpenPype_Reels" reel_name = "Loaded" - clip_name_template = "{asset}_{subset}_{representation}" + clip_name_template = "{asset}_{subset}_{output}" def load(self, context, name, namespace, options): diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index bf0bbb5168..1f87f94cc6 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -21,7 +21,7 @@ class LoadClipBatch(opfapi.ClipLoader): # settings reel_name = "OP_LoadedReel" - clip_name_template = "{asset}_{subset}_{representation}" + clip_name_template = "{asset}_{subset}_{output}" def load(self, context, name, namespace, options): @@ -34,6 +34,11 @@ class LoadClipBatch(opfapi.ClipLoader): version_data = version.get("data", {}) version_name = version.get("name", None) colorspace = version_data.get("colorspace", None) + + # in case output is not in context replace key to representation + if not context["representation"]["context"].get("output"): + self.clip_name_template.replace("output", "representation") + clip_name = self.clip_name_template.format( **context["representation"]["context"]) diff --git a/openpype/settings/defaults/project_settings/flame.json b/openpype/settings/defaults/project_settings/flame.json index afd0834c9d..ef7a2a4467 100644 --- a/openpype/settings/defaults/project_settings/flame.json +++ b/openpype/settings/defaults/project_settings/flame.json @@ -91,7 +91,7 @@ ], "reel_group_name": "OpenPype_Reels", "reel_name": "Loaded", - "clip_name_template": "{asset}_{subset}_{representation}" + "clip_name_template": "{asset}_{subset}_{output}" }, "LoadClipBatch": { "enabled": true, @@ -113,7 +113,7 @@ "mp4" ], "reel_name": "OP_LoadedReel", - "clip_name_template": "{asset}_{subset}_{representation}" + "clip_name_template": "{asset}_{subset}_{output}" } } } \ No newline at end of file From eda39b5de29e9bce283a3326427db8508d2cfb05 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 28 Mar 2022 21:12:56 +0200 Subject: [PATCH 340/854] flame: fix write to xml file input args --- openpype/hosts/flame/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 3673dc6671..750609f7d6 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -829,7 +829,7 @@ class OpenClipSolver: xml_data = self._fix_xml_data(tmp_xml) self.log.info("Adding feed version: {}".format(self.feed_basename)) - self._write_result_xml_to_file(xml_data) + self._write_result_xml_to_file(self.out_file, xml_data) self.log.info("openClip Updated: {}".format(self.tmp_file)) From 44257be4863cc0eb0522ef14aa431bb10344c14c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 29 Mar 2022 08:43:38 +0200 Subject: [PATCH 341/854] flame: fix utf8 error `'unicode' object has no attribute 'getiterator'` --- openpype/hosts/flame/api/plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index 750609f7d6..d5790d2f10 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -783,7 +783,7 @@ class OpenClipSolver: if xml_clip.find("name").text in self.feed_basename: matching_clip = xml_clip - if not matching_clip: + if matching_clip is not None: # return warning there is missing clip raise ET.ParseError( "Missing clip in `{}`. Available clips {}".format( @@ -937,7 +937,7 @@ class OpenClipSolver: def _fix_xml_data(self, xml_data): xml_root = xml_data.getroot() self._clear_handler(xml_root) - return ET.tostring(xml_root).decode('utf-8') + return xml_root def _write_result_xml_to_file(self, file, xml_data): # save it as new file From 69f5ace08485f0aea46a586602629a21416b779c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 29 Mar 2022 08:46:50 +0200 Subject: [PATCH 342/854] flame: fix condition direction --- openpype/hosts/flame/api/plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/plugin.py b/openpype/hosts/flame/api/plugin.py index d5790d2f10..464f5ce89b 100644 --- a/openpype/hosts/flame/api/plugin.py +++ b/openpype/hosts/flame/api/plugin.py @@ -783,7 +783,7 @@ class OpenClipSolver: if xml_clip.find("name").text in self.feed_basename: matching_clip = xml_clip - if matching_clip is not None: + if matching_clip is None: # return warning there is missing clip raise ET.ParseError( "Missing clip in `{}`. Available clips {}".format( From 719b184d8085e6cea54cdae06c12fcf19ac5d5e4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 10:12:37 +0200 Subject: [PATCH 343/854] Added default for review_upload_limit for Slack --- openpype/settings/defaults/project_settings/slack.json | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/settings/defaults/project_settings/slack.json b/openpype/settings/defaults/project_settings/slack.json index d77b8c2208..c156fed08e 100644 --- a/openpype/settings/defaults/project_settings/slack.json +++ b/openpype/settings/defaults/project_settings/slack.json @@ -11,6 +11,7 @@ "task_types": [], "tasks": [], "subsets": [], + "review_upload_limit": 50.0, "channel_messages": [] } ] From 3459cec3a9adf5537d11c5963e7b33ec9b5d5c2b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 29 Mar 2022 10:29:17 +0200 Subject: [PATCH 344/854] flame: task workdir for .clip when integrating batch --- openpype/hosts/flame/plugins/load/load_clip_batch.py | 2 +- .../flame/plugins/publish/integrate_batch_group.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index 1f87f94cc6..252c92516d 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -48,7 +48,7 @@ class LoadClipBatch(opfapi.ClipLoader): colorspace = colorspace # create workfile path - workfile_dir = os.environ["AVALON_WORKDIR"] + workfile_dir = options.get("workdir") or os.environ["AVALON_WORKDIR"] openclip_dir = os.path.join( workfile_dir, clip_name ) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index eaab429111..7c61ed62b5 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -105,7 +105,9 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): # load to flame by representation context try: op_pipeline.load.load_with_repre_context( - Loader, repre_context) + Loader, repre_context, **{ + "data": {"workdir": self.task_workdir} + }) except op_pipeline.load.IncompatibleLoaderError as msg: self.log.error( "Check allowed representations for Loader `{}` " @@ -192,12 +194,14 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): anatomy_data = self._get_anamoty_data_with_current_task( instance, task_data) - task_workfile_path = self._get_shot_task_dir_path(instance, task_data) - self.log.debug("__ task_workfile_path: {}".format(task_workfile_path)) + self.task_workdir = self._get_shot_task_dir_path( + instance, task_data) + self.log.debug("__ task_workdir: {}".format( + self.task_workdir)) # TODO: this might be done with template in settings render_dir_path = os.path.join( - task_workfile_path, "render", "flame") + self.task_workdir, "render", "flame") if not os.path.exists(render_dir_path): os.makedirs(render_dir_path, mode=0o777) From 000461243200f36d654a639c12d35314007878da Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 11:25:57 +0200 Subject: [PATCH 345/854] added settings of template name into hero integrator --- .../defaults/project_settings/global.json | 30 +++--- .../schemas/schema_global_publish.json | 100 +++++++++++++----- 2 files changed, 90 insertions(+), 40 deletions(-) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 30a71b044a..24334b0045 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -33,20 +33,6 @@ "enabled": false, "profiles": [] }, - "IntegrateHeroVersion": { - "enabled": true, - "optional": true, - "families": [ - "model", - "rig", - "look", - "pointcache", - "animation", - "setdress", - "layout", - "mayaScene" - ] - }, "ExtractJpegEXR": { "enabled": true, "ffmpeg_args": { @@ -204,6 +190,22 @@ } ] }, + "IntegrateHeroVersion": { + "enabled": true, + "optional": true, + "active": true, + "families": [ + "model", + "rig", + "look", + "pointcache", + "animation", + "setdress", + "layout", + "mayaScene" + ], + "template_name_profiles": [] + }, "CleanUp": { "paterns": [], "remove_temp_renders": false diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json index 12043d4205..061874e31c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_publish.json @@ -122,32 +122,6 @@ } ] }, - { - "type": "dict", - "collapsible": true, - "checkbox_key": "enabled", - "key": "IntegrateHeroVersion", - "label": "IntegrateHeroVersion", - "is_group": true, - "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, - { - "type": "boolean", - "key": "optional", - "label": "Optional" - }, - { - "key": "families", - "label": "Families", - "type": "list", - "object_type": "text" - } - ] - }, { "type": "dict", "collapsible": true, @@ -652,6 +626,80 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "IntegrateHeroVersion", + "label": "IntegrateHeroVersion", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "type": "list", + "key": "template_name_profiles", + "label": "Template name profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "template_name", + "label": "Template name", + "tooltip": "Name of template from Anatomy templates" + } + ] + } + } + ] + }, { "type": "dict", "collapsible": true, From 160247ab61d6c3e1a1e6eeab042a58d9d1b0933b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 11:26:29 +0200 Subject: [PATCH 346/854] remove unused template color --- .../schemas/template_color.json | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 openpype/settings/entities/schemas/projects_schema/schemas/template_color.json diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/template_color.json b/openpype/settings/entities/schemas/projects_schema/schemas/template_color.json deleted file mode 100644 index af8fd9dae4..0000000000 --- a/openpype/settings/entities/schemas/projects_schema/schemas/template_color.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "type": "list-strict", - "key": "{name}", - "label": "{label}", - "object_types": [ - { - "label": "Red", - "type": "number", - "minimum": 0, - "maximum": 1, - "decimal": 3 - }, - { - "label": "Green", - "type": "number", - "minimum": 0, - "maximum": 1, - "decimal": 3 - }, - { - "label": "Blue", - "type": "number", - "minimum": 0, - "maximum": 1, - "decimal": 3 - } - ] - } -] From 0114bbd965ec0975d179faf8d8f854b5a82004c5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 12:17:50 +0200 Subject: [PATCH 347/854] use new settings to define which template is used for her opublishing --- .../plugins/publish/integrate_hero_version.py | 105 ++++++++++++------ 1 file changed, 70 insertions(+), 35 deletions(-) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 466606d08b..9a50257a8b 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -7,8 +7,12 @@ import shutil from bson.objectid import ObjectId from pymongo import InsertOne, ReplaceOne import pyblish.api + from avalon import api, io, schema -from openpype.lib import create_hard_link +from openpype.lib import ( + create_hard_link, + filter_profiles +) class IntegrateHeroVersion(pyblish.api.InstancePlugin): @@ -17,7 +21,9 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): order = pyblish.api.IntegratorOrder + 0.1 optional = True + active = True + # Families are modified using settings families = [ "model", "rig", @@ -33,11 +39,13 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): "project", "asset", "task", "subset", "representation", "family", "hierarchy", "task", "username" ] - # TODO add family filtering # QUESTION/TODO this process should happen on server if crashed due to # permissions error on files (files were used or user didn't have perms) # *but all other plugins must be sucessfully completed + template_name_profiles = [] + _default_template_name = "hero" + def process(self, instance): self.log.debug( "--- Integration of Hero version for subset `{}` begins.".format( @@ -51,26 +59,34 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): ) return - project_name = api.Session["AVALON_PROJECT"] + template_key = self._get_template_key(instance) - # TODO raise error if Hero not set? anatomy = instance.context.data["anatomy"] - if "hero" not in anatomy.templates: - self.log.warning("!!! Anatomy does not have set `hero` key!") - return - - if "path" not in anatomy.templates["hero"]: + project_name = api.Session["AVALON_PROJECT"] + if template_key not in anatomy.templates: self.log.warning(( - "!!! There is not set `path` template in `hero` anatomy" - " for project \"{}\"." - ).format(project_name)) + "!!! Anatomy of project \"{}\" does not have set" + " \"{}\" template key!" + ).format(project_name, template_key)) return - hero_template = anatomy.templates["hero"]["path"] + if "path" not in anatomy.templates[template_key]: + self.log.warning(( + "!!! There is not set \"path\" template in \"{}\" anatomy" + " for project \"{}\"." + ).format(template_key, project_name)) + return + + hero_template = anatomy.templates[template_key]["path"] self.log.debug("`hero` template check was successful. `{}`".format( hero_template )) + self.process_instance(instance, template_key, hero_template) + + def process_instance(self, instance, template_key, hero_template): + anatomy = instance.context.data["anatomy"] + published_repres = instance.data["published_representations"] hero_publish_dir = self.get_publish_dir(instance) src_version_entity = instance.data.get("versionEntity") @@ -271,12 +287,12 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): continue # Prepare anatomy data - anatomy_data = repre_info["anatomy_data"] + anatomy_data = copy.deepcopy(repre_info["anatomy_data"]) anatomy_data.pop("version", None) # Get filled path to repre context anatomy_filled = anatomy.format(anatomy_data) - template_filled = anatomy_filled["hero"]["path"] + template_filled = anatomy_filled[template_key]["path"] repre_data = { "path": str(template_filled), @@ -308,11 +324,11 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): collections, remainders = clique.assemble(published_files) if remainders or not collections or len(collections) > 1: raise Exception(( - "Integrity error. Files of published representation " - "is combination of frame collections and single files." - "Collections: `{}` Single files: `{}`" - ).format(str(collections), - str(remainders))) + "Integrity error. Files of published" + " representation is combination of frame" + " collections and single files. Collections:" + " `{}` Single files: `{}`" + ).format(str(collections), str(remainders))) src_col = collections[0] @@ -320,7 +336,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): frame_splitter = "_-_FRAME_SPLIT_-_" anatomy_data["frame"] = frame_splitter _anatomy_filled = anatomy.format(anatomy_data) - _template_filled = _anatomy_filled["hero"]["path"] + _template_filled = _anatomy_filled[template_key]["path"] head, tail = _template_filled.split(frame_splitter) padding = int( anatomy.templates["render"].get( @@ -466,13 +482,13 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): files.append(_path) return files - def get_publish_dir(self, instance): + def get_publish_dir(self, instance, template_key): anatomy = instance.context.data["anatomy"] template_data = copy.deepcopy(instance.data["anatomyData"]) - if "folder" in anatomy.templates["hero"]: + if "folder" in anatomy.templates[template_key]: anatomy_filled = anatomy.format(template_data) - publish_folder = anatomy_filled["hero"]["folder"] + publish_folder = anatomy_filled[template_key]["folder"] else: # This is for cases of Deprecated anatomy without `folder` # TODO remove when all clients have solved this issue @@ -489,7 +505,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): " key underneath `publish` (in global of for project `{}`)." ).format(project_name)) - file_path = anatomy_filled["hero"]["path"] + file_path = anatomy_filled[template_key]["path"] # Directory publish_folder = os.path.dirname(file_path) @@ -499,6 +515,31 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): return publish_folder + def _get_template_key(self, instance): + anatomy_data = instance.data["anatomyData"] + task_data = anatomy_data.get("task") or {} + task_name = task_data.get("name") + task_type = task_data.get("type") + host_name = instance.context.data["hostName"] + # TODO raise error if Hero not set? + family = self.main_family_from_instance(instance) + key_values = { + "families": family, + "task_names": task_name, + "task_types": task_type, + "hosts": host_name + } + profile = filter_profiles( + self.template_name_profiles, + key_values, + logger=self.log + ) + if profile: + template_name = profile["template_name"] + else: + template_name = self._default_template_name + return template_name + def copy_file(self, src_path, dst_path): # TODO check drives if are the same to check if cas hardlink dirname = os.path.dirname(dst_path) @@ -564,22 +605,16 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): src_file (string) - original file path dst_file (string) - hero file path """ - _, rootless = anatomy.find_root_template_from_path( - dst_file - ) - _, rtls_src = anatomy.find_root_template_from_path( - src_file - ) + _, rootless = anatomy.find_root_template_from_path(dst_file) + _, rtls_src = anatomy.find_root_template_from_path(src_file) return path.replace(rtls_src, rootless) def _update_hash(self, hash, src_file_name, dst_file): """ Updates hash value with proper hero name """ - src_file_name = self._get_name_without_ext( - src_file_name) - hero_file_name = self._get_name_without_ext( - dst_file) + src_file_name = self._get_name_without_ext(src_file_name) + hero_file_name = self._get_name_without_ext(dst_file) return hash.replace(src_file_name, hero_file_name) def _get_name_without_ext(self, value): From ea8b3b79b1c3426194a49db7ac5c6d909a0c1d38 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 13:34:25 +0200 Subject: [PATCH 348/854] OP-2951 - added force_only_broken argument to sync methods Cleaned up representation in sync methods --- .../modules/sync_server/sync_server_module.py | 46 +++++++++++-------- openpype/modules/sync_server/utils.py | 5 ++ 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index caf58503f1..9895a6d430 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -23,7 +23,7 @@ from openpype.settings.lib import ( from .providers.local_drive import LocalDriveHandler from .providers import lib -from .utils import time_function, SyncStatus +from .utils import time_function, SyncStatus, SiteAlreadyPresentError log = PypeLogger().get_logger("SyncServer") @@ -129,7 +129,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): """ Start of Public API """ def add_site(self, collection, representation_id, site_name=None, - force=False): + force=False, force_only_broken=False): """ Adds new site to representation to be synced. @@ -143,6 +143,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): representation_id (string): MongoDB _id value site_name (string): name of configured and active site force (bool): reset site if exists + force_only_broken (bool): reset only if "error" present Returns: throws ValueError if any issue @@ -155,7 +156,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self.reset_site_on_representation(collection, representation_id, - site_name=site_name, force=force) + site_name=site_name, + force=force, + force_only_broken=force_only_broken) # public facing API def remove_site(self, collection, representation_id, site_name, @@ -281,7 +284,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): os.path.getmtime(local_file_path)) elem = {"name": site_name, "created_dt": created_dt} - self._add_site(collection, query, [repre], elem, + self._add_site(collection, query, repre, elem, site_name=site_name, file_id=repre_file["_id"]) sites_added += 1 @@ -819,7 +822,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self.log.debug("Adding alternate {} to {}".format( alt_site, representation["_id"])) self._add_site(collection, query, - [representation], elem, + representation, elem, alt_site, file_id=file_id, force=True) """ End of Public API """ @@ -1394,7 +1397,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule): def reset_site_on_representation(self, collection, representation_id, side=None, file_id=None, site_name=None, - remove=False, pause=None, force=False): + remove=False, pause=None, force=False, + force_only_broken=False): """ Reset information about synchronization for particular 'file_id' and provider. @@ -1417,6 +1421,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): remove (bool): if True remove site altogether pause (bool or None): if True - pause, False - unpause force (bool): hard reset - currently only for add_site + force_only_broken(bool): reset site only if there is "error" field Returns: throws ValueError @@ -1425,7 +1430,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): "_id": ObjectId(representation_id) } - representation = list(self.connection.database[collection].find(query)) + representation = self.connection.database[collection].find_one(query) if not representation: raise ValueError("Representation {} not found in {}". format(representation_id, collection)) @@ -1456,7 +1461,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): representation, site_name, pause) else: # add new site to all files for representation self._add_site(collection, query, representation, elem, site_name, - force) + force=force, force_only_broken=force_only_broken) def _update_site(self, collection, query, update, arr_filter): """ @@ -1511,7 +1516,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): Throws ValueError if 'site_name' not found on 'representation' """ found = False - for repre_file in representation.pop().get("files"): + for repre_file in representation.get("files"): for site in repre_file.get("sites"): if site.get("name") == site_name: found = True @@ -1537,7 +1542,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule): """ found = False site = None - for repre_file in representation.pop().get("files"): + for repre_file in representation.get("files"): for site in repre_file.get("sites"): if site["name"] == site_name: found = True @@ -1564,34 +1569,39 @@ class SyncServerModule(OpenPypeModule, ITrayModule): self._update_site(collection, query, update, arr_filter) def _add_site(self, collection, query, representation, elem, site_name, - force=False, file_id=None): + force=False, file_id=None, force_only_broken=False): """ Adds 'site_name' to 'representation' on 'collection' Args: - representation (list of 1 dict) + representation (dict) file_id (ObjectId) Use 'force' to remove existing or raises ValueError """ - reseted_existing = False - for repre_file in representation.pop().get("files"): + reset_existing = False + files = representation.get("files", []) + if not files: + log.debug("No files for {}".format(representation["_id"])) + return + + for repre_file in files: if file_id and file_id != repre_file["_id"]: continue for site in repre_file.get("sites"): if site["name"] == site_name: - if force: + if force or (force_only_broken and site.get("error")): self._reset_site_for_file(collection, query, elem, repre_file["_id"], site_name) - reseted_existing = True + reset_existing = True else: msg = "Site {} already present".format(site_name) log.info(msg) - raise ValueError(msg) + raise SiteAlreadyPresentError(msg) - if reseted_existing: + if reset_existing: return if not file_id: diff --git a/openpype/modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py index 85e4e03f77..03f362202f 100644 --- a/openpype/modules/sync_server/utils.py +++ b/openpype/modules/sync_server/utils.py @@ -8,6 +8,11 @@ class ResumableError(Exception): pass +class SiteAlreadyPresentError(Exception): + """Representation has already site skeleton present.""" + pass + + class SyncStatus: DO_NOTHING = 0 DO_UPLOAD = 1 From d340d05bf01a5f8beda6cdae1736cb59219c4a07 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 13:37:12 +0200 Subject: [PATCH 349/854] OP-2951 - implemented synching referenced files in workfile When workfile is synched, it checks for referenced files (added by Loader) and tries to sync them too. --- openpype/plugins/load/add_site.py | 72 ++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py index 95001691e2..0ddce6e160 100644 --- a/openpype/plugins/load/add_site.py +++ b/openpype/plugins/load/add_site.py @@ -1,9 +1,19 @@ from openpype.modules import ModulesManager from openpype.pipeline import load +:from openpype.lib.avalon_context import get_linked_ids_for_representations +from openpype.modules.sync_server.utils import SiteAlreadyPresentError class AddSyncSite(load.LoaderPlugin): - """Add sync site to representation""" + """Add sync site to representation + + If family of synced representation is 'workfile', it looks for all + representations which are referenced (loaded) in workfile with content of + 'inputLinks'. + It doesn't do any checks for site, most common use case is when artist is + downloading workfile to his local site, but it might be helpful when + artist is re-uploading broken representation on remote site also. + """ representations = ["*"] families = ["*"] @@ -12,21 +22,61 @@ class AddSyncSite(load.LoaderPlugin): icon = "download" color = "#999999" + _sync_server = None + + @property + def sync_server(self): + if not self._sync_server: + manager = ModulesManager() + self._sync_server = manager.modules_by_name["sync_server"] + + return self._sync_server + def load(self, context, name=None, namespace=None, data=None): self.log.info("Adding {} to representation: {}".format( data["site_name"], data["_id"])) - self.add_site_to_representation(data["project_name"], - data["_id"], - data["site_name"]) + family = context["representation"]["context"]["family"] + project_name = data["project_name"] + repre_id = data["_id"] + + add_ids = [repre_id] + if family == "workfile": + links = get_linked_ids_for_representations(project_name, + add_ids, + link_type="reference") + add_ids.extend(links) + + add_ids = set(add_ids) + self.log.info("Add to repre_ids {}".format(add_ids)) + is_main = True + for add_repre_id in add_ids: + self.add_site_to_representation(project_name, + add_repre_id, + data["site_name"], + is_main) + is_main = False + self.log.debug("Site added.") - @staticmethod - def add_site_to_representation(project_name, representation_id, site_name): - """Adds new site to representation_id, resets if exists""" - manager = ModulesManager() - sync_server = manager.modules_by_name["sync_server"] - sync_server.add_site(project_name, representation_id, site_name, - force=True) + def add_site_to_representation(self, project_name, representation_id, + site_name, is_main): + """Adds new site to representation_id, resets if exists + + Args: + project_name (str) + representation_id (ObjectId): + site_name (str) + is_main (bool): true for really downloaded, false for references, + force redownload main file always, for references only if + broken + """ + try: + self.sync_server.add_site(project_name, representation_id, + site_name, + force=is_main, + force_only_broken=not is_main) + except SiteAlreadyPresentError: + self.log.debug("Site present", exc_info=True) def filepath_from_context(self, context): """No real file loading""" From 952717440d550ed602b424b3a4f43a5aaa01f350 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 13:46:38 +0200 Subject: [PATCH 350/854] bug fixes and implementing missing method --- openpype/plugins/publish/integrate_hero_version.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 9a50257a8b..67c91634d2 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -82,12 +82,12 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): hero_template )) - self.process_instance(instance, template_key, hero_template) + self.integrate_instance(instance, template_key, hero_template) - def process_instance(self, instance, template_key, hero_template): + def integrate_instance(self, instance, template_key, hero_template): anatomy = instance.context.data["anatomy"] published_repres = instance.data["published_representations"] - hero_publish_dir = self.get_publish_dir(instance) + hero_publish_dir = self.get_publish_dir(instance, template_key) src_version_entity = instance.data.get("versionEntity") filtered_repre_ids = [] @@ -540,6 +540,13 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): template_name = self._default_template_name return template_name + def main_family_from_instance(self, instance): + """Returns main family of entered instance.""" + family = instance.data.get("family") + if not family: + family = instance.data["families"][0] + return family + def copy_file(self, src_path, dst_path): # TODO check drives if are the same to check if cas hardlink dirname = os.path.dirname(dst_path) From 059020cabd21cf656bceadf76d63ab719ff59a58 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 13:53:22 +0200 Subject: [PATCH 351/854] change frame padding access --- openpype/plugins/publish/integrate_hero_version.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index 67c91634d2..d50f2a4712 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -339,10 +339,7 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): _template_filled = _anatomy_filled[template_key]["path"] head, tail = _template_filled.split(frame_splitter) padding = int( - anatomy.templates["render"].get( - "frame_padding", - anatomy.templates["render"].get("padding") - ) + anatomy.templates[template_key]["frame_padding"] ) dst_col = clique.Collection( From e76c317d7700d0950e323a69ae33650889815543 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 13:56:28 +0200 Subject: [PATCH 352/854] remove her publish dir on error before renaming it back --- openpype/plugins/publish/integrate_hero_version.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index d50f2a4712..d6df6535d8 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -457,6 +457,8 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): backup_hero_publish_dir is not None and os.path.exists(backup_hero_publish_dir) ): + if os.path.exists(hero_publish_dir): + shutil.rmtree(hero_publish_dir) os.rename(backup_hero_publish_dir, hero_publish_dir) self.log.error(( "!!! Creating of hero version failed." From a197334a251404d06f89ec3de6940db68c4b1dde Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 13:57:21 +0200 Subject: [PATCH 353/854] OP-2951 - added function to collect referenced representation ids --- openpype/lib/avalon_context.py | 120 +++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index b4e6abb72d..e8a365ec39 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1971,3 +1971,123 @@ def get_last_workfile( return os.path.normpath(os.path.join(workdir, filename)) return filename + + +@with_avalon +def get_linked_ids_for_representations(project, repre_ids, dbcon=None, + link_type=None, max_depth=0): + """Returns list of linked ids of particular type (if provided). + + Goes from representations to version, back to representations + Args: + project (str) + repre_ids (list) or (ObjectId) + dbcon (avalon.mongodb.AvalonMongoDB, optional): Avalon Mongo connection + with Session. + link_type (str): ['reference', '..] + max_depth (int): limit how many levels of recursion + Returns: + (list) of ObjectId - linked representations + """ + if not dbcon: + log.debug("Using `avalon.io` for query.") + dbcon = avalon.io + # Make sure is installed + dbcon.install() + + if dbcon.Session["AVALON_PROJECT"] != project: + dbcon.Session["AVALON_PROJECT"] = project + + if not isinstance(repre_ids, list): + repre_ids = [repre_ids] + + versions = avalon.io.find( + { + "_id": {"$in": repre_ids}, + "type": "representation" + }, + projection={"parent": True} + ) + version_ids = [version["parent"] for version in versions] + + graph_lookup = { + "from": project, + "startWith": "$data.inputLinks.id", + "connectFromField": "data.inputLinks.id", + "connectToField": "_id", + "as": "outputs_recursive", + "depthField": "depth" + } + if max_depth != 0: + # We offset by -1 since 0 basically means no recursion + # but the recursion only happens after the initial lookup + # for outputs. + graph_lookup["maxDepth"] = max_depth - 1 + + match = { + "_id": {"$in": version_ids}, + "type": "version" + } + + pipeline_ = [ + # Match + {"$match": match}, + # Recursive graph lookup for inputs + {"$graphLookup": graph_lookup} + ] + + result = dbcon.aggregate(pipeline_) + referenced_version_ids = _process_referenced_pipeline_result(result, + link_type) + + representations = avalon.io.find( + { + "parent": {"$in": list(referenced_version_ids)}, + "type": "representation" + }, + projection={"_id": True} + ) + ref_ids = {representation["_id"] for representation in representations} + return list(ref_ids) + + +def _process_referenced_pipeline_result(result, link_type): + """Filters result from pipeline for particular link_type. + + Pipeline cannot use link_type directly in a query. + Returns: + (list) + """ + referenced_version_ids = set() + correctly_linked_ids = set() + for item in result: + correctly_linked_ids = _filter_input_links(item["data"]["inputLinks"], + link_type, + correctly_linked_ids) + + # outputs_recursive in random order, sort by _id + outputs_recursive = sorted(item.get("outputs_recursive", []), + key=lambda d: d["_id"]) + # go from oldest to newest + # only older _id can reference another newer _id + for output in outputs_recursive[::-1]: + if output["_id"] not in correctly_linked_ids: # leaf + continue + + correctly_linked_ids = _filter_input_links( + output["data"].get("inputLinks", []), + link_type, + correctly_linked_ids) + + referenced_version_ids.add(output["_id"]) + + return referenced_version_ids + + +def _filter_input_links(input_links, link_type, correctly_linked_ids): + for input_link in input_links: + if not link_type or input_link["type"] == link_type: + correctly_linked_ids.add(input_link.get("id") or + input_link.get("_id")) # legacy + + return correctly_linked_ids From a0a2e2678e55f449201981b419d9a6a13f8b4a49 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 14:07:57 +0200 Subject: [PATCH 354/854] OP-2951 - fixed typo --- openpype/plugins/load/add_site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py index 0ddce6e160..59720eb5b6 100644 --- a/openpype/plugins/load/add_site.py +++ b/openpype/plugins/load/add_site.py @@ -1,6 +1,6 @@ from openpype.modules import ModulesManager from openpype.pipeline import load -:from openpype.lib.avalon_context import get_linked_ids_for_representations +from openpype.lib.avalon_context import get_linked_ids_for_representations from openpype.modules.sync_server.utils import SiteAlreadyPresentError From dfa5555535b06d9fadf7118858574d79f41d0155 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 16:28:13 +0200 Subject: [PATCH 355/854] Fix creation of subset names in PS review and workfile --- .../hosts/photoshop/plugins/publish/collect_review.py | 10 +++++++++- .../photoshop/plugins/publish/collect_workfile.py | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 5ab48b76da..df069efd1f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -2,6 +2,8 @@ import os import pyblish.api +from openpype.lib import get_subset_name + class CollectReview(pyblish.api.ContextPlugin): """Gather the active document as review instance.""" @@ -13,7 +15,13 @@ class CollectReview(pyblish.api.ContextPlugin): def process(self, context): family = "review" task = os.getenv("AVALON_TASK", None) - subset = family + task.capitalize() + subset = get_subset_name( + family, + "", + task, + context.data["assetEntity"]["_id"], + host_name="photoshop" + ) file_path = context.data["currentFile"] base_name = os.path.basename(file_path) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index db1ede14d5..841db72cde 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,6 +1,8 @@ import os import pyblish.api +from openpype.lib import get_subset_name + class CollectWorkfile(pyblish.api.ContextPlugin): """Collect current script for publish.""" @@ -12,7 +14,13 @@ class CollectWorkfile(pyblish.api.ContextPlugin): def process(self, context): family = "workfile" task = os.getenv("AVALON_TASK", None) - subset = family + task.capitalize() + subset = get_subset_name( + family, + "", + task, + context.data["assetEntity"]["_id"], + host_name="photoshop" + ) file_path = context.data["currentFile"] staging_dir = os.path.dirname(file_path) From af092348e50e1bda0ac6b3a13a58f1908cf5b939 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 16:32:32 +0200 Subject: [PATCH 356/854] OP-2766 - Fix creation of subset names in PS review and workfile --- .../hosts/photoshop/plugins/publish/collect_review.py | 10 +++++++++- .../photoshop/plugins/publish/collect_workfile.py | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 4b6f855a6a..dafeb95d0e 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -10,6 +10,8 @@ import os import pyblish.api +from openpype.lib import get_subset_name + class CollectReview(pyblish.api.ContextPlugin): """Gather the active document as review instance. @@ -25,7 +27,13 @@ class CollectReview(pyblish.api.ContextPlugin): def process(self, context): family = "review" task = os.getenv("AVALON_TASK", None) - subset = family + task.capitalize() + subset = get_subset_name( + family, + "", + task, + context.data["assetEntity"]["_id"], + host_name="photoshop" + ) instance = context.create_instance(subset) instance.data.update({ diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index bdbd379a33..1a826c3f2a 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,6 +1,8 @@ import os import pyblish.api +from openpype.lib import get_subset_name + class CollectWorkfile(pyblish.api.ContextPlugin): """Collect current script for publish.""" @@ -19,7 +21,13 @@ class CollectWorkfile(pyblish.api.ContextPlugin): family = "workfile" task = os.getenv("AVALON_TASK", None) - subset = family + task.capitalize() + subset = get_subset_name( + family, + "", + task, + context.data["assetEntity"]["_id"], + host_name="photoshop" + ) file_path = context.data["currentFile"] staging_dir = os.path.dirname(file_path) From a19983313c5c509cdb5cc1e02cfbb5aa0c918c88 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 16:38:59 +0200 Subject: [PATCH 357/854] OP-2766 - Fix order of collector --- openpype/hosts/photoshop/plugins/publish/collect_review.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index df069efd1f..6c299a60f5 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -11,6 +11,7 @@ class CollectReview(pyblish.api.ContextPlugin): label = "Review" order = pyblish.api.CollectorOrder hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.1 def process(self, context): family = "review" From 653d0c1366f5d976bcd4458bfd1ceb903f216e83 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 17:21:49 +0200 Subject: [PATCH 358/854] OP-2766 - Fix pulling task and project from context --- openpype/hosts/photoshop/plugins/publish/collect_review.py | 4 ++-- openpype/hosts/photoshop/plugins/publish/collect_workfile.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 6c299a60f5..e219326d64 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -15,12 +15,12 @@ class CollectReview(pyblish.api.ContextPlugin): def process(self, context): family = "review" - task = os.getenv("AVALON_TASK", None) subset = get_subset_name( family, "", - task, + context.data["anatomyData"]["task"]["name"], context.data["assetEntity"]["_id"], + context.data["anatomyData"]["project"]["name"], host_name="photoshop" ) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 841db72cde..ca1124fd24 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -13,12 +13,12 @@ class CollectWorkfile(pyblish.api.ContextPlugin): def process(self, context): family = "workfile" - task = os.getenv("AVALON_TASK", None) subset = get_subset_name( family, "", - task, + context.data["anatomyData"]["task"]["name"], context.data["assetEntity"]["_id"], + context.data["anatomyData"]["project"]["name"], host_name="photoshop" ) From 0f08f3e31df5a6ec54c025776d490343a587ab5b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 17:25:19 +0200 Subject: [PATCH 359/854] OP-2766 - Fix pulling task and project from context --- openpype/hosts/photoshop/plugins/publish/collect_review.py | 5 +++-- openpype/hosts/photoshop/plugins/publish/collect_workfile.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index dafeb95d0e..09fed2df78 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -23,15 +23,16 @@ class CollectReview(pyblish.api.ContextPlugin): label = "Collect Review" order = pyblish.api.CollectorOrder hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.1 def process(self, context): family = "review" - task = os.getenv("AVALON_TASK", None) subset = get_subset_name( family, "", - task, + context.data["anatomyData"]["task"]["name"], context.data["assetEntity"]["_id"], + context.data["anatomyData"]["project"]["name"], host_name="photoshop" ) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 1a826c3f2a..71022a86fd 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -20,12 +20,12 @@ class CollectWorkfile(pyblish.api.ContextPlugin): break family = "workfile" - task = os.getenv("AVALON_TASK", None) subset = get_subset_name( family, "", - task, + context.data["anatomyData"]["task"]["name"], context.data["assetEntity"]["_id"], + context.data["anatomyData"]["project"]["name"], host_name="photoshop" ) From 9d8df33d4af634614f0ec9c2cbf716ef014c7698 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 16:38:59 +0200 Subject: [PATCH 360/854] Fix order of collector --- openpype/hosts/photoshop/plugins/publish/collect_review.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index df069efd1f..6c299a60f5 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -11,6 +11,7 @@ class CollectReview(pyblish.api.ContextPlugin): label = "Review" order = pyblish.api.CollectorOrder hosts = ["photoshop"] + order = pyblish.api.CollectorOrder + 0.1 def process(self, context): family = "review" From 8b1cfa7d19e476c3a6579fa63de89ef9c8c1e855 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 17:21:49 +0200 Subject: [PATCH 361/854] Fix pulling task and project from context --- openpype/hosts/photoshop/plugins/publish/collect_review.py | 4 ++-- openpype/hosts/photoshop/plugins/publish/collect_workfile.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 6c299a60f5..e219326d64 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -15,12 +15,12 @@ class CollectReview(pyblish.api.ContextPlugin): def process(self, context): family = "review" - task = os.getenv("AVALON_TASK", None) subset = get_subset_name( family, "", - task, + context.data["anatomyData"]["task"]["name"], context.data["assetEntity"]["_id"], + context.data["anatomyData"]["project"]["name"], host_name="photoshop" ) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 841db72cde..ca1124fd24 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -13,12 +13,12 @@ class CollectWorkfile(pyblish.api.ContextPlugin): def process(self, context): family = "workfile" - task = os.getenv("AVALON_TASK", None) subset = get_subset_name( family, "", - task, + context.data["anatomyData"]["task"]["name"], context.data["assetEntity"]["_id"], + context.data["anatomyData"]["project"]["name"], host_name="photoshop" ) From 3baee04ada9b7ea11b4877ac0e30d29453ccb67f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 17:32:12 +0200 Subject: [PATCH 362/854] Fix order --- openpype/hosts/photoshop/plugins/publish/collect_review.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index e219326d64..8b7508cf5b 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -9,9 +9,8 @@ class CollectReview(pyblish.api.ContextPlugin): """Gather the active document as review instance.""" label = "Review" - order = pyblish.api.CollectorOrder - hosts = ["photoshop"] order = pyblish.api.CollectorOrder + 0.1 + hosts = ["photoshop"] def process(self, context): family = "review" From b3391ebe7151ae540dc01201d43cf75651cd7979 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 29 Mar 2022 17:42:53 +0200 Subject: [PATCH 363/854] Fix order --- openpype/hosts/photoshop/plugins/publish/collect_review.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 2f87af5d72..8b7508cf5b 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -11,7 +11,6 @@ class CollectReview(pyblish.api.ContextPlugin): label = "Review" order = pyblish.api.CollectorOrder + 0.1 hosts = ["photoshop"] - order = pyblish.api.CollectorOrder + 0.1 def process(self, context): family = "review" From 505d73145c34a3379dae4e699f1d3b825ce0567c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 18:11:14 +0200 Subject: [PATCH 364/854] enhanced tool settings to have also filters --- .../defaults/system_settings/tools.json | 12 +++++++-- .../schemas/system_schema/schema_tools.json | 25 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/openpype/settings/defaults/system_settings/tools.json b/openpype/settings/defaults/system_settings/tools.json index 181236abe8..9e08465195 100644 --- a/openpype/settings/defaults/system_settings/tools.json +++ b/openpype/settings/defaults/system_settings/tools.json @@ -25,10 +25,18 @@ }, "variants": { "3-2": { - "MTOA_VERSION": "3.2" + "host_names": [], + "app_variants": [], + "environment": { + "MTOA_VERSION": "3.2" + } }, "3-1": { - "MTOA_VERSION": "3.1" + "host_names": [], + "app_variants": [], + "environment": { + "MTOA_VERSION": "3.1" + } }, "__dynamic_keys_labels__": { "3-2": "3.2", diff --git a/openpype/settings/entities/schemas/system_schema/schema_tools.json b/openpype/settings/entities/schemas/system_schema/schema_tools.json index 2346bef36d..7962fdd465 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_tools.json +++ b/openpype/settings/entities/schemas/system_schema/schema_tools.json @@ -25,7 +25,30 @@ "key": "variants", "collapsible_key": true, "object_type": { - "type": "raw-json" + "type": "dict", + "children": [ + { + "key": "host_names", + "label": "Hosts", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "app_variants", + "label": "Applications", + "type": "apps-enum", + "multiselection": true, + "tooltip": "Applications are not \"live\" and may require to Save and refresh settings UI to update values." + }, + { + "type": "separator" + }, + { + "key": "environment", + "label": "Environments", + "type": "raw-json" + } + ] } } ] From 3fd989671acb44e9df400be7217243bd12d23ae2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 18:11:59 +0200 Subject: [PATCH 365/854] added backwards compatibility of tools right into settings lib function --- openpype/settings/lib.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 1d303564d5..54502292dc 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -265,11 +265,43 @@ def save_project_anatomy(project_name, anatomy_data): raise SaveWarningExc(warnings) +def _system_settings_backwards_compatible_conversion(studio_overrides): + # Backwards compatibility of tools 3.9.1 - 3.9.2 to keep + # "tools" environments + if ( + "tools" in studio_overrides + and "tool_groups" in studio_overrides["tools"] + ): + tool_groups = studio_overrides["tools"]["tool_groups"] + for tool_group, group_value in tool_groups.items(): + if tool_group in METADATA_KEYS: + continue + + variants = group_value.get("variants") + if not variants: + continue + + for key in set(variants.keys()): + if key in METADATA_KEYS: + continue + + variant_value = variants[key] + if "environment" not in variant_value: + variants[key] = { + "environment": variant_value + } + + @require_handler def get_studio_system_settings_overrides(return_version=False): - return _SETTINGS_HANDLER.get_studio_system_settings_overrides( + output = _SETTINGS_HANDLER.get_studio_system_settings_overrides( return_version ) + value = output + if return_version: + value, version = output + _system_settings_backwards_compatible_conversion(value) + return output @require_handler From b3463afa38d2ed80c3ccffe8f3f757ace44955ef Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 18:14:09 +0200 Subject: [PATCH 366/854] load settings data in EnvironmentTool with backwards comatible way --- openpype/lib/applications.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index ad59ae0dbc..e415120ac4 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -211,6 +211,7 @@ class ApplicationGroup: data (dict): Group defying data loaded from settings. manager (ApplicationManager): Manager that created the group. """ + def __init__(self, name, data, manager): self.name = name self.manager = manager @@ -374,6 +375,7 @@ class ApplicationManager: will always use these values. Gives ability to create manager using different settings. """ + def __init__(self, system_settings=None): self.log = PypeLogger.get_logger(self.__class__.__name__) @@ -530,13 +532,13 @@ class EnvironmentToolGroup: variants = data.get("variants") or {} label_by_key = variants.pop(M_DYNAMIC_KEY_LABEL, {}) variants_by_name = {} - for variant_name, variant_env in variants.items(): + for variant_name, variant_data in variants.items(): if variant_name in METADATA_KEYS: continue variant_label = label_by_key.get(variant_name) or variant_name tool = EnvironmentTool( - variant_name, variant_label, variant_env, self + variant_name, variant_label, variant_data, self ) variants_by_name[variant_name] = tool self.variants = variants_by_name @@ -560,15 +562,35 @@ class EnvironmentTool: Args: name (str): Name of the tool. - environment (dict): Variant environments. + variant_data (dict): Variant data with environments and + host and app variant filters. group (str): Name of group which wraps tool. """ - def __init__(self, name, label, environment, group): + def __init__(self, name, label, variant_data, group): + # Backwards compatibility 3.9.1 - 3.9.2 + # - 'variant_data' contained only environments but contain also host + # and application variant filters + host_names = [] + app_variants = [] + if "host_names" in variant_data: + host_names = variant_data["host_names"] + + if "app_variants" in variant_data: + app_variants = variant_data["app_variants"] + + if "environment" in variant_data: + environment = variant_data["environemnt"] + else: + environment = variant_data + + self.host_names = host_names + self.app_variants = app_variants self.name = name self.variant_label = label self.label = " ".join((group.label, label)) self.group = group + self._environment = environment self.full_name = "/".join((group.name, name)) From 39f84f8dfdb4f2ec7f369acf3b2e80971befea70 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 18:14:24 +0200 Subject: [PATCH 367/854] added option to validate if tool is valid for application --- openpype/lib/applications.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index e415120ac4..176a9a2391 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -601,6 +601,19 @@ class EnvironmentTool: def environment(self): return copy.deepcopy(self._environment) + def is_valid_for_app(self, app): + """Is tool valid for application. + + Args: + app (Application): Application for which are prepared environments. + """ + if self.app_variants and app.full_name not in self.app_variants: + return False + + if self.host_names and app.host_name not in self.host_names: + return False + return True + class ApplicationExecutable: """Representation of executable loaded from settings.""" @@ -1406,7 +1419,7 @@ def prepare_app_environments(data, env_group=None, implementation_envs=True): # Make sure each tool group can be added only once for key in asset_doc["data"].get("tools_env") or []: tool = app.manager.tools.get(key) - if not tool: + if not tool or not tool.is_valid_for_app(app): continue groups_by_name[tool.group.name] = tool.group tool_by_group_name[tool.group.name][tool.name] = tool From 3bda53a54138b9e211f1d88d0e7e01a48fef9ac7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 29 Mar 2022 18:21:10 +0200 Subject: [PATCH 368/854] fix typo --- openpype/lib/applications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 176a9a2391..b496bd74e2 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -580,7 +580,7 @@ class EnvironmentTool: app_variants = variant_data["app_variants"] if "environment" in variant_data: - environment = variant_data["environemnt"] + environment = variant_data["environment"] else: environment = variant_data From 461bf75d660cc60aeae3401bf413496cd71b7e17 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 29 Mar 2022 23:11:29 +0200 Subject: [PATCH 369/854] texture publishing initial commit --- .../validate_simple_unreal_texture_naming.py | 15 +++++++++++++++ openpype/plugins/publish/integrate_new.py | 3 ++- .../defaults/project_anatomy/templates.json | 11 ++++++++++- .../defaults/project_settings/global.json | 11 +++++++++++ .../project_settings/standalonepublisher.json | 11 ++++++++++- 5 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py new file mode 100644 index 0000000000..158a749075 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""Validator for correct file naming.""" +import pyblish.api +import openpype.api +from openpype.pipeline import PublishXmlValidationError + + +class ValidateSimpleUnrealTextureNaming(pyblish.api.InstancePlugin): + label = "Validate Unreal Texture Names" + hosts = ["standalonepublisher"] + families = ["simpleUnrealTexture"] + order = openpype.api.ValidateContentsOrder + + def process(self, instance): + ... \ No newline at end of file diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 2304f98713..4025f18cb2 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -107,7 +107,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "hda", "usd", "usdComposition", - "usdOverride" + "usdOverride", + "simpleUnrealTexture" ] exclude_families = ["clip"] db_representation_context_keys = [ diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index d46d449c77..45611f55b1 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -32,5 +32,14 @@ "file": "{subset}_{@version}<_{output}><.{@frame}>.{ext}", "path": "{@folder}/{@file}" }, - "others": {} + "others": { + "simpleUnrealTexture": { + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}", + "file": "{original_file}", + "path": "{@folder}/{@file}" + }, + "__dynamic_keys_labels__": { + "simpleUnrealTexture": "Simple Unreal Texture" + } + } } \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 30a71b044a..f0fa09957e 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -192,6 +192,17 @@ "task_types": [], "tasks": [], "template_name": "render" + }, + { + "families": [ + "simpleUnrealTexture" + ], + "hosts": [ + "standalonepublisher" + ], + "task_types": [], + "tasks": [], + "template_name": "simpleUnrealTexture" } ], "subset_grouping_profiles": [ diff --git a/openpype/settings/defaults/project_settings/standalonepublisher.json b/openpype/settings/defaults/project_settings/standalonepublisher.json index 6858c4f34d..bc91a5ea8a 100644 --- a/openpype/settings/defaults/project_settings/standalonepublisher.json +++ b/openpype/settings/defaults/project_settings/standalonepublisher.json @@ -133,6 +133,14 @@ ], "help": "Texture files with UDIM together with worfile" }, + "create_simple_unreal_texture": { + "name": "simple_unreal_texture", + "label": "Simple Unreal Texture", + "family": "simpleUnrealTexture", + "icon": "Image", + "defaults": [], + "help": "Texture files with Unreal naming convention" + }, "__dynamic_keys_labels__": { "create_workfile": "Workfile", "create_model": "Model", @@ -145,7 +153,8 @@ "create_matchmove": "Matchmove", "create_render": "Render", "create_mov_batch": "Batch Mov", - "create_texture_batch": "Batch Texture" + "create_texture_batch": "Batch Texture", + "create_simple_unreal_texture": "Simple Unreal Texture" } }, "publish": { From d26143198c59619a879ace67930f05cd0b792f61 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Mar 2022 22:21:09 +0000 Subject: [PATCH 370/854] Bump paramiko from 2.9.2 to 2.10.1 Bumps [paramiko](https://github.com/paramiko/paramiko) from 2.9.2 to 2.10.1. - [Release notes](https://github.com/paramiko/paramiko/releases) - [Changelog](https://github.com/paramiko/paramiko/blob/main/NEWS) - [Commits](https://github.com/paramiko/paramiko/compare/2.9.2...2.10.1) --- updated-dependencies: - dependency-name: paramiko dependency-type: indirect ... Signed-off-by: dependabot[bot] --- poetry.lock | 84 ++++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/poetry.lock b/poetry.lock index ed2b0dd3c2..7998ede693 100644 --- a/poetry.lock +++ b/poetry.lock @@ -680,15 +680,8 @@ category = "main" optional = false python-versions = "*" -[package.dependencies] -attrs = ">=17.4.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -pyrsistent = ">=0.14.0" -six = ">=1.11.0" - [package.extras] -format = ["idna", "jsonpointer (>1.13)", "rfc3987", "strict-rfc3339", "webcolors"] -format_nongpl = ["idna", "jsonpointer (>1.13)", "webcolors", "rfc3986-validator (>0.1.0)", "rfc3339-validator"] +format = ["rfc3987", "strict-rfc3339", "webcolors"] [[package]] name = "keyring" @@ -784,7 +777,7 @@ pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "paramiko" -version = "2.9.2" +version = "2.10.1" description = "SSH2 protocol library" category = "main" optional = false @@ -794,6 +787,7 @@ python-versions = "*" bcrypt = ">=3.1.3" cryptography = ">=2.5" pynacl = ">=1.0.1" +six = "*" [package.extras] all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"] @@ -1087,14 +1081,6 @@ category = "main" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -[[package]] -name = "pyrsistent" -version = "0.18.1" -description = "Persistent/Functional/Immutable data structures" -category = "main" -optional = false -python-versions = ">=3.7" - [[package]] name = "pysftp" version = "0.2.9" @@ -1633,7 +1619,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "2f78d48a6aad2d8a88b7dd7f31a76d907bec9fb65f0086fba6b6d2e1605f0f88" +content-hash = "b02313c8255a1897b0f0617ad4884a5943696c363512921aab1cb2dd8f4fdbe0" [metadata.files] acre = [] @@ -2171,12 +2157,28 @@ log4mongo = [ {file = "log4mongo-1.7.0.tar.gz", hash = "sha256:dc374617206162a0b14167fbb5feac01dbef587539a235dadba6200362984a68"}, ] markupsafe = [ + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, + {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, @@ -2185,14 +2187,27 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, @@ -2202,6 +2217,12 @@ markupsafe = [ {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, @@ -2277,8 +2298,8 @@ packaging = [ {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] paramiko = [ - {file = "paramiko-2.9.2-py2.py3-none-any.whl", hash = "sha256:04097dbd96871691cdb34c13db1883066b8a13a0df2afd4cb0a92221f51c2603"}, - {file = "paramiko-2.9.2.tar.gz", hash = "sha256:944a9e5dbdd413ab6c7951ea46b0ab40713235a9c4c5ca81cfe45c6f14fa677b"}, + {file = "paramiko-2.10.1-py2.py3-none-any.whl", hash = "sha256:f6cbd3e1204abfdbcd40b3ecbc9d32f04027cd3080fe666245e21e7540ccfc1b"}, + {file = "paramiko-2.10.1.tar.gz", hash = "sha256:443f4da23ec24e9a9c0ea54017829c282abdda1d57110bf229360775ccd27a31"}, ] parso = [ {file = "parso-0.8.3-py2.py3-none-any.whl", hash = "sha256:c001d4636cd3aecdaf33cbb40aebb59b094be2a74c556778ef5576c175e19e75"}, @@ -2598,29 +2619,6 @@ pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] -pyrsistent = [ - {file = "pyrsistent-0.18.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:df46c854f490f81210870e509818b729db4488e1f30f2a1ce1698b2295a878d1"}, - {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d45866ececf4a5fff8742c25722da6d4c9e180daa7b405dc0a2a2790d668c26"}, - {file = "pyrsistent-0.18.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ed6784ceac462a7d6fcb7e9b663e93b9a6fb373b7f43594f9ff68875788e01e"}, - {file = "pyrsistent-0.18.1-cp310-cp310-win32.whl", hash = "sha256:e4f3149fd5eb9b285d6bfb54d2e5173f6a116fe19172686797c056672689daf6"}, - {file = "pyrsistent-0.18.1-cp310-cp310-win_amd64.whl", hash = "sha256:636ce2dc235046ccd3d8c56a7ad54e99d5c1cd0ef07d9ae847306c91d11b5fec"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e92a52c166426efbe0d1ec1332ee9119b6d32fc1f0bbfd55d5c1088070e7fc1b"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7a096646eab884bf8bed965bad63ea327e0d0c38989fc83c5ea7b8a87037bfc"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cdfd2c361b8a8e5d9499b9082b501c452ade8bbf42aef97ea04854f4a3f43b22"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-win32.whl", hash = "sha256:7ec335fc998faa4febe75cc5268a9eac0478b3f681602c1f27befaf2a1abe1d8"}, - {file = "pyrsistent-0.18.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6455fc599df93d1f60e1c5c4fe471499f08d190d57eca040c0ea182301321286"}, - {file = "pyrsistent-0.18.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd8da6d0124efa2f67d86fa70c851022f87c98e205f0594e1fae044e7119a5a6"}, - {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bfe2388663fd18bd8ce7db2c91c7400bf3e1a9e8bd7d63bf7e77d39051b85ec"}, - {file = "pyrsistent-0.18.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e3e1fcc45199df76053026a51cc59ab2ea3fc7c094c6627e93b7b44cdae2c8c"}, - {file = "pyrsistent-0.18.1-cp38-cp38-win32.whl", hash = "sha256:b568f35ad53a7b07ed9b1b2bae09eb15cdd671a5ba5d2c66caee40dbf91c68ca"}, - {file = "pyrsistent-0.18.1-cp38-cp38-win_amd64.whl", hash = "sha256:d1b96547410f76078eaf66d282ddca2e4baae8964364abb4f4dcdde855cd123a"}, - {file = "pyrsistent-0.18.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f87cc2863ef33c709e237d4b5f4502a62a00fab450c9e020892e8e2ede5847f5"}, - {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bc66318fb7ee012071b2792024564973ecc80e9522842eb4e17743604b5e045"}, - {file = "pyrsistent-0.18.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:914474c9f1d93080338ace89cb2acee74f4f666fb0424896fcfb8d86058bf17c"}, - {file = "pyrsistent-0.18.1-cp39-cp39-win32.whl", hash = "sha256:1b34eedd6812bf4d33814fca1b66005805d3640ce53140ab8bbb1e2651b0d9bc"}, - {file = "pyrsistent-0.18.1-cp39-cp39-win_amd64.whl", hash = "sha256:e24a828f57e0c337c8d8bb9f6b12f09dfdf0273da25fda9e314f0b684b415a07"}, - {file = "pyrsistent-0.18.1.tar.gz", hash = "sha256:d4d61f8b993a7255ba714df3aca52700f8125289f84f704cf80916517c46eb96"}, -] pysftp = [ {file = "pysftp-0.2.9.tar.gz", hash = "sha256:fbf55a802e74d663673400acd92d5373c1c7ee94d765b428d9f977567ac4854a"}, ] From d6a7beb16b056f24114aec5b27ecc6b5e60497cd Mon Sep 17 00:00:00 2001 From: OpenPype Date: Wed, 30 Mar 2022 03:40:40 +0000 Subject: [PATCH 371/854] [Automated] Bump version --- CHANGELOG.md | 19 ++++++++++++------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abe9eaa3ce..f767bc71d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [3.9.2-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.2-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...HEAD) @@ -8,11 +8,16 @@ - Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951) +**πŸ†• New features** + +- Multiverse: First PR [\#2908](https://github.com/pypeclub/OpenPype/pull/2908) + **πŸš€ Enhancements** - Slack: Added configurable maximum file size of review upload to Slack [\#2945](https://github.com/pypeclub/OpenPype/pull/2945) - NewPublisher: Prepared implementation of optional pyblish plugin [\#2943](https://github.com/pypeclub/OpenPype/pull/2943) - Workfiles: Open published workfiles [\#2925](https://github.com/pypeclub/OpenPype/pull/2925) +- General: Default modules loaded dynamically [\#2923](https://github.com/pypeclub/OpenPype/pull/2923) - CI: change the version bump logic [\#2919](https://github.com/pypeclub/OpenPype/pull/2919) - Deadline: Add headless argument [\#2916](https://github.com/pypeclub/OpenPype/pull/2916) - Nuke: Add no-audio Tag [\#2911](https://github.com/pypeclub/OpenPype/pull/2911) @@ -22,16 +27,21 @@ **πŸ› Bug fixes** +- Slack: Added default for review\_upload\_limit for Slack [\#2965](https://github.com/pypeclub/OpenPype/pull/2965) +- Settings: Conditional dictionary avoid invalid logs [\#2956](https://github.com/pypeclub/OpenPype/pull/2956) - LogViewer: Don't refresh on initialization [\#2949](https://github.com/pypeclub/OpenPype/pull/2949) +- nuke: python3 compatibility issue with `iteritems` [\#2948](https://github.com/pypeclub/OpenPype/pull/2948) - General: anatomy data with correct task short key [\#2947](https://github.com/pypeclub/OpenPype/pull/2947) - SceneInventory: Fix imports in UI [\#2944](https://github.com/pypeclub/OpenPype/pull/2944) - Slack: add generic exception [\#2941](https://github.com/pypeclub/OpenPype/pull/2941) - General: Python specific vendor paths on env injection [\#2939](https://github.com/pypeclub/OpenPype/pull/2939) - General: More fail safe delete old versions [\#2936](https://github.com/pypeclub/OpenPype/pull/2936) - Settings UI: Collapsed of collapsible wrapper works as expected [\#2934](https://github.com/pypeclub/OpenPype/pull/2934) +- Maya: Do not pass `set` to maya commands \(fixes support for older maya versions\) [\#2932](https://github.com/pypeclub/OpenPype/pull/2932) - General: Don't print log record on OSError [\#2926](https://github.com/pypeclub/OpenPype/pull/2926) - Hiero: Fix import of 'register\_event\_callback' [\#2924](https://github.com/pypeclub/OpenPype/pull/2924) - Ftrack: Missing Ftrack id after editorial publish [\#2905](https://github.com/pypeclub/OpenPype/pull/2905) +- AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) **πŸ”€ Refactored code** @@ -43,7 +53,7 @@ **Merged pull requests:** -- Maya: Do not pass `set` to maya commands \(fixes support for older maya versions\) [\#2932](https://github.com/pypeclub/OpenPype/pull/2932) +- Maya - added transparency into review creator [\#2952](https://github.com/pypeclub/OpenPype/pull/2952) ## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) @@ -96,14 +106,10 @@ - Maya: add loaded containers to published instance [\#2837](https://github.com/pypeclub/OpenPype/pull/2837) - Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836) - General: Custom function for find executable [\#2822](https://github.com/pypeclub/OpenPype/pull/2822) -- General: Color dialog UI fixes [\#2817](https://github.com/pypeclub/OpenPype/pull/2817) -- global: letter box calculated on output as last process [\#2812](https://github.com/pypeclub/OpenPype/pull/2812) -- Nuke: adding Reformat to baking mov plugin [\#2811](https://github.com/pypeclub/OpenPype/pull/2811) **πŸ› Bug fixes** - General: Missing time function [\#2877](https://github.com/pypeclub/OpenPype/pull/2877) -- AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) - Deadline: Fix plugin name for tile assemble [\#2868](https://github.com/pypeclub/OpenPype/pull/2868) - Nuke: gizmo precollect fix [\#2866](https://github.com/pypeclub/OpenPype/pull/2866) - General: Fix hardlink for windows [\#2864](https://github.com/pypeclub/OpenPype/pull/2864) @@ -126,7 +132,6 @@ - Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) - Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) - Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) -- StandalonePublisher: use dynamic groups in subset names [\#2816](https://github.com/pypeclub/OpenPype/pull/2816) **πŸ”€ Refactored code** diff --git a/openpype/version.py b/openpype/version.py index 84ea02fd08..6d55672aca 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.2-nightly.2" +__version__ = "3.9.2-nightly.3" diff --git a/pyproject.toml b/pyproject.toml index 46515b4785..479cd731fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.2-nightly.2" # OpenPype +version = "3.9.2-nightly.3" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From fe11ad9868cca57c6bc9ff34e8011beaba7989f4 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Mar 2022 10:06:50 +0200 Subject: [PATCH 372/854] Remove unused website docs pages --- website/docs/api.md | 7 ------- website/docs/artist_hosts.md | 17 ----------------- website/docs/hosts-maya.md | 33 --------------------------------- 3 files changed, 57 deletions(-) delete mode 100644 website/docs/api.md delete mode 100644 website/docs/artist_hosts.md delete mode 100644 website/docs/hosts-maya.md diff --git a/website/docs/api.md b/website/docs/api.md deleted file mode 100644 index 7cad92d603..0000000000 --- a/website/docs/api.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -id: api -title: Pype API -sidebar_label: API ---- - -Work in progress diff --git a/website/docs/artist_hosts.md b/website/docs/artist_hosts.md deleted file mode 100644 index 609f6d97c8..0000000000 --- a/website/docs/artist_hosts.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -id: artist_hosts -title: Hosts -sidebar_label: Hosts ---- - -## Maya - -## Houdini - -## Nuke - -## Fusion - -## Unreal - -## System diff --git a/website/docs/hosts-maya.md b/website/docs/hosts-maya.md deleted file mode 100644 index 0ee0c2d86b..0000000000 --- a/website/docs/hosts-maya.md +++ /dev/null @@ -1,33 +0,0 @@ -### Tools -Creator -Publisher -Loader -Scene Inventory -Look assigner -Workfiles - -### Plugins -Deadline -Muster -Yeti -Arnold -Vray -Redshift - -### Families -Model -Look -Rig -Animation -Cache -Camera -Assembly -MayaAscii (generic scene) -Setdress -RenderSetup -Review -arnoldStandin -vrayProxy -vrayScene -yetiCache -yetiRig From ad7578fc7339ad2aaa885d616dc3e07a7a2df937 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Mar 2022 10:10:49 +0200 Subject: [PATCH 373/854] Remove unused `manager_naming.md` --- website/docs/manager_naming.md | 56 ---------------------------------- 1 file changed, 56 deletions(-) delete mode 100644 website/docs/manager_naming.md diff --git a/website/docs/manager_naming.md b/website/docs/manager_naming.md deleted file mode 100644 index bf822fbeb4..0000000000 --- a/website/docs/manager_naming.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -id: manager_naming -title: Naming Conventions -sidebar_label: Naming Conventions ---- - -:::note -This naming convention holds true for most of our pipeline. Please match it as close as possible even for projects and files that might be outside of pipeline scope at this point. Small errors count! The reason for given formatting is to allow people to understand the file at glance and that a script or a program can easily get meaningful information from your files without errors. -::: - -## General rules - -For more detailed rules and different file types, have a look at naming conventions for scenes and assets - -- Every file starts with file code based on a project it belongs to e.g. β€˜tst_’, β€˜drm_’ -- Optional subversion and comment always comes after the major version. v##.subversion_comment. -- File names can only be composed of letters, numbers, underscores `_` and dots β€œ.” -- You can use snakeCase or CamelCase if you need more words in a section.Β  thisIsLongerSentenceInComment -- No spaces in filenames. Ever! -- Frame numbers are always separated by a period ”.” -- If you're not sure use this template: - -## Work files - -**`{code}_{shot}_{task}_v001.ext`** - -**`{code}_{asset}_{task}_v001.ext`** - -**Examples:** - - prj_sh010_enviro_v001.ma - prj_sh010_animation_v001.ma - prj_sh010_comp_v001.nk - - prj_bob_modelling_v001.ma - prj_bob_rigging_v001.ma - prj_bob_lookdev_v001.ma - -:::info -In all of the examples anything enclosed in curly brackets Β { }Β is compulsory in the name. -Anything in square bracketsΒ [ ]Β is optional. -::: - -## Published Assets - -**`{code}_{asset}_{family}_{subset}_{version}_[comment].ext`** - -**Examples:** - - prj_bob_model_main_v01.ma - prj_bob_model_hires_v01.ma - prj_bob_model_main_v01_clothes.ma - prj_bob_model_main_v01_body.ma - prj_bob_rig_main_v01.ma - Prj_bob_look_main_v01.ma - Prj_bob_look_wet_v01.ma From 3f8f9b9008238f7220b2b101bcceedefb70cc9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 30 Mar 2022 10:47:55 +0200 Subject: [PATCH 374/854] Update openpype/hosts/flame/plugins/publish/collect_timeline_instances.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- .../hosts/flame/plugins/publish/collect_timeline_instances.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index 390c55837c..bc5c60a97d 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -98,8 +98,7 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): label = asset if asset != clip_name: label += " ({})".format(clip_name) - label += " {}".format(subset) - label += " {}".format("[" + ", ".join(families) + "]") + label += " {} [{}]".format(subset, ", ".join(families)) inst_data.update({ "name": "{}_{}".format(asset, subset), From fbe34f74a3c18901c31b5735ddbdec38093f156d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Mar 2022 10:49:53 +0200 Subject: [PATCH 375/854] flame: flip order of processing --- .../publish/collect_timeline_instances.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py index bc5c60a97d..2482abd9c7 100644 --- a/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py +++ b/openpype/hosts/flame/plugins/publish/collect_timeline_instances.py @@ -43,17 +43,6 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): # process all sellected for segment in selected_segments: - comment_attributes = self._get_comment_attributes(segment) - self.log.debug("__ segment.name: {}".format( - segment.name - )) - self.log.debug("_ comment_attributes: {}".format( - pformat(comment_attributes))) - - clip_data = opfapi.get_segment_attributes(segment) - clip_name = clip_data["segment_name"] - self.log.debug("clip_name: {}".format(clip_name)) - # get openpype tag data marker_data = opfapi.get_segment_data_marker(segment) self.log.debug("__ marker_data: {}".format( @@ -65,6 +54,19 @@ class CollectTimelineInstances(pyblish.api.ContextPlugin): if marker_data.get("id") != "pyblish.avalon.instance": continue + self.log.debug("__ segment.name: {}".format( + segment.name + )) + + comment_attributes = self._get_comment_attributes(segment) + + self.log.debug("_ comment_attributes: {}".format( + pformat(comment_attributes))) + + clip_data = opfapi.get_segment_attributes(segment) + clip_name = clip_data["segment_name"] + self.log.debug("clip_name: {}".format(clip_name)) + # get file path file_path = clip_data["fpath"] From ee96827ff225fb1dff1419074acc89103b0cdd08 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Mar 2022 10:59:01 +0200 Subject: [PATCH 376/854] flame: improving handling color policy input --- openpype/hosts/flame/api/scripts/wiretap_com.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index a85a85ae25..54993d34eb 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -422,7 +422,13 @@ class WireTapCom(object): color_policy = color_policy or "Legacy" # check if the colour policy in custom dir - if "/" not in color_policy: + if "/" in color_policy: + # if unlikelly full path was used make it redundant + color_policy = color_policy.replace("/syncolor/policies/", "") + # expecting input is `Shared/NameOfPolicy` + color_policy = "/syncolor/policies/{}".format( + color_policy) + else: color_policy = "/syncolor/policies/Autodesk/{}".format( color_policy) From 36a43d402012e0115a856f1d6058397ab29e4fd2 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 30 Mar 2022 11:11:37 +0200 Subject: [PATCH 377/854] Simplified getting host and app filter acess. Co-authored-by: Roy Nieterau --- openpype/lib/applications.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index b496bd74e2..5821c863d7 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -571,13 +571,8 @@ class EnvironmentTool: # Backwards compatibility 3.9.1 - 3.9.2 # - 'variant_data' contained only environments but contain also host # and application variant filters - host_names = [] - app_variants = [] - if "host_names" in variant_data: - host_names = variant_data["host_names"] - - if "app_variants" in variant_data: - app_variants = variant_data["app_variants"] + host_names = variant_data.get("host_names", []) + app_variants = variant_data.get("app_variants", []) if "environment" in variant_data: environment = variant_data["environment"] From a62d98235c9d316a5c878c9376b254472d37b3de Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 30 Mar 2022 11:50:05 +0200 Subject: [PATCH 378/854] force completer to be shown inactive --- openpype/tools/settings/settings/widgets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index 577c2630ab..b3518cffd9 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -97,6 +97,9 @@ class CompleterView(QtWidgets.QListView): QtCore.Qt.FramelessWindowHint | QtCore.Qt.Tool ) + + # Open the widget unactivated + self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating) delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(delegate) From 10673544a481e34ebde96dccc3480fb03ff47452 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Mar 2022 11:51:32 +0200 Subject: [PATCH 379/854] Fix broken links --- website/docs/manager_ftrack.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/manager_ftrack.md b/website/docs/manager_ftrack.md index defbb4b48f..1b1c220c18 100644 --- a/website/docs/manager_ftrack.md +++ b/website/docs/manager_ftrack.md @@ -4,7 +4,7 @@ title: Ftrack sidebar_label: Project Manager --- -Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](http://ftrack.rtd.ftrack.com/en/stable/). +Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](http://help.ftrack.com/en/articles/1040483-creating-a-new-project). ## Project management Setting project attributes is the key to properly working pipeline. @@ -31,7 +31,7 @@ This process describes how data from Ftrack will get into Avalon database. ### How to synchronize You can trigger synchronization manually using [Sync To Avalon](manager_ftrack_actions.md#sync-to-avalon) action. -Synchronization can also be automated with OpenPype's [event server](#event-server) and synchronization events. If your Ftrack is [prepared for OpenPype](#prepare-ftrack-for-openpype), the project should have custom attribute `Avalon auto-sync`. Check the custom attribute to allow auto-updates with event server. +Synchronization can also be automated with OpenPype's [event server](#event-server) and synchronization events. If your Ftrack is [prepared for OpenPype](module_ftrack.md#prepare-ftrack-for-openpype), the project should have custom attribute `Avalon auto-sync`. Check the custom attribute to allow auto-updates with event server. :::tip Always use `Sync To Avalon` action before you enable `Avalon auto-sync`! From ccf563d60ab7a11dc3dcd7ab81c45134d0a5c387 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Mar 2022 11:54:38 +0200 Subject: [PATCH 380/854] `METADATA_KEYS` constant as `frozenset` for optimal immutable lookup --- openpype/settings/constants.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py index 8b8acf5714..19ff953eb4 100644 --- a/openpype/settings/constants.py +++ b/openpype/settings/constants.py @@ -8,11 +8,11 @@ M_ENVIRONMENT_KEY = "__environment_keys__" # Metadata key for storing dynamic created labels M_DYNAMIC_KEY_LABEL = "__dynamic_keys_labels__" -METADATA_KEYS = ( +METADATA_KEYS = frozenset([ M_OVERRIDDEN_KEY, M_ENVIRONMENT_KEY, M_DYNAMIC_KEY_LABEL -) +]) # Keys where studio's system overrides are stored GLOBAL_SETTINGS_KEY = "global_settings" From df6499868bca5b0a3eea579591f309b3e78e1f59 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Mar 2022 12:22:43 +0200 Subject: [PATCH 381/854] flame: cleaning code --- openpype/hosts/flame/api/__init__.py | 4 ---- openpype/hosts/flame/api/lib.py | 17 +---------------- openpype/hosts/flame/api/scripts/wiretap_com.py | 2 +- openpype/hosts/flame/plugins/load/load_clip.py | 2 +- .../hosts/flame/plugins/load/load_clip_batch.py | 2 +- .../plugins/publish/integrate_batch_group.py | 3 +++ 6 files changed, 7 insertions(+), 23 deletions(-) diff --git a/openpype/hosts/flame/api/__init__.py b/openpype/hosts/flame/api/__init__.py index 561aaab3de..28511458c2 100644 --- a/openpype/hosts/flame/api/__init__.py +++ b/openpype/hosts/flame/api/__init__.py @@ -11,10 +11,8 @@ from .constants import ( from .lib import ( CTX, FlameAppFramework, - get_project_manager, get_current_project, get_current_sequence, - create_bin, create_segment_data_marker, get_segment_data_marker, set_segment_data_marker, @@ -87,10 +85,8 @@ __all__ = [ # lib "CTX", "FlameAppFramework", - "get_project_manager", "get_current_project", "get_current_sequence", - "create_bin", "create_segment_data_marker", "get_segment_data_marker", "set_segment_data_marker", diff --git a/openpype/hosts/flame/api/lib.py b/openpype/hosts/flame/api/lib.py index dd91252a00..7316fa1c5b 100644 --- a/openpype/hosts/flame/api/lib.py +++ b/openpype/hosts/flame/api/lib.py @@ -227,16 +227,6 @@ class FlameAppFramework(object): return True -def get_project_manager(): - # TODO: get_project_manager - return - - -def get_media_storage(): - # TODO: get_media_storage - return - - def get_current_project(): import flame return flame.project.current_project @@ -266,11 +256,6 @@ def get_current_sequence(selection): return process_timeline -def create_bin(name, root=None): - # TODO: create_bin - return - - def rescan_hooks(): import flame try: @@ -724,5 +709,5 @@ def get_batch_group_from_desktop(name): project_desktop = project.current_workspace.desktop for bgroup in project_desktop.batch_groups: - if bgroup.name.get_value() == name: + if bgroup.name.get_value() in name: return bgroup diff --git a/openpype/hosts/flame/api/scripts/wiretap_com.py b/openpype/hosts/flame/api/scripts/wiretap_com.py index 54993d34eb..14fbcec954 100644 --- a/openpype/hosts/flame/api/scripts/wiretap_com.py +++ b/openpype/hosts/flame/api/scripts/wiretap_com.py @@ -254,7 +254,7 @@ class WireTapCom(object): filtered_users = [user for user in used_names if user_name in user] if filtered_users: - # todo: need to find lastly created following regex pattern for + # TODO: need to find lastly created following regex pattern for # date used in name return filtered_users.pop() diff --git a/openpype/hosts/flame/plugins/load/load_clip.py b/openpype/hosts/flame/plugins/load/load_clip.py index b27600db1f..e0a7297381 100644 --- a/openpype/hosts/flame/plugins/load/load_clip.py +++ b/openpype/hosts/flame/plugins/load/load_clip.py @@ -39,7 +39,7 @@ class LoadClip(opfapi.ClipLoader): clip_name = self.clip_name_template.format( **context["representation"]["context"]) - # todo: settings in imageio + # TODO: settings in imageio # convert colorspace with ocio to flame mapping # in imageio flame section colorspace = colorspace diff --git a/openpype/hosts/flame/plugins/load/load_clip_batch.py b/openpype/hosts/flame/plugins/load/load_clip_batch.py index 252c92516d..3c13d88d3a 100644 --- a/openpype/hosts/flame/plugins/load/load_clip_batch.py +++ b/openpype/hosts/flame/plugins/load/load_clip_batch.py @@ -42,7 +42,7 @@ class LoadClipBatch(opfapi.ClipLoader): clip_name = self.clip_name_template.format( **context["representation"]["context"]) - # todo: settings in imageio + # TODO: settings in imageio # convert colorspace with ocio to flame mapping # in imageio flame section colorspace = colorspace diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 7c61ed62b5..253a1d6192 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -166,8 +166,11 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): self.log.info( "Updating batch group: {}".format(batchgroup_name)) # update already created batch group + bgroup.name = batchgroup_name bgroup.start_frame = frame_start bgroup.duration = frame_duration + # TODO: also update write node if there is any + # TODO: also update loaders to start from correct frameStart return bgroup From 5580ef083bd51bba96e11e1d68156d9dbedc4809 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Mar 2022 12:24:12 +0200 Subject: [PATCH 382/854] hound catch --- openpype/hosts/flame/plugins/publish/integrate_batch_group.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 253a1d6192..4dd6081170 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -218,7 +218,8 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): # /path/to/file.[0001-0010].exr media_path = render_dir_path # name of file represented by tokens - media_path_pattern = "_v/_v." + media_path_pattern = ( + "_v/_v.") # The Create Open Clip attribute of the Write File node. \ # Determines if an Open Clip is created by the Write File node. create_clip = True From ee885051d915a20fe5e004a27c40a246f1e156de Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 30 Mar 2022 12:35:27 +0200 Subject: [PATCH 383/854] Added compute_resource_sync_sites to sync_server_module This method will be used in integrate_new to logically separate Site Sync parts. --- .../modules/sync_server/sync_server_module.py | 107 +++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index caf58503f1..7126c17e17 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -157,7 +157,6 @@ class SyncServerModule(OpenPypeModule, ITrayModule): representation_id, site_name=site_name, force=force) - # public facing API def remove_site(self, collection, representation_id, site_name, remove_local_files=False): """ @@ -184,6 +183,112 @@ class SyncServerModule(OpenPypeModule, ITrayModule): if remove_local_files: self._remove_local_file(collection, representation_id, site_name) + def compute_resource_sync_sites(self, project_name): + """Get available resource sync sites state for publish process. + + Returns dict with prepared state of sync sites for 'project_name'. + It checks if Site Sync is enabled, handles alternative sites. + Publish process stores this dictionary as a part of representation + document in DB. + + Example: + [ + { + 'name': '42abbc09-d62a-44a4-815c-a12cd679d2d7', + 'created_dt': datetime.datetime(2022, 3, 30, 12, 16, 9, 778637) + }, + {'name': 'studio'}, + {'name': 'SFTP'} + ] -- representation is published locally, artist or Settings have set + remote site as 'studio'. 'SFTP' is alternate site to 'studio'. Eg. + whenever file is on 'studio', it is also on 'SFTP'. + """ + + def create_metadata(name, created=True): + """Create sync site metadata for site with `name`""" + metadata = {"name": name} + if created: + metadata["created_dt"] = datetime.now() + return metadata + + if ( + not self.sync_system_settings["enabled"] or + not self.sync_project_settings[project_name]["enabled"]): + return [create_metadata(self.DEFAULT_SITE)] + + local_site = self.get_active_site(project_name) + remote_site = self.get_remote_site(project_name) + + # Attached sites metadata by site name + # That is the local site, remote site, the always accesible sites + # and their alternate sites (alias of sites with different protocol) + attached_sites = dict() + attached_sites[local_site] = create_metadata(local_site) + + if remote_site and remote_site not in attached_sites: + attached_sites[remote_site] = create_metadata(remote_site, + created=False) + + # add skeleton for sites where it should be always synced to + # usually it would be a backup site which is handled by separate + # background process + for site in self._get_always_accessible_sites(project_name): + if site not in attached_sites: + attached_sites[site] = create_metadata(site, created=False) + + attached_sites = self._add_alternative_sites(attached_sites) + + return list(attached_sites.values()) + + def _get_always_accessible_sites(self, project_name): + """Sites that synced to as a part of background process. + + Artist machine doesn't handle those, explicit Tray with that site name + as a local id must be running. + Example is dropbox site serving as a backup solution + """ + always_accessible_sites = ( + self.get_sync_project_setting(project_name)["config"]. + get("always_accessible_on", []) + ) + return [site.strip() for site in always_accessible_sites] + + def _add_alternative_sites(self, attached_sites): + """Add skeleton document for alternative sites + + Each new configured site in System Setting could serve as a alternative + site, it's a kind of alias. It means that files on 'a site' are + physically accessible also on 'a alternative' site. + Example is sftp site serving studio files via sftp protocol, physically + file is only in studio, sftp server has this location mounted. + """ + additional_sites = self.sync_system_settings.get("sites", {}) + + for site_name, site_info in additional_sites.items(): + # Get alternate sites (stripped names) for this site name + alt_sites = site_info.get("alternative_sites", []) + alt_sites = [site.strip() for site in alt_sites] + alt_sites = set(alt_sites) + + # If no alternative sites we don't need to add + if not alt_sites: + continue + + # Take a copy of data of the first alternate site that is already + # defined as an attached site to match the same state. + match_meta = next((attached_sites[site] for site in alt_sites + if site in attached_sites), None) + if not match_meta: + continue + + alt_site_meta = copy.deepcopy(match_meta) + alt_site_meta["name"] = site_name + + # Note: We change mutable `attached_site` dict in-place + attached_sites[site_name] = alt_site_meta + + return attached_sites + def clear_project(self, collection, site_name): """ Clear 'collection' of 'site_name' and its local files From a6a98ae104c3747bec65ebd19c2d81110021acc1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 30 Mar 2022 13:27:59 +0200 Subject: [PATCH 384/854] Switched subset function according to review comments --- .../hosts/photoshop/plugins/publish/collect_review.py | 8 ++++---- .../hosts/photoshop/plugins/publish/collect_workfile.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 8b7508cf5b..f3842b9ee5 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -2,7 +2,7 @@ import os import pyblish.api -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectReview(pyblish.api.ContextPlugin): @@ -14,13 +14,13 @@ class CollectReview(pyblish.api.ContextPlugin): def process(self, context): family = "review" - subset = get_subset_name( + subset = get_subset_name_with_asset_doc( family, "", context.data["anatomyData"]["task"]["name"], - context.data["assetEntity"]["_id"], + context.data["assetEntity"], context.data["anatomyData"]["project"]["name"], - host_name="photoshop" + host_name=context.data["hostName"] ) file_path = context.data["currentFile"] diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index ca1124fd24..0dbe2c6609 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,7 +1,7 @@ import os import pyblish.api -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectWorkfile(pyblish.api.ContextPlugin): @@ -13,13 +13,13 @@ class CollectWorkfile(pyblish.api.ContextPlugin): def process(self, context): family = "workfile" - subset = get_subset_name( + subset = get_subset_name_with_asset_doc( family, "", context.data["anatomyData"]["task"]["name"], - context.data["assetEntity"]["_id"], + context.data["assetEntity"], context.data["anatomyData"]["project"]["name"], - host_name="photoshop" + host_name=context.data["hostName"] ) file_path = context.data["currentFile"] From 9e4e6d4b85a1273d1eeab0f473c88cb8b7f62f30 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 30 Mar 2022 13:30:53 +0200 Subject: [PATCH 385/854] OP-2766 Switched subset function according to review comments --- .../hosts/photoshop/plugins/publish/collect_review.py | 8 ++++---- .../hosts/photoshop/plugins/publish/collect_workfile.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_review.py b/openpype/hosts/photoshop/plugins/publish/collect_review.py index 09fed2df78..d825950b9e 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_review.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_review.py @@ -10,7 +10,7 @@ import os import pyblish.api -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectReview(pyblish.api.ContextPlugin): @@ -27,13 +27,13 @@ class CollectReview(pyblish.api.ContextPlugin): def process(self, context): family = "review" - subset = get_subset_name( + subset = get_subset_name_with_asset_doc( family, "", context.data["anatomyData"]["task"]["name"], - context.data["assetEntity"]["_id"], + context.data["assetEntity"], context.data["anatomyData"]["project"]["name"], - host_name="photoshop" + host_name=context.data["hostName"] ) instance = context.create_instance(subset) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py index 71022a86fd..e4f0a07b34 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_workfile.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_workfile.py @@ -1,7 +1,7 @@ import os import pyblish.api -from openpype.lib import get_subset_name +from openpype.lib import get_subset_name_with_asset_doc class CollectWorkfile(pyblish.api.ContextPlugin): @@ -20,13 +20,13 @@ class CollectWorkfile(pyblish.api.ContextPlugin): break family = "workfile" - subset = get_subset_name( + subset = get_subset_name_with_asset_doc( family, "", context.data["anatomyData"]["task"]["name"], - context.data["assetEntity"]["_id"], + context.data["assetEntity"], context.data["anatomyData"]["project"]["name"], - host_name="photoshop" + host_name=context.data["hostName"] ) file_path = context.data["currentFile"] From 57ba944ad3759136d000e7d946a6c438e56d6faa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Mar 2022 14:10:09 +0200 Subject: [PATCH 386/854] nuke: settings for read raw reordering ui attributes for better logic --- .../defaults/project_settings/nuke.json | 7 +++-- .../schemas/schema_nuke_publish.json | 29 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 6992fb6e3e..2fb6a372e4 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -119,11 +119,10 @@ "families": [], "sebsets": [] }, - "extension": "mov", + "read_raw": false, "viewer_process_override": "", "bake_viewer_process": true, "bake_viewer_input_process": true, - "add_tags": [], "reformat_node_add": false, "reformat_node_config": [ { @@ -151,7 +150,9 @@ "name": "pbb", "value": false } - ] + ], + "extension": "mov", + "add_tags": [] } } }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 1636a8d700..673e12d54b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -208,9 +208,10 @@ "type": "separator" }, { - "type": "text", - "key": "extension", - "label": "File extension" + "type": "boolean", + "key": "read_raw", + "label": "Read colorspace RAW", + "default": false }, { "type": "text", @@ -227,12 +228,6 @@ "key": "bake_viewer_input_process", "label": "Bake Viewer Input Process (LUTs)" }, - { - "key": "add_tags", - "label": "Add additional tags to representations", - "type": "list", - "object_type": "text" - }, { "type": "separator" }, @@ -246,7 +241,7 @@ "type": "collapsible-wrap", "label": "Reformat Node Knobs", "collapsible": true, - "collapsed": false, + "collapsed": true, "children": [ { "type": "list", @@ -347,6 +342,20 @@ } } ] + }, + { + "type": "separator" + }, + { + "type": "text", + "key": "extension", + "label": "Write node file type" + }, + { + "key": "add_tags", + "label": "Add additional tags to representations", + "type": "list", + "object_type": "text" } ] } From 65d93ca9f109ae9dad81d2c9058dee423feda6fb Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Wed, 30 Mar 2022 14:15:22 +0200 Subject: [PATCH 387/854] nuke: adding read raw baking preset attr --- openpype/hosts/nuke/api/plugin.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/openpype/hosts/nuke/api/plugin.py b/openpype/hosts/nuke/api/plugin.py index d0bb45a05d..3ac750a48f 100644 --- a/openpype/hosts/nuke/api/plugin.py +++ b/openpype/hosts/nuke/api/plugin.py @@ -450,6 +450,7 @@ class ExporterReviewMov(ExporterReview): def generate_mov(self, farm=False, **kwargs): self.publish_on_farm = farm + read_raw = kwargs["read_raw"] reformat_node_add = kwargs["reformat_node_add"] reformat_node_config = kwargs["reformat_node_config"] bake_viewer_process = kwargs["bake_viewer_process"] @@ -484,6 +485,9 @@ class ExporterReviewMov(ExporterReview): r_node["origlast"].setValue(self.last_frame) r_node["colorspace"].setValue(self.write_colorspace) + if read_raw: + r_node["raw"].setValue(1) + # connect self._temp_nodes[subset].append(r_node) self.previous_node = r_node From 215da09d3dcf92138eeddfc97bf48a9fd5728729 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 30 Mar 2022 15:24:23 +0200 Subject: [PATCH 388/854] Change default value of force copy to True --- openpype/hosts/maya/plugins/create/create_look.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/maya/plugins/create/create_look.py b/openpype/hosts/maya/plugins/create/create_look.py index 56e2640919..d9521d1860 100644 --- a/openpype/hosts/maya/plugins/create/create_look.py +++ b/openpype/hosts/maya/plugins/create/create_look.py @@ -22,4 +22,4 @@ class CreateLook(plugin.Creator): self.data["maketx"] = self.make_tx # Enable users to force a copy. - self.data["forceCopy"] = False + self.data["forceCopy"] = True From 62e98546e1826bb96bac90c365bb92ffadfd1d43 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Mar 2022 16:03:16 +0200 Subject: [PATCH 389/854] Update website/docs/manager_ftrack.md - add https to url Co-authored-by: Petr Kalis --- website/docs/manager_ftrack.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/manager_ftrack.md b/website/docs/manager_ftrack.md index 1b1c220c18..730c57d1f9 100644 --- a/website/docs/manager_ftrack.md +++ b/website/docs/manager_ftrack.md @@ -4,7 +4,7 @@ title: Ftrack sidebar_label: Project Manager --- -Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](http://help.ftrack.com/en/articles/1040483-creating-a-new-project). +Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](https://help.ftrack.com/en/articles/1040483-creating-a-new-project). ## Project management Setting project attributes is the key to properly working pipeline. From 018a48b1fbc0f8f1a2600ea0976744d9c659046a Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 30 Mar 2022 16:20:21 +0200 Subject: [PATCH 390/854] OP-2011 - cleanup of unnecessary long query from Setting --- .../plugins/publish/submit_maya_deadline.py | 35 ++++--------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 15a6f8d828..31a7c2f176 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -255,6 +255,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): tile_assembler_plugin = "OpenPypeTileAssembler" asset_dependencies = False limit_groups = [] + jobInfo = None + pluginInfo = None group = "none" def process(self, instance): @@ -272,37 +274,12 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): self.deadline_url = instance.data.get("deadlineUrl") assert self.deadline_url, "Requires Deadline Webservice URL" - self._job_info = ( - context.data["project_settings"].get( - "deadline", {}).get( - "publish", {}).get( - "MayaSubmitDeadline", {}).get( - "jobInfo", {}) - ) + # just using existing names from Setting + self._job_info = self.jobInfo - self._plugin_info = ( - context.data["project_settings"].get( - "deadline", {}).get( - "publish", {}).get( - "MayaSubmitDeadline", {}).get( - "pluginInfo", {}) - ) + self._plugin_info = self.pluginInfo - self.limit_groups = ( - context.data["project_settings"].get( - "deadline", {}).get( - "publish", {}).get( - "MayaSubmitDeadline", {}).get( - "limit", []) - ) - - self.group = ( - context.data["project_settings"].get( - "deadline", {}).get( - "publish", {}).get( - "MayaSubmitDeadline", {}).get( - "group", "none") - ) + self.limit_groups = self.limit context = instance.context workspace = context.data["workspaceDir"] From 8eab6ae180fe308e03bc4d3df7cd53bd030b59ce Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 30 Mar 2022 16:25:37 +0200 Subject: [PATCH 391/854] disable hardlink on windows for look publishing --- openpype/hosts/maya/plugins/create/create_look.py | 4 +++- openpype/hosts/maya/plugins/publish/extract_look.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_look.py b/openpype/hosts/maya/plugins/create/create_look.py index d9521d1860..44e439fe1f 100644 --- a/openpype/hosts/maya/plugins/create/create_look.py +++ b/openpype/hosts/maya/plugins/create/create_look.py @@ -22,4 +22,6 @@ class CreateLook(plugin.Creator): self.data["maketx"] = self.make_tx # Enable users to force a copy. - self.data["forceCopy"] = True + # - on Windows is "forceCopy" always changed to `True` because of + # windows implementation of hardlinks + self.data["forceCopy"] = False diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index a8893072d0..137683ca6d 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 @@ -334,7 +335,11 @@ class ExtractLook(openpype.api.Extractor): transfers = [] hardlinks = [] hashes = {} - force_copy = instance.data.get("forceCopy", False) + # Temporary fix to NOT create hardlinks on windows machines + if platform.system().lower() == "windows": + force_copy = True + else: + force_copy = instance.data.get("forceCopy", False) for filepath in files_metadata: From 58085d3b4ed15ac8506b2a86ae402e68be176f51 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 30 Mar 2022 16:31:41 +0200 Subject: [PATCH 392/854] removed unused function --- openpype/modules/ftrack/lib/avalon_sync.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index 5301ec568e..c5b58ca94d 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -286,21 +286,6 @@ def from_dict_to_set(data, is_project): return result -def get_avalon_project_template(project_name): - """Get avalon template - Args: - project_name: (string) - Returns: - dictionary with templates - """ - templates = Anatomy(project_name).templates - return { - "workfile": templates["avalon"]["workfile"], - "work": templates["avalon"]["work"], - "publish": templates["avalon"]["publish"] - } - - def get_project_apps(in_app_list): """ Application definitions for app name. From 8940830787fc68d043febecd26040d944e5d91d9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 30 Mar 2022 16:38:45 +0200 Subject: [PATCH 393/854] added info log about changing hardlink --- openpype/hosts/maya/plugins/publish/extract_look.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 137683ca6d..6fcc308f78 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -337,6 +337,9 @@ class ExtractLook(openpype.api.Extractor): hashes = {} # Temporary fix to NOT create hardlinks on windows machines if platform.system().lower() == "windows": + self.log.info( + "Forcing copy instead of hardlink due to issues on Windows..." + ) force_copy = True else: force_copy = instance.data.get("forceCopy", False) From 053748ac2319468a4071db41e3818d4923e105c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Wed, 30 Mar 2022 18:54:05 +0200 Subject: [PATCH 394/854] handling validation and hero versions --- .../publish/collect_original_basename.py | 18 ++++++++++++++++++ .../help/validate_simple_texture_naming.xml | 17 +++++++++++++++++ .../validate_simple_unreal_texture_naming.py | 10 +++++++++- .../plugins/publish/integrate_hero_version.py | 5 +++++ openpype/plugins/publish/integrate_new.py | 13 +++++++++++++ .../defaults/project_anatomy/templates.json | 10 ++++++++-- .../defaults/project_settings/global.json | 17 +++++++++++++++-- 7 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/collect_original_basename.py create mode 100644 openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_original_basename.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_original_basename.py new file mode 100644 index 0000000000..b83a924d33 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_original_basename.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""Collect original base name for use in templates.""" +from pathlib import Path + +import pyblish.api + + +class CollectOriginalBasename(pyblish.api.InstancePlugin): + """Collect original file base name.""" + + order = pyblish.api.CollectorOrder + 0.498 + label = "Collect Base Name" + hosts = ["standalonepublisher"] + families = ["simpleUnrealTexture"] + + def process(self, instance): + file_name = Path(instance.data["representations"][0]["files"]) + instance.data["originalBasename"] = file_name.stem diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml new file mode 100644 index 0000000000..1818748407 --- /dev/null +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml @@ -0,0 +1,17 @@ + + + +Invalid texture name + +## Source files not found + +Submitted file has invalid name: +'{invalid_file}' + +### How to repair? + + Texture file must adhere to naming conventions for Unreal: + T_[ASSET_NAME}_*.ext + + + \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py index 158a749075..05f38159c1 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py @@ -2,6 +2,7 @@ """Validator for correct file naming.""" import pyblish.api import openpype.api +import re from openpype.pipeline import PublishXmlValidationError @@ -10,6 +11,13 @@ class ValidateSimpleUnrealTextureNaming(pyblish.api.InstancePlugin): hosts = ["standalonepublisher"] families = ["simpleUnrealTexture"] order = openpype.api.ValidateContentsOrder + regex = "^T_{asset}.*" def process(self, instance): - ... \ No newline at end of file + file_name = instance.data.get("originalBasename") + self.log.info(file_name) + pattern = self.regex.format(asset=instance.data.get("asset")) + if not re.match(pattern, file_name): + msg = f"Invalid file name {file_name}" + raise PublishXmlValidationError( + self, msg, formatting_data={"invalid_file": file_name}) diff --git a/openpype/plugins/publish/integrate_hero_version.py b/openpype/plugins/publish/integrate_hero_version.py index d6df6535d8..ded149bdd0 100644 --- a/openpype/plugins/publish/integrate_hero_version.py +++ b/openpype/plugins/publish/integrate_hero_version.py @@ -485,6 +485,11 @@ class IntegrateHeroVersion(pyblish.api.InstancePlugin): anatomy = instance.context.data["anatomy"] template_data = copy.deepcopy(instance.data["anatomyData"]) + if "originalBasename" in instance.data: + template_data.update({ + "originalBasename": instance.data.get("originalBasename") + }) + if "folder" in anatomy.templates[template_key]: anatomy_filled = anatomy.format(template_data) publish_folder = anatomy_filled[template_key]["folder"] diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 4025f18cb2..a3b9f0ef4a 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -356,6 +356,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if profile: template_name = profile["template_name"] + + published_representations = {} for idx, repre in enumerate(instance.data["representations"]): # reset transfers for next representation @@ -384,6 +386,11 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): if resolution_width: template_data["fps"] = fps + if "originalBasename" in instance.data: + template_data.update({ + "originalBasename": instance.data.get("originalBasename") + }) + files = repre['files'] if repre.get('stagingDir'): stagingdir = repre['stagingDir'] @@ -555,6 +562,12 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): repre['published_path'] = dst self.log.debug("__ dst: {}".format(dst)) + if not instance.data.get("publishDir"): + instance.data["publishDir"] = ( + anatomy_filled + [template_name] + ["folder"] + ) if repre.get("udim"): repre_context["udim"] = repre.get("udim") # store list diff --git a/openpype/settings/defaults/project_anatomy/templates.json b/openpype/settings/defaults/project_anatomy/templates.json index 45611f55b1..4be923da11 100644 --- a/openpype/settings/defaults/project_anatomy/templates.json +++ b/openpype/settings/defaults/project_anatomy/templates.json @@ -33,12 +33,18 @@ "path": "{@folder}/{@file}" }, "others": { + "simpleUnrealTextureHero": { + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/hero", + "file": "{originalBasename}.{ext}", + "path": "{@folder}/{@file}" + }, "simpleUnrealTexture": { - "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}", - "file": "{original_file}", + "folder": "{root[work]}/{project[name]}/{hierarchy}/{asset}/publish/{family}/{@version}", + "file": "{originalBasename}_{@version}.{ext}", "path": "{@folder}/{@file}" }, "__dynamic_keys_labels__": { + "simpleUnrealTextureHero": "Simple Unreal Texture - Hero", "simpleUnrealTexture": "Simple Unreal Texture" } } diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 0bfd571a79..5443293c93 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -213,9 +213,22 @@ "animation", "setdress", "layout", - "mayaScene" + "mayaScene", + "simpleUnrealTexture" ], - "template_name_profiles": [] + "template_name_profiles": [ + { + "families": [ + "simpleUnrealTexture" + ], + "hosts": [ + "standalonepublisher" + ], + "task_types": [], + "task_names": [], + "template_name": "simpleUnrealTextureHero" + } + ] }, "CleanUp": { "paterns": [], From 2520ceca630d5985a9646e0002aaea352445cfd0 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 30 Mar 2022 22:12:00 +0200 Subject: [PATCH 395/854] Fix #2946: Avoid ImportError on `hdefereval` when Houdini runs without UI --- openpype/hosts/houdini/api/pipeline.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index d079c9ea81..31c82b1cfd 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -4,7 +4,6 @@ import logging import contextlib import hou -import hdefereval import pyblish.api import avalon.api @@ -305,7 +304,13 @@ def on_new(): start = hou.playbar.playbackRange()[0] hou.setFrame(start) - hdefereval.executeDeferred(_enforce_start_frame) + if hou.isUIAvailable(): + import hdefereval + hdefereval.executeDeferred(_enforce_start_frame) + else: + # Run without execute deferred when no UI is available because + # without UI `hdefereval` is not available to import + _enforce_start_frame() def _set_context_settings(): From 34f5f7d60c2692d99448d535ba0c4a688b844b59 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 31 Mar 2022 10:13:13 +0200 Subject: [PATCH 396/854] fix validator message --- .../plugins/publish/help/validate_simple_texture_naming.xml | 4 ++-- .../plugins/publish/validate_simple_unreal_texture_naming.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml index 1818748407..b65d274fe5 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml +++ b/openpype/hosts/standalonepublisher/plugins/publish/help/validate_simple_texture_naming.xml @@ -3,7 +3,7 @@ Invalid texture name -## Source files not found +## Invalid file name Submitted file has invalid name: '{invalid_file}' @@ -11,7 +11,7 @@ Submitted file has invalid name: ### How to repair? Texture file must adhere to naming conventions for Unreal: - T_[ASSET_NAME}_*.ext + T_{asset}_*.ext \ No newline at end of file diff --git a/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py b/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py index 05f38159c1..ef8da9f280 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/validate_simple_unreal_texture_naming.py @@ -20,4 +20,7 @@ class ValidateSimpleUnrealTextureNaming(pyblish.api.InstancePlugin): if not re.match(pattern, file_name): msg = f"Invalid file name {file_name}" raise PublishXmlValidationError( - self, msg, formatting_data={"invalid_file": file_name}) + self, msg, formatting_data={ + "invalid_file": file_name, + "asset": instance.data.get("asset") + }) From 1f1b15e2fcf07b117c34d6e429e7d92677d8dd66 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 11:03:42 +0200 Subject: [PATCH 397/854] hide completer using timer --- openpype/tools/settings/settings/widgets.py | 26 ++++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index b3518cffd9..6db001f2f6 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -228,10 +228,18 @@ class SettingsLineEdit(PlaceholderLineEdit): def __init__(self, *args, **kwargs): super(SettingsLineEdit, self).__init__(*args, **kwargs) - self._completer = None + # Timer which will get started on focus in and stopped on focus out + # - callback checks if line edit or completer have focus + # and hide completer if not + focus_timer = QtCore.QTimer() + focus_timer.setInterval(50) + focus_timer.timeout.connect(self._on_focus_timer) self.textChanged.connect(self._on_text_change) + self._completer = None + self._focus_timer = focus_timer + def _on_text_change(self, text): if self._completer is not None: self._completer.set_text_filter(text) @@ -243,19 +251,19 @@ class SettingsLineEdit(PlaceholderLineEdit): new_point = self.mapToGlobal(point) self._completer.move(new_point) + def _on_focus_timer(self): + if not self.hasFocus() and not self._completer.hasFocus(): + self._completer.hide() + self._focus_timer.stop() + def focusInEvent(self, event): super(SettingsLineEdit, self).focusInEvent(event) self.focused_in.emit() - if self._completer is None: - return - self._completer.show() - self._update_completer() - - def focusOutEvent(self, event): - super(SettingsLineEdit, self).focusOutEvent(event) if self._completer is not None: - self._completer.hide() + self._focus_timer.start() + self._completer.show() + self._update_completer() def paintEvent(self, event): super(SettingsLineEdit, self).paintEvent(event) From 4891f336ed50ec51ab8d3736c5af483f08334acc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 11:09:57 +0200 Subject: [PATCH 398/854] Fix - handle better exception when no file opened in AE Occured when no scene was opened and Workfile wanted to open existing workfile --- openpype/hosts/aftereffects/api/workio.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/aftereffects/api/workio.py b/openpype/hosts/aftereffects/api/workio.py index 5a8f86ead5..70815bda6b 100644 --- a/openpype/hosts/aftereffects/api/workio.py +++ b/openpype/hosts/aftereffects/api/workio.py @@ -5,14 +5,6 @@ from openpype.pipeline import HOST_WORKFILE_EXTENSIONS from .launch_logic import get_stub -def _active_document(): - document_name = get_stub().get_active_document_name() - if not document_name: - return None - - return document_name - - def file_extensions(): return HOST_WORKFILE_EXTENSIONS["aftereffects"] @@ -39,7 +31,8 @@ def current_file(): full_name = get_stub().get_active_document_full_name() if full_name and full_name != "null": return os.path.normpath(full_name).replace("\\", "/") - except Exception: + except ValueError: + print("Nothing opened") pass return None @@ -47,3 +40,15 @@ def current_file(): def work_root(session): return os.path.normpath(session["AVALON_WORKDIR"]).replace("\\", "/") + + +def _active_document(): + # TODO merge with current_file - even in extension + document_name = None + try: + document_name = get_stub().get_active_document_name() + except ValueError: + print("Nothing opened") + pass + + return document_name \ No newline at end of file From d6b423e2082f0cc3e0cff7a4b3d8730ca44533d4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 31 Mar 2022 11:15:34 +0200 Subject: [PATCH 399/854] Change Pype to OpenPype MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: OndΕ™ej Samohel <33513211+antirotor@users.noreply.github.com> --- openpype/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 2820091bcc..7fc7e63e61 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -24,7 +24,7 @@ LOAD_PATH = os.path.join(PLUGINS_DIR, "load") def install(): - """Install Pype to Avalon.""" + """Install OpenPype to Avalon.""" import avalon.api import pyblish.api from pyblish.lib import MessageHandler From 1499fecc73af170f095b1bdd3bd244e63413a54d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 11:18:32 +0200 Subject: [PATCH 400/854] fix thumbnail discovery --- openpype/pipeline/thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py index 47452b21e7..c09dab70eb 100644 --- a/openpype/pipeline/thumbnail.py +++ b/openpype/pipeline/thumbnail.py @@ -131,7 +131,7 @@ class BinaryThumbnail(ThumbnailResolver): # Thumbnail resolvers def discover_thumbnail_resolvers(): - return discover(ThumbnailResolver).plugins + return discover(ThumbnailResolver) def register_thumbnail_resolver(plugin): From f6fb60bb49bed7a0c26825ef85b5d0f65c4aa6bb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 11:53:58 +0200 Subject: [PATCH 401/854] Update openpype/plugins/load/add_site.py Co-authored-by: Roy Nieterau --- openpype/plugins/load/add_site.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py index 59720eb5b6..e26ef586e0 100644 --- a/openpype/plugins/load/add_site.py +++ b/openpype/plugins/load/add_site.py @@ -38,23 +38,20 @@ class AddSyncSite(load.LoaderPlugin): family = context["representation"]["context"]["family"] project_name = data["project_name"] repre_id = data["_id"] + self.add_site_to_representation(project_name, + repre_id, + data["site_name"], + is_main=True) - add_ids = [repre_id] if family == "workfile": links = get_linked_ids_for_representations(project_name, add_ids, link_type="reference") - add_ids.extend(links) - - add_ids = set(add_ids) - self.log.info("Add to repre_ids {}".format(add_ids)) - is_main = True - for add_repre_id in add_ids: - self.add_site_to_representation(project_name, - add_repre_id, - data["site_name"], - is_main) - is_main = False + for link_repre_id in links: + self.add_site_to_representation(project_name, + link_repre_id, + data["site_name"], + is_main=False) self.log.debug("Site added.") From 6b6c466d8b6ca5b587c8ccf1a8e1dac5e9326bfe Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 12:00:48 +0200 Subject: [PATCH 402/854] OP-2951 - fix wrong variable --- openpype/plugins/load/add_site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py index e26ef586e0..22d3ebf24b 100644 --- a/openpype/plugins/load/add_site.py +++ b/openpype/plugins/load/add_site.py @@ -45,7 +45,7 @@ class AddSyncSite(load.LoaderPlugin): if family == "workfile": links = get_linked_ids_for_representations(project_name, - add_ids, + [repre_id], link_type="reference") for link_repre_id in links: self.add_site_to_representation(project_name, From f486cef9251f18dcdfb390e8cfbaa840c6cda461 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Thu, 31 Mar 2022 12:03:48 +0200 Subject: [PATCH 403/854] Update manager_ftrack.md Update Ftrack Documentation URL --- website/docs/manager_ftrack.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/manager_ftrack.md b/website/docs/manager_ftrack.md index 730c57d1f9..b5ca167838 100644 --- a/website/docs/manager_ftrack.md +++ b/website/docs/manager_ftrack.md @@ -4,7 +4,7 @@ title: Ftrack sidebar_label: Project Manager --- -Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](https://help.ftrack.com/en/articles/1040483-creating-a-new-project). +Ftrack is currently the main project management option for OpenPype. This documentation assumes that you are familiar with Ftrack and it's basic principles. If you're new to Ftrack, we recommend having a thorough look at [Ftrack Official Documentation](https://help.ftrack.com/en/). ## Project management Setting project attributes is the key to properly working pipeline. From af079897a884538a5af90bbf2301a004fef7233c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 12:07:43 +0200 Subject: [PATCH 404/854] OP-2951 - refactor use better function --- openpype/lib/avalon_context.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index e8a365ec39..496b55a6f2 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -2040,14 +2040,14 @@ def get_linked_ids_for_representations(project, repre_ids, dbcon=None, referenced_version_ids = _process_referenced_pipeline_result(result, link_type) - representations = avalon.io.find( - { + ref_ids = avalon.io.distinct( + "_id", + filter={ "parent": {"$in": list(referenced_version_ids)}, "type": "representation" - }, - projection={"_id": True} + } ) - ref_ids = {representation["_id"] for representation in representations} + return list(ref_ids) From eac1394be8b1644468f8b6427c5abe37bedd3f97 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 12:12:56 +0200 Subject: [PATCH 405/854] hide published checkbox if save is not enabled --- openpype/tools/workfiles/files_widget.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 1faafe2bdb..1f93d15e22 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -305,6 +305,9 @@ class FilesWidget(QtWidgets.QWidget): def set_save_enabled(self, enabled): self._btn_save.setEnabled(enabled) + if not enabled and self._published_checkbox.isChecked(): + self._published_checkbox.setChecked(False) + self._published_checkbox.setVisible(enabled) def set_asset_task(self, asset_id, task_name, task_type): if asset_id != self._asset_id: From b826cfac4115f51d8387daab30e3475445256e0f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 12:14:17 +0200 Subject: [PATCH 406/854] OP-2951 - change sort by depth Previous sorting by _id might not be deterministic, not reliable. The main logic is to have outputs sorted by how they were traversed, which should be denoted by 'depth' field. --- 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 496b55a6f2..9a5d382c98 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -2067,7 +2067,7 @@ def _process_referenced_pipeline_result(result, link_type): # outputs_recursive in random order, sort by _id outputs_recursive = sorted(item.get("outputs_recursive", []), - key=lambda d: d["_id"]) + key=lambda d: d["depth"]) # go from oldest to newest # only older _id can reference another newer _id for output in outputs_recursive[::-1]: From 8e382b9c52feb0bcc45c235a5121088552e7d01c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 12:17:19 +0200 Subject: [PATCH 407/854] removed View option from published files --- openpype/tools/workfiles/files_widget.py | 46 +--- openpype/tools/workfiles/lib.py | 272 ----------------------- openpype/tools/workfiles/window.py | 65 ++---- 3 files changed, 27 insertions(+), 356 deletions(-) delete mode 100644 openpype/tools/workfiles/lib.py diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 1f93d15e22..80a94cc1bd 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -26,7 +26,6 @@ from .model import ( DATE_MODIFIED_ROLE, ) from .save_as_dialog import SaveAsDialog -from .lib import TempPublishFiles log = logging.getLogger(__name__) @@ -49,7 +48,6 @@ class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" file_selected = QtCore.Signal(str) file_opened = QtCore.Signal() - publish_file_viewed = QtCore.Signal() workfile_created = QtCore.Signal(str) published_visible_changed = QtCore.Signal(bool) @@ -71,9 +69,6 @@ class FilesWidget(QtWidgets.QWidget): self._workfiles_root = None self._workdir_path = None self.host = api.registered_host() - temp_publish_files = TempPublishFiles() - temp_publish_files.cleanup() - self._temp_publish_files = temp_publish_files # Whether to automatically select the latest modified # file on a refresh of the files model. @@ -168,15 +163,14 @@ class FilesWidget(QtWidgets.QWidget): workarea_btns_layout.addWidget(btn_save, 1) publish_btns_widget = QtWidgets.QWidget(btns_widget) - btn_view_published = QtWidgets.QPushButton("View", publish_btns_widget) btn_save_as_published = QtWidgets.QPushButton( - "Save As", publish_btns_widget + "Copy & Open", publish_btns_widget ) - btn_save_as_to_published = QtWidgets.QPushButton( - "Save As (to context)", publish_btns_widget + btn_change_context = QtWidgets.QPushButton( + "Choose different context", publish_btns_widget ) btn_select_context_published = QtWidgets.QPushButton( - "Select context", publish_btns_widget + "Copy & Open", publish_btns_widget ) btn_cancel_published = QtWidgets.QPushButton( "Cancel", publish_btns_widget @@ -184,9 +178,8 @@ class FilesWidget(QtWidgets.QWidget): publish_btns_layout = QtWidgets.QHBoxLayout(publish_btns_widget) publish_btns_layout.setContentsMargins(0, 0, 0, 0) - publish_btns_layout.addWidget(btn_view_published, 1) publish_btns_layout.addWidget(btn_save_as_published, 1) - publish_btns_layout.addWidget(btn_save_as_to_published, 1) + publish_btns_layout.addWidget(btn_change_context, 1) publish_btns_layout.addWidget(btn_cancel_published, 1) publish_btns_layout.addWidget(btn_select_context_published, 1) @@ -215,19 +208,15 @@ class FilesWidget(QtWidgets.QWidget): workarea_files_view.selectionModel().selectionChanged.connect( self.on_file_select ) - publish_files_view.doubleClickedLeft.connect( - self._on_published_view_pressed - ) btn_open.pressed.connect(self._on_workarea_open_pressed) btn_browse.pressed.connect(self.on_browse_pressed) btn_save.pressed.connect(self._on_save_as_pressed) - btn_view_published.pressed.connect(self._on_published_view_pressed) btn_save_as_published.pressed.connect( self._on_published_save_as_pressed ) - btn_save_as_to_published.pressed.connect( - self._on_publish_save_as_to_pressed + btn_change_context.pressed.connect( + self._on_publish_change_context_pressed ) btn_select_context_published.pressed.connect( self._on_publish_select_context_pressed @@ -256,9 +245,8 @@ class FilesWidget(QtWidgets.QWidget): self._btn_browse = btn_browse self._btn_save = btn_save - self._btn_view_published = btn_view_published self._btn_save_as_published = btn_save_as_published - self._btn_save_as_to_published = btn_save_as_to_published + self._btn_change_context = btn_change_context self._btn_select_context_published = btn_select_context_published self._btn_cancel_published = btn_cancel_published @@ -323,9 +311,8 @@ class FilesWidget(QtWidgets.QWidget): self._asset_id, self._task_name ) has_valid_items = self._publish_files_model.has_valid_items() - self._btn_view_published.setEnabled(has_valid_items) self._btn_save_as_published.setEnabled(has_valid_items) - self._btn_save_as_to_published.setEnabled(has_valid_items) + self._btn_change_context.setEnabled(has_valid_items) else: # Define a custom session so we can query the work root @@ -587,16 +574,6 @@ class FilesWidget(QtWidgets.QWidget): else: self.refresh() - def _on_published_view_pressed(self): - filepath = self._get_selected_filepath() - if not filepath or not os.path.exists(filepath): - return - item = self._temp_publish_files.add_file(filepath) - self.host.open_file(item.filepath) - self.publish_file_viewed.emit() - # Change state back to workarea - self._published_checkbox.setChecked(False) - def _on_published_save_as_pressed(self): self._save_as_with_dialog() @@ -611,9 +588,8 @@ class FilesWidget(QtWidgets.QWidget): bool(self._asset_id) and bool(self._task_name) ) - self._btn_view_published.setVisible(not enabled) self._btn_save_as_published.setVisible(not enabled) - self._btn_save_as_to_published.setVisible(not enabled) + self._btn_change_context.setVisible(not enabled) # Change views and disable workarea view if enabled self._workarea_files_view.setEnabled(not enabled) @@ -628,7 +604,7 @@ class FilesWidget(QtWidgets.QWidget): self._published_checkbox.setEnabled(not enabled) self._filter_input.setEnabled(not enabled) - def _on_publish_save_as_to_pressed(self): + def _on_publish_change_context_pressed(self): self._set_publish_context_select_mode(True) def _on_publish_select_context_pressed(self): diff --git a/openpype/tools/workfiles/lib.py b/openpype/tools/workfiles/lib.py deleted file mode 100644 index 21a7485b7b..0000000000 --- a/openpype/tools/workfiles/lib.py +++ /dev/null @@ -1,272 +0,0 @@ -import os -import shutil -import uuid -import time -import json -import logging -import contextlib - -import appdirs - - -class TempPublishFilesItem(object): - """Object representing copied workfile in app temp folder. - - Args: - item_id (str): Id of item used as subfolder. - data (dict): Metadata about temp files. - directory (str): Path to directory where files are copied to. - """ - - def __init__(self, item_id, data, directory): - self._id = item_id - self._directory = directory - self._filepath = os.path.join(directory, data["filename"]) - - @property - def directory(self): - return self._directory - - @property - def filepath(self): - return self._filepath - - @property - def id(self): - return self._id - - @property - def size(self): - if os.path.exists(self.filepath): - s = os.stat(self.filepath) - return s.st_size - return 0 - - -class TempPublishFiles(object): - """Directory where published workfiles are copied when opened. - - Directory is located in appdirs on the machine. Folder contains file - with metadata about stored files. Each item in metadata has id, filename - and expiration time. When expiration time is higher then current time the - item is removed from metadata and it's files are deleted. Files of items - are stored in subfolder named by item's id. - - Metadata file can be in theory opened and modified by multiple processes, - threads at one time. For those cases is created simple lock file which - is created before modification begins and is removed when modification - ends. Existence of the file means that it should not be modified by - any other process at the same time. - - Metadata example: - ``` - { - "96050b4a-8974-4fca-8179-7c446c478d54": { - "created": 1647880725.555, - "expiration": 1647884325.555, - "filename": "cg_pigeon_workfileModeling_v025.ma" - }, - ... - } - ``` - - ## Why is this needed - Combination of more issues. Temp files are not automatically removed by - OS on windows so using tempfiles in TEMP would lead to kill disk space of - machine. There are also cases when someone wants to open multiple files - in short period of time and want to manually remove those files so keeping - track of temporary copied files in pre-defined structure is needed. - """ - minute_in_seconds = 60 - hour_in_seconds = 60 * minute_in_seconds - day_in_seconds = 24 * hour_in_seconds - - def __init__(self): - root_dir = appdirs.user_data_dir( - "published_workfiles_temp", "openpype" - ) - if not os.path.exists(root_dir): - os.makedirs(root_dir) - - metadata_path = os.path.join(root_dir, "metadata.json") - lock_path = os.path.join(root_dir, "lock.json") - - self._root_dir = root_dir - self._metadata_path = metadata_path - self._lock_path = lock_path - self._log = None - - @property - def log(self): - if self._log is None: - self._log = logging.getLogger(self.__class__.__name__) - return self._log - - @property - def life_time(self): - """How long will be new item kept in temp in seconds. - - Returns: - int: Lifetime of temp item. - """ - return int(self.hour_in_seconds) - - @property - def size(self): - """File size of existing items.""" - size = 0 - for item in self.get_items(): - size += item.size - return size - - def add_file(self, src_path): - """Add workfile to temp directory. - - This will create new item and source path is copied to it's directory. - """ - filename = os.path.basename(src_path) - - item_id = str(uuid.uuid4()) - dst_dirpath = os.path.join(self._root_dir, item_id) - if not os.path.exists(dst_dirpath): - os.makedirs(dst_dirpath) - - dst_path = os.path.join(dst_dirpath, filename) - shutil.copy(src_path, dst_path) - - now = time.time() - item_data = { - "filename": filename, - "expiration": now + self.life_time, - "created": now - } - with self._modify_data() as data: - data[item_id] = item_data - - return TempPublishFilesItem(item_id, item_data, dst_dirpath) - - @contextlib.contextmanager - def _modify_data(self): - """Create lock file when data in metadata file are modified.""" - start_time = time.time() - timeout = 3 - while os.path.exists(self._lock_path): - time.sleep(0.01) - if start_time > timeout: - self.log.warning(( - "Waited for {} seconds to free lock file. Overriding lock." - ).format(timeout)) - - with open(self._lock_path, "w") as stream: - json.dump({"pid": os.getpid()}, stream) - - try: - data = self._get_data() - yield data - with open(self._metadata_path, "w") as stream: - json.dump(data, stream) - - finally: - os.remove(self._lock_path) - - def _get_data(self): - output = {} - if not os.path.exists(self._metadata_path): - return output - - try: - with open(self._metadata_path, "r") as stream: - output = json.load(stream) - except Exception: - self.log.warning("Failed to read metadata file.", exc_info=True) - return output - - def cleanup(self, check_expiration=True): - """Cleanup files based on metadata. - - Items that passed expiration are removed when this is called. Or all - files are removed when `check_expiration` is set to False. - - Args: - check_expiration (bool): All items and files are removed when set - to True. - """ - data = self._get_data() - now = time.time() - remove_ids = set() - all_ids = set() - for item_id, item_data in data.items(): - all_ids.add(item_id) - if check_expiration and now < item_data["expiration"]: - continue - - remove_ids.add(item_id) - - for item_id in remove_ids: - try: - self.remove_id(item_id) - except Exception: - self.log.warning( - "Failed to remove temp publish item \"{}\"".format( - item_id - ), - exc_info=True - ) - - # Remove unknown folders/files - for filename in os.listdir(self._root_dir): - if filename in all_ids: - continue - - full_path = os.path.join(self._root_dir, filename) - if full_path in (self._metadata_path, self._lock_path): - continue - - try: - shutil.rmtree(full_path) - except Exception: - self.log.warning( - "Couldn't remove arbitrary path \"{}\"".format(full_path), - exc_info=True - ) - - def clear(self): - self.cleanup(False) - - def get_items(self): - """Receive all items from metadata file. - - Returns: - list: Info about each item in metadata. - """ - output = [] - data = self._get_data() - for item_id, item_data in data.items(): - item_path = os.path.join(self._root_dir, item_id) - output.append(TempPublishFilesItem(item_id, item_data, item_path)) - return output - - def remove_id(self, item_id): - """Remove files of item and then remove the item from metadata.""" - filepath = os.path.join(self._root_dir, item_id) - if os.path.exists(filepath): - shutil.rmtree(filepath) - - with self._modify_data() as data: - data.pop(item_id, None) - - -def file_size_to_string(file_size): - size = 0 - size_ending_mapping = { - "KB": 1024 ** 1, - "MB": 1024 ** 2, - "GB": 1024 ** 3 - } - ending = "B" - for _ending, _size in size_ending_mapping.items(): - if file_size < _size: - break - size = file_size / _size - ending = _ending - return "{:.2f} {}".format(size, ending) diff --git a/openpype/tools/workfiles/window.py b/openpype/tools/workfiles/window.py index 8654a18036..73e63d30b5 100644 --- a/openpype/tools/workfiles/window.py +++ b/openpype/tools/workfiles/window.py @@ -14,7 +14,22 @@ from openpype.tools.utils.assets_widget import SingleSelectAssetsWidget from openpype.tools.utils.tasks_widget import TasksWidget from .files_widget import FilesWidget -from .lib import TempPublishFiles, file_size_to_string + + +def file_size_to_string(file_size): + size = 0 + size_ending_mapping = { + "KB": 1024 ** 1, + "MB": 1024 ** 2, + "GB": 1024 ** 3 + } + ending = "B" + for _ending, _size in size_ending_mapping.items(): + if file_size < _size: + break + size = file_size / _size + ending = _ending + return "{:.2f} {}".format(size, ending) class SidePanelWidget(QtWidgets.QWidget): @@ -44,67 +59,25 @@ class SidePanelWidget(QtWidgets.QWidget): btn_note_save, 0, alignment=QtCore.Qt.AlignRight ) - publish_temp_widget = QtWidgets.QWidget(self) - publish_temp_info_label = QtWidgets.QLabel( - self.published_workfile_message.format( - file_size_to_string(0) - ), - publish_temp_widget - ) - publish_temp_info_label.setWordWrap(True) - - btn_clear_temp = QtWidgets.QPushButton( - "Clear temp", publish_temp_widget - ) - - publish_temp_layout = QtWidgets.QVBoxLayout(publish_temp_widget) - publish_temp_layout.setContentsMargins(0, 0, 0, 0) - publish_temp_layout.addWidget(publish_temp_info_label, 0) - publish_temp_layout.addWidget( - btn_clear_temp, 0, alignment=QtCore.Qt.AlignRight - ) - main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.addWidget(details_label, 0) main_layout.addWidget(details_input, 1) main_layout.addWidget(artist_note_widget, 1) - main_layout.addWidget(publish_temp_widget, 0) note_input.textChanged.connect(self._on_note_change) btn_note_save.clicked.connect(self._on_save_click) - btn_clear_temp.clicked.connect(self._on_clear_temp_click) self._details_input = details_input self._artist_note_widget = artist_note_widget self._note_input = note_input self._btn_note_save = btn_note_save - self._publish_temp_info_label = publish_temp_info_label - self._publish_temp_widget = publish_temp_widget - self._orig_note = "" self._workfile_doc = None - publish_temp_widget.setVisible(False) - def set_published_visible(self, published_visible): self._artist_note_widget.setVisible(not published_visible) - self._publish_temp_widget.setVisible(published_visible) - if published_visible: - self.refresh_publish_temp_sizes() - - def refresh_publish_temp_sizes(self): - temp_publish_files = TempPublishFiles() - text = self.published_workfile_message.format( - file_size_to_string(temp_publish_files.size) - ) - self._publish_temp_info_label.setText(text) - - def _on_clear_temp_click(self): - temp_publish_files = TempPublishFiles() - temp_publish_files.clear() - self.refresh_publish_temp_sizes() def _on_note_change(self): text = self._note_input.toPlainText() @@ -225,9 +198,6 @@ class Window(QtWidgets.QMainWindow): files_widget.file_selected.connect(self.on_file_select) files_widget.workfile_created.connect(self.on_workfile_create) files_widget.file_opened.connect(self._on_file_opened) - files_widget.publish_file_viewed.connect( - self._on_publish_file_viewed - ) files_widget.published_visible_changed.connect( self._on_published_change ) @@ -292,9 +262,6 @@ class Window(QtWidgets.QMainWindow): def _on_file_opened(self): self.close() - def _on_publish_file_viewed(self): - self.side_panel.refresh_publish_temp_sizes() - def _on_published_change(self, visible): self.side_panel.set_published_visible(visible) From 34656d9b79536155eca3a5729cf6d0b449c6633d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 12:29:29 +0200 Subject: [PATCH 408/854] fix ampresand in button label --- openpype/tools/workfiles/files_widget.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 80a94cc1bd..f29223b321 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -95,7 +95,7 @@ class FilesWidget(QtWidgets.QWidget): extensions = set(self.host.file_extensions()) views_widget = QtWidgets.QWidget(self) - # Workarea view + # --- Workarea view --- workarea_files_model = WorkAreaFilesModel(extensions) # Create proxy model for files to be able sort and filter @@ -113,13 +113,14 @@ class FilesWidget(QtWidgets.QWidget): # Date modified delegate workarea_time_delegate = PrettyTimeDelegate() workarea_files_view.setItemDelegateForColumn(1, workarea_time_delegate) - workarea_files_view.setIndentation(3) # smaller indentation + # smaller indentation + workarea_files_view.setIndentation(3) # Default to a wider first filename column it is what we mostly care # about and the date modified is relatively small anyway. workarea_files_view.setColumnWidth(0, 330) - # Publish files view + # --- Publish files view --- publish_files_model = PublishFilesModel(extensions, io, self.anatomy) publish_proxy_model = QtCore.QSortFilterProxyModel() @@ -136,7 +137,8 @@ class FilesWidget(QtWidgets.QWidget): # Date modified delegate publish_time_delegate = PrettyTimeDelegate() publish_files_view.setItemDelegateForColumn(1, publish_time_delegate) - publish_files_view.setIndentation(3) # smaller indentation + # smaller indentation + publish_files_view.setIndentation(3) # Default to a wider first filename column it is what we mostly care # about and the date modified is relatively small anyway. @@ -164,13 +166,13 @@ class FilesWidget(QtWidgets.QWidget): publish_btns_widget = QtWidgets.QWidget(btns_widget) btn_save_as_published = QtWidgets.QPushButton( - "Copy & Open", publish_btns_widget + "Copy && Open", publish_btns_widget ) btn_change_context = QtWidgets.QPushButton( "Choose different context", publish_btns_widget ) btn_select_context_published = QtWidgets.QPushButton( - "Copy & Open", publish_btns_widget + "Copy && Open", publish_btns_widget ) btn_cancel_published = QtWidgets.QPushButton( "Cancel", publish_btns_widget From aa8438a6ad2e947038af43bef93d1dd686267aea Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 12:29:50 +0200 Subject: [PATCH 409/854] define extensions of save as dialog with argument --- openpype/tools/workfiles/files_widget.py | 7 +++++++ openpype/tools/workfiles/save_as_dialog.py | 11 +++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index f29223b321..6e90dea982 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -449,11 +449,18 @@ class FilesWidget(QtWidgets.QWidget): """ session = self._get_session() + if self.published_enabled: + filepath = self._get_selected_filepath() + extensions = [os.path.splitext(filepath)[1]] + else: + extensions = self.host.file_extensions() + window = SaveAsDialog( parent=self, root=self._workfiles_root, anatomy=self.anatomy, template_key=self.template_key, + extensions=extensions, session=session ) window.exec_() diff --git a/openpype/tools/workfiles/save_as_dialog.py b/openpype/tools/workfiles/save_as_dialog.py index e616a325cc..f5ae393d0f 100644 --- a/openpype/tools/workfiles/save_as_dialog.py +++ b/openpype/tools/workfiles/save_as_dialog.py @@ -193,7 +193,9 @@ class SaveAsDialog(QtWidgets.QDialog): """ - def __init__(self, parent, root, anatomy, template_key, session=None): + def __init__( + self, parent, root, anatomy, template_key, extensions, session=None + ): super(SaveAsDialog, self).__init__(parent=parent) self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint) @@ -201,6 +203,7 @@ class SaveAsDialog(QtWidgets.QDialog): self.host = api.registered_host() self.root = root self.work_file = None + self._extensions = extensions if not session: # Fallback to active session @@ -257,7 +260,7 @@ class SaveAsDialog(QtWidgets.QDialog): # Add styled delegate to use stylesheets ext_delegate = QtWidgets.QStyledItemDelegate() ext_combo.setItemDelegate(ext_delegate) - ext_combo.addItems(self.host.file_extensions()) + ext_combo.addItems(self._extensions) # Build inputs inputs_layout = QtWidgets.QFormLayout(inputs_widget) @@ -336,7 +339,7 @@ class SaveAsDialog(QtWidgets.QDialog): def get_existing_comments(self): matcher = CommentMatcher(self.anatomy, self.template_key, self.data) - host_extensions = set(self.host.file_extensions()) + host_extensions = set(self._extensions) comments = set() if os.path.isdir(self.root): for fname in os.listdir(self.root): @@ -392,7 +395,7 @@ class SaveAsDialog(QtWidgets.QDialog): return anatomy_filled[self.template_key]["file"] def refresh(self): - extensions = self.host.file_extensions() + extensions = list(self._extensions) extension = self.data["ext"] if extension is None: # Define saving file extension From 2d038efde5122b9c709f1fedb9808eb75343f92e Mon Sep 17 00:00:00 2001 From: Allan Ihsan Date: Thu, 31 Mar 2022 13:34:38 +0300 Subject: [PATCH 410/854] fixes parameter name for readability --- openpype/pipeline/farm/patterning.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index e1c05df77f..6d3eb3e5ab 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -2,7 +2,7 @@ import re -def match_aov_pattern(app, aov_patterns, render_file_name): +def match_aov_pattern(host_name, aov_patterns, render_file_name): """Matching against a `AOV` pattern in the render files. In order to match the AOV name we must compare @@ -18,7 +18,7 @@ def match_aov_pattern(app, aov_patterns, render_file_name): Returns: bool: Review state for rendered file (render_file_name). """ - aov_pattern = aov_patterns.get(app, []) + aov_pattern = aov_patterns.get(host_name, []) if aov_pattern: if re.match(aov_pattern, render_file_name): preview = True From f630b8cb7d2c76d512c9dde35ba0a620cb7d1237 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 31 Mar 2022 12:46:24 +0200 Subject: [PATCH 411/854] fix setuptools --- setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3ee6ad43ea..5739356084 100644 --- a/setup.py +++ b/setup.py @@ -187,5 +187,6 @@ setup( "build_dir": (openpype_root / "docs" / "build").as_posix() } }, - executables=executables + executables=executables, + package_dir=[] ) From 790850697f4742081779bccdc8230c1c04100f39 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 13:16:52 +0200 Subject: [PATCH 412/854] OP-2517 - fix renaming subset incorrectly in PS --- .../hosts/photoshop/plugins/publish/validate_naming.py | 9 +++++---- .../settings/defaults/project_settings/photoshop.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/validate_naming.py b/openpype/hosts/photoshop/plugins/publish/validate_naming.py index b40e44d016..583e9c7a4e 100644 --- a/openpype/hosts/photoshop/plugins/publish/validate_naming.py +++ b/openpype/hosts/photoshop/plugins/publish/validate_naming.py @@ -29,7 +29,8 @@ class ValidateNamingRepair(pyblish.api.Action): stub = photoshop.stub() for instance in instances: self.log.info("validate_naming instance {}".format(instance)) - metadata = stub.read(instance[0]) + layer_item = instance.data["layer"] + metadata = stub.read(layer_item) self.log.info("metadata instance {}".format(metadata)) layer_name = None if metadata.get("uuid"): @@ -43,11 +44,11 @@ class ValidateNamingRepair(pyblish.api.Action): stub.rename_layer(instance.data["uuid"], layer_name) subset_name = re.sub(invalid_chars, replace_char, - instance.data["name"]) + instance.data["subset"]) - instance[0].Name = layer_name or subset_name + layer_item.name = layer_name or subset_name metadata["subset"] = subset_name - stub.imprint(instance[0], metadata) + stub.imprint(layer_item, metadata) return True diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 118b9c721e..822a94a8eb 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -18,7 +18,7 @@ "active": true }, "ValidateNaming": { - "invalid_chars": "[ \\\\/+\\*\\?\\(\\)\\[\\]\\{\\}:,]", + "invalid_chars": "[ \\\\/+\\*\\?\\(\\)\\[\\]\\{\\}:,;]", "replace_char": "_" }, "ExtractImage": { From 5b81a43c7c7db30171d8f4110018f3a17e77f2b8 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 31 Mar 2022 13:31:18 +0200 Subject: [PATCH 413/854] print log if build fails --- tools/build.ps1 | 3 +++ tools/build.sh | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tools/build.ps1 b/tools/build.ps1 index 10da3d0b83..ff28544954 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -180,6 +180,9 @@ $out = & "$($env:POETRY_HOME)\bin\poetry" run python setup.py build 2>&1 Set-Content -Path "$($openpype_root)\build\build.log" -Value $out if ($LASTEXITCODE -ne 0) { + Write-Host "------------------------------------------" -ForegroundColor Red + Get-Content "$($openpype_root)\build\build.log" + Write-Host "------------------------------------------" -ForegroundColor Red Write-Host "!!! " -NoNewLine -ForegroundColor Red Write-Host "Build failed. Check the log: " -NoNewline Write-Host ".\build\build.log" -ForegroundColor Yellow diff --git a/tools/build.sh b/tools/build.sh index 301f26023a..79fb748cd5 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -185,9 +185,9 @@ if [ "$disable_submodule_update" == 1 ]; then fi echo -e "${BIGreen}>>>${RST} Building ..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then - "$POETRY_HOME/bin/poetry" run python "$openpype_root/setup.py" build &> "$openpype_root/build/build.log" || { echo -e "${BIRed}!!!${RST} Build failed, see the build log."; return 1; } + "$POETRY_HOME/bin/poetry" run python "$openpype_root/setup.py" build &> "$openpype_root/build/build.log" || { echo -e "${BIRed}------------------------------------------${RST}"; cat "$openpype_root/build/build.log"; echo -e "${BIRed}------------------------------------------${RST}"; echo -e "${BIRed}!!!${RST} Build failed, see the build log."; return 1; } elif [[ "$OSTYPE" == "darwin"* ]]; then - "$POETRY_HOME/bin/poetry" run python "$openpype_root/setup.py" bdist_mac &> "$openpype_root/build/build.log" || { echo -e "${BIRed}!!!${RST} Build failed, see the build log."; return 1; } + "$POETRY_HOME/bin/poetry" run python "$openpype_root/setup.py" bdist_mac &> "$openpype_root/build/build.log" || { echo -e "${BIRed}------------------------------------------${RST}"; cat "$openpype_root/build/build.log"; echo -e "${BIRed}------------------------------------------${RST}"; echo -e "${BIRed}!!!${RST} Build failed, see the build log."; return 1; } fi "$POETRY_HOME/bin/poetry" run python "$openpype_root/tools/build_dependencies.py" From 67bc56c8bca8dde59b7b56e4823b437d3c547e5d Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 31 Mar 2022 14:12:38 +0200 Subject: [PATCH 414/854] fix argument --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5739356084..bf42602b52 100644 --- a/setup.py +++ b/setup.py @@ -188,5 +188,5 @@ setup( } }, executables=executables, - package_dir=[] + packages=[] ) From 37900da59d1c5ba718c20cc3266c3bb62c9ee987 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 14:32:40 +0200 Subject: [PATCH 415/854] added overlay guiding to select context --- openpype/style/style.css | 8 ++++++ openpype/tools/workfiles/files_widget.py | 31 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/openpype/style/style.css b/openpype/style/style.css index df83600973..b5f6962eee 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1269,6 +1269,14 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: #21252B; } +/* Workfiles */ +#WorkfilesPublishedContextSelect { + background: rgba(0, 0, 0, 127); +} +#WorkfilesPublishedContextSelect QLabel { + font-size: 17pt; +} + /* Tray */ #TrayRestartButton { background: {color:restart-btn-bg}; diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 6e90dea982..55abd39b36 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -44,6 +44,31 @@ class FilesView(QtWidgets.QTreeView): return super(FilesView, self).mouseDoubleClickEvent(event) +class SelectContextOverlay(QtWidgets.QFrame): + def __init__(self, parent): + super(SelectContextOverlay, self).__init__(parent) + + self.setObjectName("WorkfilesPublishedContextSelect") + label_widget = QtWidgets.QLabel( + "Please select context on Left side
<", + self + ) + label_widget.setAlignment(QtCore.Qt.AlignCenter) + + layout = QtWidgets.QHBoxLayout(self) + layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter) + + label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + parent.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Resize: + self.resize(obj.size()) + + return super(SelectContextOverlay, self).eventFilter(obj, event) + + class FilesWidget(QtWidgets.QWidget): """A widget displaying files that allows to save and open files.""" file_selected = QtCore.Signal(str) @@ -144,6 +169,9 @@ class FilesWidget(QtWidgets.QWidget): # about and the date modified is relatively small anyway. publish_files_view.setColumnWidth(0, 330) + publish_context_overlay = SelectContextOverlay(views_widget) + publish_context_overlay.setVisible(False) + views_layout = QtWidgets.QHBoxLayout(views_widget) views_layout.setContentsMargins(0, 0, 0, 0) views_layout.addWidget(workarea_files_view, 1) @@ -241,6 +269,8 @@ class FilesWidget(QtWidgets.QWidget): self._publish_files_model = publish_files_model self._publish_proxy_model = publish_proxy_model + self._publish_context_overlay = publish_context_overlay + self._workarea_btns_widget = workarea_btns_widget self._publish_btns_widget = publish_btns_widget self._btn_open = btn_open @@ -590,6 +620,7 @@ class FilesWidget(QtWidgets.QWidget): self._publish_context_select_mode = enabled # Show buttons related to context selection + self._publish_context_overlay.setVisible(enabled) self._btn_cancel_published.setVisible(enabled) self._btn_select_context_published.setVisible(enabled) # Change enabled state based on select context From 14da7f74c6ddedc4eeaa9fc3fb5b093bf630b2e8 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 14:33:02 +0200 Subject: [PATCH 416/854] don't cancel save as publishing on cancel save as dialog --- openpype/tools/workfiles/files_widget.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 55abd39b36..6cfd3dd651 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -554,7 +554,7 @@ class FilesWidget(QtWidgets.QWidget): def _save_as_with_dialog(self): work_filename = self.get_filename() if not work_filename: - return + return None src_path = self._get_selected_filepath() @@ -612,6 +612,7 @@ class FilesWidget(QtWidgets.QWidget): self._published_checkbox.setChecked(False) else: self.refresh() + return filepath def _on_published_save_as_pressed(self): self._save_as_with_dialog() @@ -648,9 +649,10 @@ class FilesWidget(QtWidgets.QWidget): self._set_publish_context_select_mode(True) def _on_publish_select_context_pressed(self): - self._save_as_with_dialog() - self._set_publish_context_select_mode(False) - self._update_asset_task() + result = self._save_as_with_dialog() + if result is not None: + self._set_publish_context_select_mode(False) + self._update_asset_task() def _on_publish_cancel_pressed(self): self._set_publish_context_select_mode(False) From 6e592e4df62dd5f8969fbe8e29a730ce999a8d57 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 14:56:07 +0200 Subject: [PATCH 417/854] changed label --- openpype/tools/workfiles/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index 6cfd3dd651..edfcb17722 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -50,7 +50,7 @@ class SelectContextOverlay(QtWidgets.QFrame): self.setObjectName("WorkfilesPublishedContextSelect") label_widget = QtWidgets.QLabel( - "Please select context on Left side
<", + "Please choose context on the left
<", self ) label_widget.setAlignment(QtCore.Qt.AlignCenter) From 8c213809effe28eaf92f09e876adae71900d8922 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Mar 2022 15:47:37 +0200 Subject: [PATCH 418/854] flame: adding extract review data to bypass baking --- .../plugins/publish/extract_review_data.py | 41 +++++++++++++++++++ .../defaults/project_settings/nuke.json | 3 ++ .../schemas/schema_nuke_publish.json | 15 +++++++ 3 files changed, 59 insertions(+) create mode 100644 openpype/hosts/nuke/plugins/publish/extract_review_data.py diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py new file mode 100644 index 0000000000..19c894448d --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -0,0 +1,41 @@ +import os +import pyblish.api +import openpype +from pprint import pformat + + +class ExtractReviewData(openpype.api.Extractor): + """Extracts review tag into available representation + """ + + order = pyblish.api.ExtractorOrder + 0.01 + # order = pyblish.api.CollectorOrder + 0.499 + label = "Extract Review Data" + + families = ["review"] + hosts = ["nuke"] + + def process(self, instance): + fpath = instance.data["path"] + ext = os.path.splitext(fpath)[-1][1:] + + representations = instance.data.get("representations", []) + + if "render.farm" in instance.data["families"]: + instance.data["families"].remove("review") + + for repre in representations: + if ext not in repre["ext"]: + continue + + if not repre.get("tags"): + repre["tags"] = [] + + if "review" not in repre["tags"]: + repre["tags"].append("review") + + self.log.debug("Matching representation: {}".format( + pformat(repre) + )) + + instance.data["representations"] = representations diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 6992fb6e3e..d3d6252be7 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -106,6 +106,9 @@ ] } }, + "ExtractReviewData": { + "enabled": false + }, "ExtractReviewDataLut": { "enabled": false }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 1636a8d700..6776f316d9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -138,6 +138,21 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ExtractReviewData", + "label": "ExtractReviewData", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "collapsible": true, From 15f4d110699249a540f4889b08b319056f42f29b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Mar 2022 15:47:37 +0200 Subject: [PATCH 419/854] nuke: adding extract review data to bypass baking --- .../plugins/publish/extract_review_data.py | 41 +++++++++++++++++++ .../defaults/project_settings/nuke.json | 3 ++ .../schemas/schema_nuke_publish.json | 15 +++++++ 3 files changed, 59 insertions(+) create mode 100644 openpype/hosts/nuke/plugins/publish/extract_review_data.py diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py new file mode 100644 index 0000000000..19c894448d --- /dev/null +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -0,0 +1,41 @@ +import os +import pyblish.api +import openpype +from pprint import pformat + + +class ExtractReviewData(openpype.api.Extractor): + """Extracts review tag into available representation + """ + + order = pyblish.api.ExtractorOrder + 0.01 + # order = pyblish.api.CollectorOrder + 0.499 + label = "Extract Review Data" + + families = ["review"] + hosts = ["nuke"] + + def process(self, instance): + fpath = instance.data["path"] + ext = os.path.splitext(fpath)[-1][1:] + + representations = instance.data.get("representations", []) + + if "render.farm" in instance.data["families"]: + instance.data["families"].remove("review") + + for repre in representations: + if ext not in repre["ext"]: + continue + + if not repre.get("tags"): + repre["tags"] = [] + + if "review" not in repre["tags"]: + repre["tags"].append("review") + + self.log.debug("Matching representation: {}".format( + pformat(repre) + )) + + instance.data["representations"] = representations diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 6992fb6e3e..d3d6252be7 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -106,6 +106,9 @@ ] } }, + "ExtractReviewData": { + "enabled": false + }, "ExtractReviewDataLut": { "enabled": false }, diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json index 1636a8d700..6776f316d9 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_nuke_publish.json @@ -138,6 +138,21 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "checkbox_key": "enabled", + "key": "ExtractReviewData", + "label": "ExtractReviewData", + "is_group": true, + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + } + ] + }, { "type": "dict", "collapsible": true, From dc2da01f52be73061de77e868f19e6252f8c2510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Thu, 31 Mar 2022 16:03:22 +0200 Subject: [PATCH 420/854] Update openpype/hosts/nuke/plugins/publish/extract_review_data.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/plugins/publish/extract_review_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py index 19c894448d..d973e6accd 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -25,7 +25,7 @@ class ExtractReviewData(openpype.api.Extractor): instance.data["families"].remove("review") for repre in representations: - if ext not in repre["ext"]: + if ext != repre["ext"]: continue if not repre.get("tags"): From 995ff7b94ac12ec7c25b81655ebab99095fac529 Mon Sep 17 00:00:00 2001 From: "Allan I. A" <76656700+Allan-I@users.noreply.github.com> Date: Thu, 31 Mar 2022 18:59:44 +0300 Subject: [PATCH 421/854] Updates match_aov_pattern() logic to handle empty regex Using `is not None` to simplify code and handle empty regex cases. Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/pipeline/farm/patterning.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/pipeline/farm/patterning.py b/openpype/pipeline/farm/patterning.py index 6d3eb3e5ab..5ba7a8df4b 100644 --- a/openpype/pipeline/farm/patterning.py +++ b/openpype/pipeline/farm/patterning.py @@ -19,9 +19,6 @@ def match_aov_pattern(host_name, aov_patterns, render_file_name): bool: Review state for rendered file (render_file_name). """ aov_pattern = aov_patterns.get(host_name, []) - if aov_pattern: - if re.match(aov_pattern, render_file_name): - preview = True - return preview - else: - return False + if not aov_pattern: + return False + return re.match(aov_pattern, render_file_name) is not None From eb95e11607483dd5f7b106c4701847f56e36a4f2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 18:57:08 +0200 Subject: [PATCH 422/854] OP-2011 - added pulling priority for DL from Settings Default value is from Settings, artist can modify it. --- openpype/hosts/maya/plugins/create/create_render.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 9002ae3876..7ac739b227 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -294,6 +294,12 @@ class CreateRender(plugin.Creator): deadline_url = next(iter(self.deadline_servers.values())) pool_names = self._get_deadline_pools(deadline_url) + priority = self._project_settings.get( + "deadline", {}).get( + "publish", {}).get( + "MayaSubmitDeadline", {}).get( + "priority", 50) + self.data["priority"] = priority if muster_enabled: self.log.info(">>> Loading Muster credentials ...") From 79de61cda50d5e1f6c3897d1a31d780fecbea8b1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 19:01:59 +0200 Subject: [PATCH 423/854] OP-2011 - added separate tile_priority field --- .../hosts/maya/plugins/create/create_render.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 7ac739b227..4f0a394f85 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -252,6 +252,7 @@ class CreateRender(plugin.Creator): """Create instance settings.""" # get pools pool_names = [] + default_priority = 50 self.server_aliases = list(self.deadline_servers.keys()) self.data["deadlineServers"] = self.server_aliases @@ -260,7 +261,8 @@ class CreateRender(plugin.Creator): self.data["extendFrames"] = False self.data["overrideExistingFrame"] = True # self.data["useLegacyRenderLayers"] = True - self.data["priority"] = 50 + self.data["priority"] = default_priority + self.data["tile_priority"] = default_priority self.data["framesPerTask"] = 1 self.data["whitelist"] = False self.data["machineList"] = "" @@ -294,13 +296,17 @@ class CreateRender(plugin.Creator): deadline_url = next(iter(self.deadline_servers.values())) pool_names = self._get_deadline_pools(deadline_url) - priority = self._project_settings.get( + maya_submit_dl = self._project_settings.get( "deadline", {}).get( "publish", {}).get( - "MayaSubmitDeadline", {}).get( - "priority", 50) + "MayaSubmitDeadline", {}) + priority = maya_submit_dl.get("priority", default_priority) self.data["priority"] = priority + tile_priority = maya_submit_dl.get("tile_priority", + default_priority) + self.data["tile_priority"] = tile_priority + if muster_enabled: self.log.info(">>> Loading Muster credentials ...") self._load_credentials() From c345e4aa27b615ad529d5b4a9e20d503a0b27cb8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 19:02:56 +0200 Subject: [PATCH 424/854] OP-2011 - added separate tile_priority to submit to DL --- .../deadline/plugins/publish/submit_maya_deadline.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 31a7c2f176..2d2d70758f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -254,6 +254,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): use_published = True tile_assembler_plugin = "OpenPypeTileAssembler" asset_dependencies = False + priority = 50 + tile_priority = 50 limit_groups = [] jobInfo = None pluginInfo = None @@ -442,7 +444,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): self.payload_skeleton["JobInfo"]["UserName"] = deadline_user # Set job priority self.payload_skeleton["JobInfo"]["Priority"] = \ - self._instance.data.get("priority", 50) + self._instance.data.get("priority", self.priority) if self.group != "none" and self.group: self.payload_skeleton["JobInfo"]["Group"] = self.group @@ -612,7 +614,7 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): } assembly_payload["JobInfo"].update(output_filenames) assembly_payload["JobInfo"]["Priority"] = self._instance.data.get( - "priority", 50) + "tile_priority", self.tile_priority) assembly_payload["JobInfo"]["UserName"] = deadline_user frame_payloads = [] From 6aa23b5c3946756921d54b7fd52554ad332902c1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 19:06:15 +0200 Subject: [PATCH 425/854] OP-2011 - added separate tile_priority to Settings --- .../defaults/project_settings/deadline.json | 56 ++++++++++--------- .../schema_project_deadline.json | 12 ++++ 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 5bb0a4022e..053c50ce8b 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -15,33 +15,6 @@ "deadline" ] }, - "ProcessSubmittedJobOnFarm": { - "enabled": true, - "deadline_department": "", - "deadline_pool": "", - "deadline_group": "", - "deadline_chunk_size": 1, - "deadline_priority": 50, - "publishing_script": "", - "skip_integration_repre_list": [], - "aov_filter": { - "maya": [ - ".+(?:\\.|_)([Bb]eauty)(?:\\.|_).*" - ], - "nuke": [ - ".*" - ], - "aftereffects": [ - ".*" - ], - "celaction": [ - ".*" - ], - "harmony": [ - ".*" - ] - } - }, "MayaSubmitDeadline": { "enabled": true, "optional": false, @@ -49,6 +22,8 @@ "tile_assembler_plugin": "OpenPypeTileAssembler", "use_published": true, "asset_dependencies": true, + "priority": 50, + "tile_priority": 50, "group": "none", "limit": [], "jobInfo": {}, @@ -95,6 +70,33 @@ "group": "", "department": "", "multiprocess": true + }, + "ProcessSubmittedJobOnFarm": { + "enabled": true, + "deadline_department": "", + "deadline_pool": "", + "deadline_group": "", + "deadline_chunk_size": 1, + "deadline_priority": 40, + "publishing_script": "", + "skip_integration_repre_list": [], + "aov_filter": { + "maya": [ + ".+(?:\\.|_)([Bb]eauty)(?:\\.|_).*" + ], + "nuke": [ + ".*" + ], + "aftereffects": [ + ".*" + ], + "celaction": [ + ".*" + ], + "harmony": [ + ".*" + ] + } } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index e6097a2b14..0348543c81 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -117,6 +117,18 @@ "key": "asset_dependencies", "label": "Use Asset dependencies" }, + { + "type": "number", + "key": "priority", + "label": "Priority", + "default": 50 + }, + { + "type": "number", + "key": "tile_priority", + "label": "Tile Assembler Priority", + "default": 50 + }, { "type": "text", "key": "group", From 3ee7c213c38f08a15dd7ca4e3d9edd8a0ec1072f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 19:16:16 +0200 Subject: [PATCH 426/854] added some ui examples --- website/docs/assets/publisher_card_view.png | Bin 0 -> 103961 bytes .../docs/assets/publisher_create_dialog.png | Bin 0 -> 59900 bytes website/docs/assets/publisher_list_view.png | Bin 0 -> 29456 bytes website/docs/dev_publishing.md | 25 ++++++++++++++++++ 4 files changed, 25 insertions(+) create mode 100644 website/docs/assets/publisher_card_view.png create mode 100644 website/docs/assets/publisher_create_dialog.png create mode 100644 website/docs/assets/publisher_list_view.png diff --git a/website/docs/assets/publisher_card_view.png b/website/docs/assets/publisher_card_view.png new file mode 100644 index 0000000000000000000000000000000000000000..57b012cb6ddd1399bf89fe766a536a9fa13ae5d0 GIT binary patch literal 103961 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 literal 0 HcmV?d00001 diff --git a/website/docs/assets/publisher_create_dialog.png b/website/docs/assets/publisher_create_dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..6e9275062d5bcf688a0466e7893ad157a2e8a581 GIT binary patch literal 59900 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 Cxfq`S literal 0 HcmV?d00001 diff --git a/website/docs/assets/publisher_list_view.png b/website/docs/assets/publisher_list_view.png new file mode 100644 index 0000000000000000000000000000000000000000..e9dc8a607af520f37ae403b775c5800af071894f GIT binary patch literal 29456 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 literal 0 HcmV?d00001 diff --git a/website/docs/dev_publishing.md b/website/docs/dev_publishing.md index 49e720a7e0..ec01a941b6 100644 --- a/website/docs/dev_publishing.md +++ b/website/docs/dev_publishing.md @@ -522,4 +522,29 @@ Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_ ``` +## **UI examples** +### Main publish window +Main window of publisher shows instances and their values, collected by creators. +**Card view** +![Publisher UI - Card view](assets/publisher_card_view.png) +**List view** +![Publisher UI - List view](assets/publisher_list_view.png) + +#### *Instances views* +List of instances always contains `Options` item which is used to show attributes of context plugins. Values from the item are saved and loaded using [host implementation](#required-functions-in-host-implementation) **get_context_data** and **update_context_data**. Instances are grouped by family and can be shown in card view (single selection) or list view (multi selection). + +Instance view has at the bottom 3 buttons. Plus sign opens [create dialog](#create-dialog), bin removes selected instances and stripes swap card and list view. + +#### *Context options* +It is possible to change variant or asset and task context of instances at the top part but all changes there must be confirmed. Confirmation will trigger recalculation of subset names and all new data are stored to instances. + +#### *Create attributes* +Instance attributes display all create attributes of all selected instances. All attributes that have same definition are grouped into one input and is visually indicated if values are not same for selected instances. In most of cases have **< Multiselection >** placeholder. + +#### *Publish attributes* +Publish attributes work the same way as create attributes but the source of attribute definitions are pyblish plugins. Attributes are filtered based on families of selected instances and families defined in pyblish plugin. + +### Create dialog +![Publisher UI - Create dialog](assets/publisher_create_dialog.png) +Create dialog is used by artist to create new instances in a context. The context selection can be enabled/disabled by changing `create_allow_context_change` on [creator plugin](#creator). In middle part artist select what will be created and what variant it is. On right side is information about selected creator and it's pre-create attributes. There is also question mark button which extends window and display more detailed information about the creator. From 2e890b5500b82d5ec480ca22a2ae97bd7f7162a9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Thu, 31 Mar 2022 19:16:22 +0200 Subject: [PATCH 427/854] fix example plugin --- website/docs/dev_publishing.md | 36 ++++++++++++++++------------------ 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/website/docs/dev_publishing.md b/website/docs/dev_publishing.md index ec01a941b6..710090af40 100644 --- a/website/docs/dev_publishing.md +++ b/website/docs/dev_publishing.md @@ -465,21 +465,21 @@ Values of publish attributes from created instance are never removed automatical Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`.
- Toggle me! +Example plugin +

- ``` python - - import pyblish.api - from openpype.pipeline import ( +```python +import pyblish.api +from openpype.pipeline import ( OpenPypePyblishPluginMixin, attribute_definitions, - ) +) - # Example context plugin - class MyExtendedPlugin( +# Example context plugin +class MyExtendedPlugin( pyblish.api.ContextPlugin, OpenPypePyblishPluginMixin - ): +): optional = True active = True @@ -501,16 +501,13 @@ Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_ if not self.optional: return True + # Attribute values are stored by class names + # - for those purposes was implemented 'get_attr_values_from_data' + # to help with accessing it + attribute_values = self.get_attr_values_from_data(context.data) # Get 'process' key - process_value = ( - context.data - .get("publish_attributes", {}) - # Attribute values are stored by class names - .get(self.__class__.__name__, {}) - # Access the key - .get("process") - ) - if process_value or process_value is None: + process_value = attribute_values.get("process") + if process_value is None or process_value: return True return False @@ -519,7 +516,8 @@ Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_ return # Do plugin logic ... - ``` +``` +

## **UI examples** From 700184cfdc2f3dbabb3b49bbc1d9464fd3939a25 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 31 Mar 2022 19:44:23 +0200 Subject: [PATCH 428/854] OP-2011 - added priority to publish job --- .../deadline/plugins/publish/submit_publish_job.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index fad4d14ea0..29a276d3b3 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -916,12 +916,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # User is deadline user render_job["Props"]["User"] = context.data.get( "deadlineUser", getpass.getuser()) - # Priority is now not handled at all - - if self.deadline_priority: - render_job["Props"]["Pri"] = self.deadline_priority - else: - render_job["Props"]["Pri"] = instance.data.get("priority") render_job["Props"]["Env"] = { "FTRACK_API_USER": os.environ.get("FTRACK_API_USER"), @@ -937,6 +931,11 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): self.deadline_url = instance.data.get("deadlineUrl") assert self.deadline_url, "Requires Deadline Webservice URL" + if self.deadline_priority: + render_job["Props"]["Pri"] = self.deadline_priority + else: + render_job["Props"]["Pri"] = instance.data.get("priority") + self._submit_deadline_post_job(instance, render_job, instances) # publish job file From f80bc13cc309e3d2769da17acc73e64cedaeb134 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 31 Mar 2022 20:26:08 +0200 Subject: [PATCH 429/854] adding limitations for pyright speeding up development --- pyproject.toml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 479cd731fe..e42fd75db2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,3 +136,19 @@ hash = "de63a8bf7f6c45ff59ecafeba13123f710c2cbc1783ec9e0b938e980d4f5c37f" [openpype.thirdparty.oiio.darwin] url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-darwin.tgz" hash = "sha256:..." + +[tool.pyright] +include = [ + "igniter", + "openpype", + "repos", + "vendor" +] +exclude = [ + "**/node_modules", + "**/__pycache__" +] +ignore = ["website", "docs", ".git"] + +reportMissingImports = true +reportMissingTypeStubs = false \ No newline at end of file From d8c56f0a67cacfc2e05b726efc0e3d8e392c0f78 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 10:39:52 +0200 Subject: [PATCH 430/854] Update openpype/lib/avalon_context.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 9a5d382c98..5ea472f11e 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -2001,7 +2001,7 @@ def get_linked_ids_for_representations(project, repre_ids, dbcon=None, if not isinstance(repre_ids, list): repre_ids = [repre_ids] - versions = avalon.io.find( + versions = dbcon.find( { "_id": {"$in": repre_ids}, "type": "representation" From 6f86f78860c795f027ac481b1f6494ddd5b6979c Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 10:40:00 +0200 Subject: [PATCH 431/854] Update openpype/lib/avalon_context.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 5ea472f11e..68d38acf35 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -2040,7 +2040,7 @@ def get_linked_ids_for_representations(project, repre_ids, dbcon=None, referenced_version_ids = _process_referenced_pipeline_result(result, link_type) - ref_ids = avalon.io.distinct( + ref_ids = dbcon.distinct( "_id", filter={ "parent": {"$in": list(referenced_version_ids)}, From d14d739e1cfd312390d9ab880da0a589b3c6d567 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 10:40:08 +0200 Subject: [PATCH 432/854] Update openpype/lib/avalon_context.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- 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 68d38acf35..7d562733fc 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1974,7 +1974,7 @@ def get_last_workfile( @with_avalon -def get_linked_ids_for_representations(project, repre_ids, dbcon=None, +def get_linked_ids_for_representations(project_name, repre_ids, dbcon=None, link_type=None, max_depth=0): """Returns list of linked ids of particular type (if provided). From bc0054cd88ca6494305dde5bc992b370de59592b Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 11:05:15 +0200 Subject: [PATCH 433/854] nuke | general: removing redundant review representation --- .../deadline/plugins/publish/submit_publish_job.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index fad4d14ea0..6730c6a7dd 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -509,8 +509,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): most cases, but if not - we create representation from each of them. Arguments: - instance (pyblish.plugin.Instance): instance for which we are - setting representations + instance (dict): instance data for which we are + setting representations exp_files (list): list of expected files Returns: @@ -528,6 +528,14 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # preview video rendering for app in self.aov_filter.keys(): if os.environ.get("AVALON_APP", "") == app: + # no need to add review if baking in nuke present + if ( + app == "nuke" + and instance.get("bakingNukeScripts") + ): + break + + # iteratre all aov filters for aov in self.aov_filter[app]: if re.match( aov, From 35e6b8e42d14188650b5fcf58a5891498514721c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 11:07:22 +0200 Subject: [PATCH 434/854] ftrack: improving asset name if multiple reviewable representation --- .../publish/integrate_ftrack_instances.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index cff7cd32cb..f157f0db22 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -2,7 +2,7 @@ import os import json import copy import pyblish.api - +from pprint import pformat class IntegrateFtrackInstance(pyblish.api.InstancePlugin): """Collect ftrack component data (not integrate yet). @@ -168,7 +168,31 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Change asset name of each new component for review is_first_review_repre = True not_first_components = [] + extended_asset_name = False for repre in review_representations: + # Create copy of base comp item and append it + review_item = copy.deepcopy(base_component_item) + + # condition for multiple reviewable representations + # expand name to better label componenst + if is_first_review_repre and len(review_representations) > 1: + asset_name = review_item["asset_data"]["name"] + # define new extended name + extended_asset_name = "_".join( + (asset_name, repre["name"]) + ) + review_item["asset_data"]["name"] = extended_asset_name + # and rename all already created components + for _ci in component_list: + _ci["asset_data"]["name"] = extended_asset_name + + # and rename all already created src components + for _sci in src_components_to_add: + _sci["asset_data"]["name"] = extended_asset_name + + first_thumbnail_component[ + "asset_data"]["name"] = extended_asset_name + frame_start = repre.get("frameStartFtrack") frame_end = repre.get("frameEndFtrack") if frame_start is None or frame_end is None: @@ -184,8 +208,6 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): if fps is None: fps = instance_fps - # Create copy of base comp item and append it - review_item = copy.deepcopy(base_component_item) # Change location review_item["component_path"] = repre["published_path"] # Change component data @@ -200,8 +222,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): }) } } - # Create copy of item before setting location or changing asset - src_components_to_add.append(copy.deepcopy(review_item)) + + # rename asset name only if multiple reviewable repre if is_first_review_repre: is_first_review_repre = False else: @@ -212,6 +234,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): ) not_first_components.append(review_item) + # Create copy of item before setting location + src_components_to_add.append(copy.deepcopy(review_item)) + # Set location review_item["component_location"] = ftrack_server_location # Add item to component list @@ -249,6 +274,11 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): continue # Create copy of base comp item and append it other_item = copy.deepcopy(base_component_item) + + # add extended name if any + if extended_asset_name: + other_item["asset_data"]["name"] = extended_asset_name + other_item["component_data"] = { "name": repre["name"] } From 44afe82d5a21f8ac4bf393fa35b2357df0c583a5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 11:07:54 +0200 Subject: [PATCH 435/854] OP-2951 - refactored distinct version ids Fixed ordering of referenced versions --- openpype/lib/avalon_context.py | 37 +++++++++++++++------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 7d562733fc..65575493e0 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1980,7 +1980,7 @@ def get_linked_ids_for_representations(project_name, repre_ids, dbcon=None, Goes from representations to version, back to representations Args: - project (str) + project_name (str) repre_ids (list) or (ObjectId) dbcon (avalon.mongodb.AvalonMongoDB, optional): Avalon Mongo connection with Session. @@ -1995,23 +1995,24 @@ def get_linked_ids_for_representations(project_name, repre_ids, dbcon=None, # Make sure is installed dbcon.install() - if dbcon.Session["AVALON_PROJECT"] != project: - dbcon.Session["AVALON_PROJECT"] = project + if dbcon.Session["AVALON_PROJECT"] != project_name: + dbcon.Session["AVALON_PROJECT"] = project_name if not isinstance(repre_ids, list): repre_ids = [repre_ids] - versions = dbcon.find( - { - "_id": {"$in": repre_ids}, - "type": "representation" - }, - projection={"parent": True} - ) - version_ids = [version["parent"] for version in versions] + version_ids = dbcon.distinct("parent", { + "_id": {"$in": repre_ids}, + "type": "representation" + }) + + match = { + "_id": {"$in": version_ids}, + "type": "version" + } graph_lookup = { - "from": project, + "from": project_name, "startWith": "$data.inputLinks.id", "connectFromField": "data.inputLinks.id", "connectToField": "_id", @@ -2024,11 +2025,6 @@ def get_linked_ids_for_representations(project_name, repre_ids, dbcon=None, # for outputs. graph_lookup["maxDepth"] = max_depth - 1 - match = { - "_id": {"$in": version_ids}, - "type": "version" - } - pipeline_ = [ # Match {"$match": match}, @@ -2065,12 +2061,11 @@ def _process_referenced_pipeline_result(result, link_type): link_type, correctly_linked_ids) - # outputs_recursive in random order, sort by _id + # outputs_recursive in random order, sort by depth outputs_recursive = sorted(item.get("outputs_recursive", []), key=lambda d: d["depth"]) - # go from oldest to newest - # only older _id can reference another newer _id - for output in outputs_recursive[::-1]: + + for output in outputs_recursive: if output["_id"] not in correctly_linked_ids: # leaf continue From b69032e9f57c8efcb1b4e88ed26fbbf57c1a59bd Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 11:19:15 +0200 Subject: [PATCH 436/854] hound catch --- .../ftrack/plugins/publish/integrate_ftrack_instances.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index f157f0db22..c0d188c6ab 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -2,7 +2,7 @@ import os import json import copy import pyblish.api -from pprint import pformat + class IntegrateFtrackInstance(pyblish.api.InstancePlugin): """Collect ftrack component data (not integrate yet). From 1994a11fe1a259707d58e369d82cdf77bb865e7e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 11:48:36 +0200 Subject: [PATCH 437/854] modified collect instance --- .../plugins/publish/collect_instances.py | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 9cbfb61550..0008248405 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -20,21 +20,35 @@ class CollectInstances(pyblish.api.ContextPlugin): json.dumps(workfile_instances, indent=4) )) + filtered_instance_data = [] + # Check if there is any created instance + any_created_instance = False # Backwards compatibility for workfiles that already have review # instance in metadata. review_instance_exist = False for instance_data in workfile_instances: - if instance_data["family"] == "review": + family = instance_data["family"] + if family == "review": review_instance_exist = True - break + + elif family in ("renderPass", "renderLayer"): + any_created_instance = True + + else: + self.log.info("Unknown family \"{}\". Skipping {}".format( + family, json.dumps(instance_data, indent=4) + )) + continue + + filtered_instance_data.append(instance_data) # Fake review instance if review was not found in metadata families if not review_instance_exist: - workfile_instances.append( + filtered_instance_data.append( self._create_review_instance_data(context) ) - for instance_data in workfile_instances: + for instance_data in filtered_instance_data: instance_data["fps"] = context.data["sceneFps"] # Store workfile instance data to instance data @@ -42,8 +56,11 @@ class CollectInstances(pyblish.api.ContextPlugin): # Global instance data modifications # Fill families family = instance_data["family"] + families = [family] + if family != "review": + families.append("review") # Add `review` family for thumbnail integration - instance_data["families"] = [family, "review"] + instance_data["families"] = families # Instance name subset_name = instance_data["subset"] @@ -78,7 +95,7 @@ class CollectInstances(pyblish.api.ContextPlugin): # Project name from workfile context project_name = context.data["workfile_context"]["project"] # Host name from environment variable - host_name = os.environ["AVALON_APP"] + host_name = context.data["hostName"] # Use empty variant value variant = "" task_name = io.Session["AVALON_TASK"] @@ -106,12 +123,6 @@ class CollectInstances(pyblish.api.ContextPlugin): instance = self.create_render_pass_instance( context, instance_data ) - else: - raise AssertionError( - "Instance with unknown family \"{}\": {}".format( - family, instance_data - ) - ) if instance is None: continue From a29a7a67f23b7a132b099b7c1487084403ad7b1c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 11:59:27 +0200 Subject: [PATCH 438/854] added collector which collects renderScene family --- .../plugins/publish/collect_scene_render.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py new file mode 100644 index 0000000000..2dcdab5c69 --- /dev/null +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -0,0 +1,104 @@ +import json +import copy +import pyblish.api +from avalon import io + +from openpype.lib import get_subset_name_with_asset_doc + + +class CollectRenderScene(pyblish.api.ContextPlugin): + """Collect instance which renders whole scene in PNG. + + Creates instance with family 'renderScene' which will have all layers + to render which will be composite into one result. The instance is not + collected from scene. + + Scene will be rendered with all visible layers similar way like review is. + + Instance is disabled if there are any created instances of 'renderLayer' + or 'renderPass'. That is because it is expected that this instance is + used as lazy publish of TVPaint file. + + Subset name is created similar way like 'renderLayer' family. It can use + `renderPass` and `renderLayer` keys which can be set using settings and + `variant` is filled using `renderPass` value. + """ + label = "Collect Render Scene" + order = pyblish.api.CollectorOrder - 0.4 + hosts = ["tvpaint"] + + # Settings attributes + enabled = False + # Value of 'renderLayer' for subset name template + render_layer_name = "Scene" + # Value of 'renderPass' for subset name template + render_pass_name = "Beauty" + + def process(self, context): + # Check if there are created instances of renderPass and renderLayer + # - that will define if renderScene instance is enabled after + # collection + any_created_instance = False + for instance in context: + family = instance.data["family"] + if family in ("renderPass", "renderLayer"): + any_created_instance = True + break + + # Global instance data modifications + # Fill families + family = "renderScene" + # Add `review` family for thumbnail integration + families = [family, "review"] + + # Collect asset doc to get asset id + # - not sure if it's good idea to require asset id in + # get_subset_name? + asset_name = context.data["workfile_context"]["asset"] + asset_doc = io.find_one({ + "type": "asset", + "name": asset_name + }) + + # Project name from workfile context + project_name = context.data["workfile_context"]["project"] + # Host name from environment variable + host_name = context.data["hostName"] + # Variant is using render pass name + variant = self.render_pass_name + dynamic_data = { + "renderLayer": self.render_layer_name, + "renderPass": self.render_pass_name, + } + task_name = io.Session["AVALON_TASK"] + subset_name = get_subset_name_with_asset_doc( + family, + variant, + task_name, + asset_doc, + project_name, + host_name, + dynamic_data=dynamic_data + ) + + instance_data = { + "family": family, + "families": families, + "fps": context.data["sceneFps"], + "name": subset_name, + "label": "{} [{}-{}]".format( + subset_name, + context.data["sceneMarkIn"] + 1, + context.data["sceneMarkOut"] + 1 + ), + "active": not any_created_instance, + "publish": not any_created_instance, + "representations": [], + "layers": copy.deepcopy(context.data["layersData"]) + } + + instance = context.create_instance(**instance_data) + + self.log.debug("Created instance: {}\n{}".format( + instance, json.dumps(instance.data, indent=4) + )) From 288fa288c1dcca485febdf588becac109e76daa5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 12:01:53 +0200 Subject: [PATCH 439/854] added renderScene into other tvpaint plugins related to the renderScene logic --- openpype/hosts/tvpaint/plugins/publish/extract_sequence.py | 6 +++--- .../tvpaint/plugins/publish/validate_layers_visibility.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 139dabadee..73daf60567 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -18,7 +18,7 @@ from openpype.hosts.tvpaint.lib import ( class ExtractSequence(pyblish.api.Extractor): label = "Extract Sequence" hosts = ["tvpaint"] - families = ["review", "renderPass", "renderLayer"] + families = ["review", "renderPass", "renderLayer", "renderScene"] # Modifiable with settings review_bg = [255, 255, 255, 255] @@ -159,7 +159,7 @@ class ExtractSequence(pyblish.api.Extractor): # Fill tags and new families tags = [] - if family_lowered in ("review", "renderlayer"): + if family_lowered in ("review", "renderlayer", "renderScene"): tags.append("review") # Sequence of one frame @@ -185,7 +185,7 @@ class ExtractSequence(pyblish.api.Extractor): instance.data["representations"].append(new_repre) - if family_lowered in ("renderpass", "renderlayer"): + if family_lowered in ("renderpass", "renderlayer", "renderscene"): # Change family to render instance.data["family"] = "render" diff --git a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py index 7ea0587b8f..d3a04cc69f 100644 --- a/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py +++ b/openpype/hosts/tvpaint/plugins/publish/validate_layers_visibility.py @@ -8,7 +8,7 @@ class ValidateLayersVisiblity(pyblish.api.InstancePlugin): label = "Validate Layers Visibility" order = pyblish.api.ValidatorOrder - families = ["review", "renderPass", "renderLayer"] + families = ["review", "renderPass", "renderLayer", "renderScene"] def process(self, instance): layer_names = set() From 2951fe77469716bf7e105ccf408f8bcba3f41db8 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 12:26:39 +0200 Subject: [PATCH 440/854] styling tweaks, mostly adding of articles --- website/docs/dev_publishing.md | 104 ++++++++++++++++----------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/website/docs/dev_publishing.md b/website/docs/dev_publishing.md index 710090af40..8ee3b7e85f 100644 --- a/website/docs/dev_publishing.md +++ b/website/docs/dev_publishing.md @@ -5,19 +5,19 @@ sidebar_label: Publishing toc_max_heading_level: 4 --- -Publishing workflow consist of 2 parts: +Publishing workflow consists of 2 parts: - Creating - Mark what will be published and how. -- Publishing - Use data from Creating to go through pyblish process. +- Publishing - Use data from Creating to go through the pyblish process. -OpenPype is using [pyblish](https://pyblish.com/) for publishing process. OpenPype a little bit extend and modify few functions mainly for reports and UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during Creating part instead of in publishing part and has limited plugin actions only for failed validation plugins. +OpenPype is using [pyblish](https://pyblish.com/) for the publishing process. OpenPype extends and modifies its few functions a bit, mainly for reports and UI purposes. The main differences are that OpenPype's publish UI allows to enable/disable instances or plugins during Creating part instead of in the publishing part and has limited plugin actions only for failed validation plugins. ## **Creating** -Concept of Creating does not have to "create" anything but prepare and store metadata about an "instance" (becomes a subset after publish process). Created instance always has `family` which defines what kind of data will be published, best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. In most of hosts are metadata stored to workfile (Maya scene, Nuke script, etc.) to an item or a node the same way so consistency of host implementation is kept, but some features may require different approach that is the reason why it is creator plugin responsibility. Storing the metadata to workfile gives ability to keep values so artist does not have to do create and set what should be published and how over and over. +Concept of Creating does not have to "create" anything yet, but prepare and store metadata about an "instance" (becomes a subset after the publish process). Created instance always has `family` which defines what kind of data will be published, the best example is `workfile` family. Storing of metadata is host specific and may be even a Creator plugin specific. Most hosts are storing metadata into a workfile (Maya scene, Nuke script, etc.) to an item or a node the same way as regular Pyblish instances, so consistency of host implementation is kept, but some features may require a different approach that is the reason why it is creator plugin responsibility. Storing the metadata to the workfile persists values, so the artist does not have to create and set what should be published and how over and over. ### Created instance -Objected representation of created instance metadata defined by class **CreatedInstance**. Has access to **CreateContext** and **BaseCreator** that initialized the object. Is dictionary like object with few immutable keys (marked with start `*` in table). The immutable keys are set by creator plugin or create context on initialization and thei values can't change. Instance can have more arbitrary data, for example ids of nodes in scene but keep in mind that some keys are reserved. +Objected representation of created instance metadata defined by class **CreatedInstance**. Has access to **CreateContext** and **BaseCreator** that initialized the object. Is a dictionary-like object with few immutable keys (marked with start `*` in table). The immutable keys are set by the creator plugin or create context on initialization and their values can't change. Instance can have more arbitrary data, for example ids of nodes in scene but keep in mind that some keys are reserved. | Key | Type | Description | |---|---|---| @@ -25,34 +25,34 @@ Objected representation of created instance metadata defined by class **CreatedI | *instance_id | str | Unique ID of instance. Set automatically on instance creation using `str(uuid.uuid4())` | | *family | str | Instance's family representing type defined by creator plugin. | | *creator_identifier | str | Identifier of creator that collected/created the instance. | -| *creator_attributes | dict | Dictionary of attributes that are defined by creator plugin (`get_instance_attr_defs`). | +| *creator_attributes | dict | Dictionary of attributes that are defined by the creator plugin (`get_instance_attr_defs`). | | *publish_attributes | dict | Dictionary of attributes that are defined by publish plugins. | -| variant | str | Variant is entered by artist on creation and may affect **subset**. | -| subset | str | Name of instance. This name will be used as subset name during publishing. Can be changed on context change or variant change. | -| active | bool | Is instance active and will be published or not. | +| variant | str | Variant is entered by the artist on creation and may affect **subset**. | +| subset | str | Name of instance. This name will be used as a subset name during publishing. Can be changed on context change or variant change. | +| active | bool | Is the instance active and will be published or not. | | asset | str | Name of asset in which context was created. | | task | str | Name of task in which context was created. Can be set to `None`. | :::note -Task should not be required until subset name template expect it. +Task should not be required until the subset name template expects it. ::: -object of **CreatedInstance** has method **data_to_store** which returns dictionary that can be parsed to json string. This method will return all data related to instance so can be re-created using `CreatedInstance.from_existing(data)`. +object of **CreatedInstance** has method **data_to_store** which returns a dictionary that can be parsed to a json string. This method will return all data related to the instance so it can be re-created using `CreatedInstance.from_existing(data)`. #### *Create context* {#category-doc-link} Controller and wrapper around Creating is `CreateContext` which cares about loading of plugins needed for Creating. And validates required functions in host implementation. -Context discovers creator and publish plugins. Trigger collections of existing instances on creators and trigger Creating itself. Also keeps in mind instance objects by their ids. +Context discovers creator and publish plugins. Trigger collections of existing instances on creators and trigger Creating itself. Also it keeps in mind instance objects by their ids. -Creator plugins can call **creator_adds_instance** or **creator_removed_instance** to add/remove instance but these methods are not meant to be called directly out of creator. The reason is that is creator's responsibility to remove metadata or decide if should remove the instance. +Creator plugins can call **creator_adds_instance** or **creator_removed_instance** to add/remove instances but these methods are not meant to be called directly out of the creator. The reason is that it is the creator's responsibility to remove metadata or decide if it should remove the instance. #### Required functions in host implementation -Host implementation **must** have implemented **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instance but are needed for Creating and publishing process. Right now are there stored data about enabled/disabled optional publish plugins. When data are not stored and loaded properly reset of publishing will cause that they will be set to default value. Similar to instance data can be context data also parsed to json string. +Host implementation **must** implement **get_context_data** and **update_context_data**. These two functions are needed to store metadata that are not related to any instance but are needed for Creating and publishing process. Right now only data about enabled/disabled optional publish plugins is stored there. When data is not stored and loaded properly, reset of publishing will cause that they will be set to default value. Context data also parsed to json string similarly as instance data. -There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return string showed in UI as a title. Output string may contain html tags. It is recommended to return context path (it will be created function this purposes) in this order `"{project name}/{asset hierarchy}/{asset name}/{task name}"`. +There are also few optional functions. For UI purposes it is possible to implement **get_context_title** which can return a string shown in UI as a title. Output string may contain html tags. It is recommended to return context path (it will be created function this purposes) in this order `"{project name}/{asset hierarchy}/{asset name}/{task name}"`. -Another optional function is **get_current_context**. This function is handy in hosts where is possible to open multiple workfiles in one process so using global context variables is not relevant because artist can switch between opened workfiles without being acknowledged. When function is not implemented or won't return right keys the global context is used. +Another optional function is **get_current_context**. This function is handy in hosts where it is possible to open multiple workfiles in one process so using global context variables is not relevant because artists can switch between opened workfiles without being acknowledged. When a function is not implemented or won't return the right keys the global context is used. ```json # Expected keys in output { @@ -75,7 +75,7 @@ class WorkfileCreator(Creator): family = "workfile" ``` -- **`collect_instances`** (method) - Collect already existing instances from workfile and add them to create context. This method is called on initialization or reset of **CreateContext**. Each creator is responsible to find it's instances metadata, convert them to **CreatedInstance** object and add the to create context (`self._add_instance_to_context(instnace_obj)`). +- **`collect_instances`** (method) - Collect already existing instances from the workfile and add them to create context. This method is called on initialization or reset of **CreateContext**. Each creator is responsible to find its instance metadata, convert them to **CreatedInstance** object and add them to create context (`self._add_instance_to_context(instnace_obj)`). ```python def collect_instances(self): # Using 'pipeline.list_instances' is just example how to get existing instances from scene @@ -92,7 +92,7 @@ def collect_instances(self): self._add_instance_to_context(instance) ``` -- **`create`** (method) - Create new object of **CreatedInstance** store it's metadata to workfile and add the instance into create context. Failed Creating should raise **CreatorError** if happens error that can artist fix or give him some useful information. Trigger and implementation differs for **Creator** and **AutoCreator**. +- **`create`** (method) - Create a new object of **CreatedInstance** store its metadata to the workfile and add the instance into the created context. Failed Creating should raise **CreatorError** if an error happens that artists can fix or give them some useful information. Triggers and implementation differs for **Creator** and **AutoCreator**. - **`update_instances`** (method) - Update data of instances. Receives tuple with **instance** and **changes**. ```python @@ -145,8 +145,8 @@ When host implementation use universal way how to store and load instances you s **Optional implementations** -- **`enabled`** (attr) - Boolean if creator plugin is enabled and used. -- **`identifier`** (class attr) - Consistent unique string identifier of the creator plugin. Is used to identify source plugin of existing instances. There can't be 2 creator plugins with same identifier. Default implementation returns `family` attribute. +- **`enabled`** (attr) - Boolean if the creator plugin is enabled and used. +- **`identifier`** (class attr) - Consistent unique string identifier of the creator plugin. Is used to identify source plugin of existing instances. There can't be 2 creator plugins with the same identifier. Default implementation returns `family` attribute. ```python class RenderLayerCreator(Creator): family = "render" @@ -158,13 +158,13 @@ class RenderPassCreator(Creator): identifier = "render_pass" ``` -- **`label`** (attr) - String label of creator plugin which will showed in UI, `identifier` is used when not set. It should be possible to use html tags. +- **`label`** (attr) - String label of creator plugin which will show up in UI, `identifier` is used when not set. It should be possible to use html tags. ```python class RenderLayerCreator(Creator): label = "Render Layer" ``` -- **`get_icon`** (attr) - Icon of creator and it's instances. Value can be a path to image file, full name of qtawesome icon, `QPixmap` or `QIcon`. For complex cases or cases when `Qt` objects are returned it is recommended to override `get_icon` method and handle the logic or import `Qt` inside the method to not break headless usage of creator plugin. For list of qtawesome icons check qtawesome github repository (look for used version in pyproject.toml). Default implementation return **icon** attribute. +- **`get_icon`** (attr) - Icon of creator and its instances. Value can be a path to an image file, full name of qtawesome icon, `QPixmap` or `QIcon`. For complex cases or cases when `Qt` objects are returned it is recommended to override `get_icon` method and handle the logic or import `Qt` inside the method to not break headless usage of creator plugin. For list of qtawesome icons check qtawesome github repository (look for the used version in pyproject.toml). Default implementation return **icon** attribute. - **`icon`** (method) - Attribute for default implementation of **get_icon**. ```python class RenderLayerCreator(Creator): @@ -172,7 +172,7 @@ class RenderLayerCreator(Creator): icon = "fa5.building" ``` -- **`get_instance_attr_defs`** (method) - Attribute definitions of instance. Creator can define attribute values with default values for each instance. These attributes may affect how will be instance processed during publishing. Attribute defiitions can be used from `openpype.pipeline.lib.attribute_definitions` (NOTE: Will be moved to `openpype.lib.attribute_definitions` soon). Attribute definitions define basic type of values for different cases e.g. boolean, number, string, enumerator, etc. Default implementations returns **instance_attr_defs**. +- **`get_instance_attr_defs`** (method) - Attribute definitions of instance. Creator can define attribute values with default values for each instance. These attributes may affect how instances will be instance processed during publishing. Attribute defiitions can be used from `openpype.pipeline.lib.attribute_definitions` (NOTE: Will be moved to `openpype.lib.attribute_definitions` soon). Attribute definitions define basic types of values for different cases e.g. boolean, number, string, enumerator, etc. Default implementation returns **instance_attr_defs**. - **`instance_attr_defs`** (attr) - Attribute for default implementation of **get_instance_attr_defs**. ```python @@ -194,16 +194,16 @@ class RenderLayerCreator(Creator): ] ``` -- **`get_subset_name`** (method) - Calculate subset name based on passed data. Data can be extended using `get_dynamic_data` method. Default implementation is using `get_subset_name` from `openpype.lib` which is recommended. +- **`get_subset_name`** (method) - Calculate subset name based on passed data. Data can be extended using the `get_dynamic_data` method. Default implementation is using `get_subset_name` from `openpype.lib` which is recommended. -- **`get_dynamic_data`** (method) - Can be used to extend data for subset template which may be required in some cases. +- **`get_dynamic_data`** (method) - Can be used to extend data for subset templates which may be required in some cases. #### *AutoCreator* -Creator that is triggered on reset of create context. Can be used for families that are expected to be created automatically without artist interaction (e.g. **workfile**). Method `create` is triggered after collecting of all creators. +Creator that is triggered on reset of create context. Can be used for families that are expected to be created automatically without artist interaction (e.g. **workfile**). Method `create` is triggered after collecting all creators. :::important -**AutoCreator** has implemented **remove_instances** to do nothing as removing of auto created instances would lead to create new instance immediately or on refresh. +**AutoCreator** has implemented **remove_instances** to do nothing as removing of auto created instances would lead to creating new instance immediately or on refresh. ::: ```python @@ -273,10 +273,10 @@ def create(self): ``` #### *Creator* -Implementation of creator plugin that is triggered manually by artist in UI (or by code). Has extended options for UI purposes than **AutoCreator** and **create** method expect more arguments. +Implementation of creator plugin that is triggered manually by the artist in UI (or by code). Has extended options for UI purposes than **AutoCreator** and **create** method expect more arguments. **Optional implementations** -- **`create_allow_context_change`** (class attr) - Allow to set context in UI before Creating. Some creator may not allow it or their logic would not use the context selection (e.g. bulk creators). Is set to `True` but default. +- **`create_allow_context_change`** (class attr) - Allow to set context in UI before Creating. Some creators may not allow it or their logic would not use the context selection (e.g. bulk creators). Is set to `True` but default. ```python class BulkRenderCreator(Creator): create_allow_context_change = False @@ -287,7 +287,7 @@ class BulkRenderCreator(Creator): - **`get_default_variant`** (method) - Returns default variant that is prefilled in UI (value does not have to be in default variants). By default returns **default_variant** attribute. If returns `None` then UI logic will take first item from **get_default_variants** if there is any otherwise **"Main"** is used. - **`default_variant`** (attr) - Attribute for default implementation of **get_default_variant**. -- **`get_description`** (method) - Returns short string description of creator. Returns **description** attribute by default. +- **`get_description`** (method) - Returns a short string description of the creator. Returns **description** attribute by default. - **`description`** (attr) - Attribute for default implementation of **get_description**. - **`get_detailed_description`** (method) - Returns detailed string description of creator. Can contain markdown. Returns **detailed_description** attribute by default. @@ -397,34 +397,34 @@ class CreateRender(Creator): OpenPype define few specific exceptions that should be used in publish plugins. #### *Validation exception* -Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that error happened in plugin can be fixed by artist himself (with or without action on plugin). Any other errors will stop publishing immediately. Exception `PublishValidationError` raised after validation order has same effect as any other exception. +Validation plugins should raise `PublishValidationError` to show to an artist what's wrong and give him actions to fix it. The exception says that errors in the plugin can be fixed by the artist himself (with or without action on plugin). Any other errors will stop publishing immediately. The exception `PublishValidationError` raised after validation order has the same effect as any other exception. Exception `PublishValidationError` expects 4 arguments: - **message** Which is not used in UI but for headless publishing. - **title** Short description of error (2-5 words). Title is used for grouping of exceptions per plugin. -- **description** Detailed description of happened issue where markdown and html can be used. -- **detail** Is optional to give even more detailed information for advanced users. At this moment is detail showed under description but it is in plan to have detail in collapsible widget. +- **description** Detailed description of the issue where markdown and html can be used. +- **detail** Is optional to give even more detailed information for advanced users. At this moment the detail is shown directly under description but it is in plan to have detail in a collapsible widget. Extended version is `PublishXmlValidationError` which uses xml files with stored descriptions. This helps to avoid having huge markdown texts inside code. The exception has 4 arguments: -- **plugin** The plugin object which raises the exception to find it's related xml file. +- **plugin** The plugin object which raises the exception to find its related xml file. - **message** Exception message for publishing without UI or different pyblish UI. -- **key** Optional argument says which error from xml is used as validation plugin may raise error with different messages based on the current errors. Default is **"main"**. +- **key** Optional argument says which error from xml is used as a validation plugin may raise error with different messages based on the current errors. Default is **"main"**. - **formatting_data** Optional dictionary to format data in the error. This is used to fill detailed description with data from the publishing so artist can get more precise information. **Where and how to create xml file** -Xml files for `PublishXmlValidationError` must be located in **./help** subfolder next to plugin and the filename must match the filename of plugin. +Xml files for `PublishXmlValidationError` must be located in **./help** subfolder next to the plugin and the filename must match the filename of the plugin. ``` # File location related to plugin file β”” publish - β”œ help - β”‚ β”œ validate_scene.xml - β”‚ β”” ... - β”œ validate_scene.py - β”” ... + β”œ help + β”‚ β”œ validate_scene.xml + β”‚ β”” ... + β”œ validate_scene.py + β”” ... ``` -Xml file content has **<root>** node which may contain any amount of **<error>** nodes, but each of them must have **id** attribute with unique value. That is then used for **key**. Each error must have **<title>** and **<description>** and **<detail>**. Text content may contain python formatting keys that can be filled when exception is raised. +Xml file content has **<root>** node which may contain any amount of **<error>** nodes, but each of them must have **id** attribute with unique value. That is then used for **key**. Each error must have **<title>** and **<description>** and **<detail>**. Text content may contain python formatting keys that can be filled when an exception is raised. ```xml @@ -436,7 +436,7 @@ Context of the given subset doesn't match your current scene. ### How to repair? -Yout can fix this with "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata. +You can fix this with the "Repair" button on the right. This will use '{expected_asset}' asset name and overwrite '{found_asset}' asset name in scene metadata. After that restart publishing with Reload button. @@ -451,15 +451,15 @@ or the scene file was copy pasted from different context. ``` #### *Known errors* -When there is a known error that can't be fixed by user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raise. The only difference is that it's message is shown in UI to artist otherwise a neutral message without context is shown. +When there is a known error that can't be fixed by the user (e.g. can't connect to deadline service, etc.) `KnownPublishError` should be raised. The only difference is that its message is shown in UI to the artist otherwise a neutral message without context is shown. ### Plugin extension -Publish plugins can be extended by additional logic when inherits from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). Publish plugins that inherit from this mixin can define attributes that will be shown in **CreatedInstance**. One of most important usages is to be able turn on/off optional plugins. +Publish plugins can be extended by additional logic when inheriting from `OpenPypePyblishPluginMixin` which can be used as mixin (additional inheritance of class). Publish plugins that inherit from this mixin can define attributes that will be shown in **CreatedInstance**. One of the most important usages is to be able turn on/off optional plugins. -Attributes are defined by return value of `get_attribute_defs` method. Attribute definitions are for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be re-implemented `convert_attribute_values`. Default implementation just converts the values to right types. +Attributes are defined by the return value of `get_attribute_defs` method. Attribute definitions are for families defined in plugin's `families` attribute if it's instance plugin or for whole context if it's context plugin. To convert existing values (or to remove legacy values) can be re-implemented `convert_attribute_values`. Default implementation just converts the values to right types. -:::important -Values of publish attributes from created instance are never removed automatically so implementing of this method is best way to remove legacy data or convert them to new data structure. +:::Important +Values of publish attributes from created instance are never removed automatically so implementing this method is the best way to remove legacy data or convert them to new data structure. ::: Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`. @@ -530,7 +530,7 @@ Main window of publisher shows instances and their values, collected by creators ![Publisher UI - List view](assets/publisher_list_view.png) #### *Instances views* -List of instances always contains `Options` item which is used to show attributes of context plugins. Values from the item are saved and loaded using [host implementation](#required-functions-in-host-implementation) **get_context_data** and **update_context_data**. Instances are grouped by family and can be shown in card view (single selection) or list view (multi selection). +List of instances always contains an `Options` item which is used to show attributes of context plugins. Values from the item are saved and loaded using [host implementation](#required-functions-in-host-implementation) **get_context_data** and **update_context_data**. Instances are grouped by family and can be shown in card view (single selection) or list view (multi selection). Instance view has at the bottom 3 buttons. Plus sign opens [create dialog](#create-dialog), bin removes selected instances and stripes swap card and list view. @@ -538,11 +538,11 @@ Instance view has at the bottom 3 buttons. Plus sign opens [create dialog](#crea It is possible to change variant or asset and task context of instances at the top part but all changes there must be confirmed. Confirmation will trigger recalculation of subset names and all new data are stored to instances. #### *Create attributes* -Instance attributes display all create attributes of all selected instances. All attributes that have same definition are grouped into one input and is visually indicated if values are not same for selected instances. In most of cases have **< Multiselection >** placeholder. +Instance attributes display all created attributes of all selected instances. All attributes that have the same definition are grouped into one input and are visually indicated if values are not the same for selected instances. In most cases have **< Multiselection >** placeholder. #### *Publish attributes* -Publish attributes work the same way as create attributes but the source of attribute definitions are pyblish plugins. Attributes are filtered based on families of selected instances and families defined in pyblish plugin. +Publish attributes work the same way as create attributes but the source of attribute definitions are pyblish plugins. Attributes are filtered based on families of selected instances and families defined in the pyblish plugin. ### Create dialog ![Publisher UI - Create dialog](assets/publisher_create_dialog.png) -Create dialog is used by artist to create new instances in a context. The context selection can be enabled/disabled by changing `create_allow_context_change` on [creator plugin](#creator). In middle part artist select what will be created and what variant it is. On right side is information about selected creator and it's pre-create attributes. There is also question mark button which extends window and display more detailed information about the creator. +Create dialog is used by artist to create new instances in a context. The context selection can be enabled/disabled by changing `create_allow_context_change` on [creator plugin](#creator). In the middle part the artist selects what will be created and what variant it is. On the right side is information about the selected creator and its pre-create attributes. There is also a question mark button which extends the window and displays more detailed information about the creator. \ No newline at end of file From 61d5f32fe868f118aa84722b3ccb07594caff580 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 12:56:18 +0200 Subject: [PATCH 441/854] ftrack: improve conditional rename --- .../ftrack/plugins/publish/integrate_ftrack_instances.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index c0d188c6ab..b1a7da58f9 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -190,8 +190,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): for _sci in src_components_to_add: _sci["asset_data"]["name"] = extended_asset_name - first_thumbnail_component[ - "asset_data"]["name"] = extended_asset_name + # rename also first thumbnail component if any + if first_thumbnail_component is not None: + first_thumbnail_component[ + "asset_data"]["name"] = extended_asset_name frame_start = repre.get("frameStartFtrack") frame_end = repre.get("frameEndFtrack") From 77b7b4b1bc3e6ea467d5a0c38eb3fd96523d69ce Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 13:16:02 +0200 Subject: [PATCH 442/854] Fix - remove doubled dot in workfile created from template --- openpype/lib/avalon_context.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index cc6bcba58e..0348d88be2 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1965,6 +1965,7 @@ def get_last_workfile( data.pop("comment", None) if not data.get("ext"): data["ext"] = extensions[0] + data["ext"] = data["ext"].replace('.', '') filename = StringTemplate.format_strict_template(file_template, data) if full_path: From 7bfc66efed073f47fb1c4e13692020074b155ced Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 13:42:12 +0200 Subject: [PATCH 443/854] Added mention of adding My Drive as a root --- website/docs/module_site_sync.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/website/docs/module_site_sync.md b/website/docs/module_site_sync.md index 78f482352e..2e9cf01102 100644 --- a/website/docs/module_site_sync.md +++ b/website/docs/module_site_sync.md @@ -123,6 +123,10 @@ To get working connection to Google Drive there are some necessary steps: - add new site back in OpenPype Settings, name as you want, provider needs to be 'gdrive' - distribute credentials file via shared mounted disk location +:::note +If you are using regular personal GDrive for testing don't forget adding `/My Drive` as the prefix in root configuration. Business accounts and share drives don't need this. +::: + ### SFTP SFTP provider is used to connect to SFTP server. Currently authentication with `user:password` or `user:ssh key` is implemented. From 82d2a4be19c011378f898020a61b8668c888d109 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 14:19:42 +0200 Subject: [PATCH 444/854] added settings and pass is now defined only by plugin --- .../plugins/publish/collect_scene_render.py | 15 ++++++------ .../defaults/project_settings/tvpaint.json | 4 ++++ .../schema_project_tvpaint.json | 24 +++++++++++++++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index 2dcdab5c69..dc9c63f3bd 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -27,12 +27,13 @@ class CollectRenderScene(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.4 hosts = ["tvpaint"] + # Value of 'render_pass' in subset name template + render_pass = "beauty" + # Settings attributes enabled = False - # Value of 'renderLayer' for subset name template - render_layer_name = "Scene" - # Value of 'renderPass' for subset name template - render_pass_name = "Beauty" + # Value of 'render_layer' and 'variant' in subset name template + render_layer = "Main" def process(self, context): # Check if there are created instances of renderPass and renderLayer @@ -65,10 +66,10 @@ class CollectRenderScene(pyblish.api.ContextPlugin): # Host name from environment variable host_name = context.data["hostName"] # Variant is using render pass name - variant = self.render_pass_name + variant = self.render_layer dynamic_data = { - "renderLayer": self.render_layer_name, - "renderPass": self.render_pass_name, + "render_layer": self.render_layer, + "render_pass": self.render_pass } task_name = io.Session["AVALON_TASK"] subset_name = get_subset_name_with_asset_doc( diff --git a/openpype/settings/defaults/project_settings/tvpaint.json b/openpype/settings/defaults/project_settings/tvpaint.json index 46beeb85b9..88b5a598cd 100644 --- a/openpype/settings/defaults/project_settings/tvpaint.json +++ b/openpype/settings/defaults/project_settings/tvpaint.json @@ -1,6 +1,10 @@ { "stop_timer_on_application_exit": false, "publish": { + "CollectRenderScene": { + "enabled": false, + "render_layer": "Main" + }, "ExtractSequence": { "review_bg": [ 255, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index 97462a8b62..e1166dc2bb 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -16,6 +16,30 @@ "key": "publish", "label": "Publish plugins", "children": [ + { + "type": "dict", + "collapsible": true, + "key": "CollectRenderScene", + "label": "Collect Render Scene", + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "label", + "label": "It is possible to fill 'render_layer' or 'variant' in subset name template with custom value. Value of 'render_pass' is \"Beauty\"." + }, + { + "type": "text", + "key": "render_layer", + "label": "Render Layer" + } + ] + }, { "type": "dict", "collapsible": true, From 53c95cd1ab48fc8ff30fb53fdd4e3da14d64f4a5 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 14:22:15 +0200 Subject: [PATCH 445/854] nuke: adding comments and fixing condition --- .../hosts/nuke/plugins/publish/extract_review_data.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data.py b/openpype/hosts/nuke/plugins/publish/extract_review_data.py index d973e6accd..38a8140cff 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data.py @@ -21,9 +21,15 @@ class ExtractReviewData(openpype.api.Extractor): representations = instance.data.get("representations", []) - if "render.farm" in instance.data["families"]: + # review can be removed since `ProcessSubmittedJobOnFarm` will create + # reviable representation if needed + if ( + "render.farm" in instance.data["families"] + and "review" in instance.data["families"] + ): instance.data["families"].remove("review") + # iterate representations and add `review` tag for repre in representations: if ext != repre["ext"]: continue From 7ce9095115653f0b0ef9e0f5faf26e4d9a24c2fd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 14:27:23 +0200 Subject: [PATCH 446/854] change order --- openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index dc9c63f3bd..38dc431778 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -24,7 +24,7 @@ class CollectRenderScene(pyblish.api.ContextPlugin): `variant` is filled using `renderPass` value. """ label = "Collect Render Scene" - order = pyblish.api.CollectorOrder - 0.4 + order = pyblish.api.CollectorOrder - 0.39 hosts = ["tvpaint"] # Value of 'render_pass' in subset name template From 1bde9c0fa96c3b20101316ceedb0c84b07673882 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 14:30:45 +0200 Subject: [PATCH 447/854] remove unused variable --- .../hosts/tvpaint/plugins/publish/collect_instances.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 0008248405..5e8d13592c 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -21,8 +21,6 @@ class CollectInstances(pyblish.api.ContextPlugin): )) filtered_instance_data = [] - # Check if there is any created instance - any_created_instance = False # Backwards compatibility for workfiles that already have review # instance in metadata. review_instance_exist = False @@ -31,10 +29,7 @@ class CollectInstances(pyblish.api.ContextPlugin): if family == "review": review_instance_exist = True - elif family in ("renderPass", "renderLayer"): - any_created_instance = True - - else: + elif family not in ("renderPass", "renderLayer"): self.log.info("Unknown family \"{}\". Skipping {}".format( family, json.dumps(instance_data, indent=4) )) From d63a0aad71cbc40aac8a8baa46b3dc183eeac977 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 14:42:39 +0200 Subject: [PATCH 448/854] Nuke: adding concurrent tasks attribute to job submission also adding to settings --- .../plugins/publish/submit_nuke_deadline.py | 2 ++ .../defaults/project_settings/deadline.json | 1 + .../projects_schema/schema_project_deadline.json | 14 ++++++++++++++ 3 files changed, 17 insertions(+) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index d6bd11620d..055d3c8a2c 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -27,6 +27,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): # presets priority = 50 chunk_size = 1 + concurent_task = 1 primary_pool = "" secondary_pool = "" group = "" @@ -177,6 +178,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): "Priority": priority, "ChunkSize": chunk_size, + "ConcurrentTasks": self.concurent_task, "Department": self.department, "Pool": self.primary_pool, diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 5bb0a4022e..cfbb92e590 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -62,6 +62,7 @@ "use_published": true, "priority": 50, "chunk_size": 10, + "concurent_task": 1, "primary_pool": "", "secondary_pool": "", "group": "", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index e6097a2b14..700c3863fb 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -192,6 +192,9 @@ "key": "use_published", "label": "Use Published scene" }, + { + "type": "splitter" + }, { "type": "number", "key": "priority", @@ -202,6 +205,14 @@ "key": "chunk_size", "label": "Chunk Size" }, + { + "type": "number", + "key": "concurent_task", + "label": "Number of concurent tasks" + }, + { + "type": "splitter" + }, { "type": "text", "key": "primary_pool", @@ -217,6 +228,9 @@ "key": "group", "label": "Group" }, + { + "type": "splitter" + }, { "type": "text", "key": "department", From 83ad95bed5f180cf90c551b389db970bb0cc92b0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 15:27:35 +0200 Subject: [PATCH 449/854] OP-2895 - added flatten_subset_template to Settings for PS Used as a subset name for ephemeral instance created if no image instance present in a workfile. --- .../defaults/project_settings/photoshop.json | 5 ++++- .../schema_project_photoshop.json | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/openpype/settings/defaults/project_settings/photoshop.json b/openpype/settings/defaults/project_settings/photoshop.json index 822a94a8eb..d9b7a8083f 100644 --- a/openpype/settings/defaults/project_settings/photoshop.json +++ b/openpype/settings/defaults/project_settings/photoshop.json @@ -12,6 +12,9 @@ "flatten_subset_template": "", "color_code_mapping": [] }, + "CollectInstances": { + "flatten_subset_template": "" + }, "ValidateContainers": { "enabled": true, "optional": true, @@ -44,4 +47,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 b499ccc4be..f6e0e51f49 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -108,6 +108,23 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "CollectInstances", + "label": "Collect Instances", + "children": [ + { + "type": "label", + "label": "Name for flatten image created if no image instance present" + }, + { + "type": "text", + "key": "flatten_subset_template", + "label": "Subset template for flatten image" + } + ] + }, { "type": "schema_template", "name": "template_publish_plugin", From 2e35be01507e9af8abec4fa538d2ceb6e8b17ad2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 15:28:32 +0200 Subject: [PATCH 450/854] OP-2895 - added functionality to create flatten image if no instances present --- .../plugins/publish/collect_instances.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index c3e27e9646..f1446f045f 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -1,6 +1,9 @@ +from avalon import api import pyblish.api +from openpype.settings import get_project_settings from openpype.hosts.photoshop import api as photoshop +from openpype.lib import prepare_template_data class CollectInstances(pyblish.api.ContextPlugin): @@ -19,13 +22,16 @@ class CollectInstances(pyblish.api.ContextPlugin): families_mapping = { "image": [] } + flatten_subset_template = "" def process(self, context): stub = photoshop.stub() layers = stub.get_layers() layers_meta = stub.get_layers_metadata() instance_names = [] + all_layer_ids = [] for layer in layers: + all_layer_ids.append(layer.id) layer_data = stub.read(layer, layers_meta) # Skip layers without metadata. @@ -59,3 +65,33 @@ class CollectInstances(pyblish.api.ContextPlugin): if len(instance_names) != len(set(instance_names)): self.log.warning("Duplicate instances found. " + "Remove unwanted via SubsetManager") + + if len(instance_names) == 0 and self.flatten_subset_template: + project_name = context.data["projectEntity"]["name"] + variants = get_project_settings(project_name).get( + "photoshop", {}).get( + "create", {}).get( + "CreateImage", {}).get( + "defaults", ['']) + family = "image" + task_name = api.Session["AVALON_TASK"] + asset_name = context.data["assetEntity"]["name"] + + fill_pairs = { + "variant": variants[0], + "family": family, + "task": task_name + } + + subset = self.flatten_subset_template.format( + **prepare_template_data(fill_pairs)) + + instance = context.create_instance(subset) + instance.data["family"] = family + instance.data["asset"] = asset_name + instance.data["subset"] = subset + instance.data["ids"] = all_layer_ids + instance.data["families"] = self.families_mapping[family] + instance.data["publish"] = True + + self.log.info("flatten instance: {} ".format(instance.data)) From ee0bb56459f170ff0644b64c8b07fa982bcef914 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 15:30:56 +0200 Subject: [PATCH 451/854] OP-2895 - updates for flatten image New ephemeral instance doesn't have any layer, previous implementation depended on it. --- openpype/hosts/photoshop/plugins/publish/extract_image.py | 4 +++- openpype/hosts/photoshop/plugins/publish/extract_review.py | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 04ce77ee34..b07d0740c1 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -26,8 +26,10 @@ class ExtractImage(openpype.api.Extractor): with photoshop.maintained_selection(): self.log.info("Extracting %s" % str(list(instance))) with photoshop.maintained_visibility(): + ids = set() layer = instance.data.get("layer") - ids = set([layer.id]) + if layer: + ids.add(layer.id) add_ids = instance.data.pop("ids", None) if add_ids: ids.update(set(add_ids)) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_review.py b/openpype/hosts/photoshop/plugins/publish/extract_review.py index b8f4470c7b..d076610ead 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_review.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_review.py @@ -155,6 +155,9 @@ class ExtractReview(openpype.api.Extractor): for image_instance in instance.context: if image_instance.data["family"] != "image": continue + if not image_instance.data.get("layer"): + # dummy instance for flatten image + continue layers.append(image_instance.data.get("layer")) return sorted(layers) From f0189f4703bdbee8684ef6d19b96fda2249216a2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 15:42:14 +0200 Subject: [PATCH 452/854] OP-2895 - added documentation --- .../hosts/photoshop/plugins/publish/collect_instances.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/hosts/photoshop/plugins/publish/collect_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_instances.py index f1446f045f..6198ed0156 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_instances.py @@ -12,6 +12,10 @@ class CollectInstances(pyblish.api.ContextPlugin): This collector takes into account assets that are associated with an LayerSet and marked with a unique identifier; + If no image instances are explicitly created, it looks if there is value + in `flatten_subset_template` (configurable in Settings), in that case it + produces flatten image with all visible layers. + Identifier: id (str): "pyblish.avalon.instance" """ @@ -22,6 +26,7 @@ class CollectInstances(pyblish.api.ContextPlugin): families_mapping = { "image": [] } + # configurable in Settings flatten_subset_template = "" def process(self, context): From eb400c2b9eae5fd7e51d15c61c96c2506884226b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 15:46:24 +0200 Subject: [PATCH 453/854] OP-2895 - added documentation --- .../schemas/projects_schema/schema_project_photoshop.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f6e0e51f49..badf94229b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_photoshop.json @@ -42,7 +42,7 @@ "children": [ { "type": "label", - "label": "Set color for publishable layers, set its resulting family and template for subset name. Can create flatten image from published instances" + "label": "Set color for publishable layers, set its resulting family and template for subset name. \nCan create flatten image from published instances.(Applicable only for remote publishing!)" }, { "type": "boolean", From f3f5007af5b3fdc4512adc2fdba85b9a4030e5a0 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 15:47:18 +0200 Subject: [PATCH 454/854] added asset and task on instance data --- .../tvpaint/plugins/publish/collect_scene_render.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index 38dc431778..df3f715853 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -55,7 +55,8 @@ class CollectRenderScene(pyblish.api.ContextPlugin): # Collect asset doc to get asset id # - not sure if it's good idea to require asset id in # get_subset_name? - asset_name = context.data["workfile_context"]["asset"] + workfile_context = context.data["workfile_context"] + asset_name = workfile_context["asset"] asset_doc = io.find_one({ "type": "asset", "name": asset_name @@ -71,7 +72,8 @@ class CollectRenderScene(pyblish.api.ContextPlugin): "render_layer": self.render_layer, "render_pass": self.render_pass } - task_name = io.Session["AVALON_TASK"] + + task_name = workfile_context["task"] subset_name = get_subset_name_with_asset_doc( family, variant, @@ -95,7 +97,9 @@ class CollectRenderScene(pyblish.api.ContextPlugin): "active": not any_created_instance, "publish": not any_created_instance, "representations": [], - "layers": copy.deepcopy(context.data["layersData"]) + "layers": copy.deepcopy(context.data["layersData"]), + "asset": asset_name, + "task": task_name } instance = context.create_instance(**instance_data) From 3dbd2ef7493a620e2de584cff640f3ad0b45ce4f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 15:52:04 +0200 Subject: [PATCH 455/854] OP-2895 - added documentation for simplified workflow --- website/docs/artist_hosts_photoshop.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/website/docs/artist_hosts_photoshop.md b/website/docs/artist_hosts_photoshop.md index b2b5fd58da..a140170c49 100644 --- a/website/docs/artist_hosts_photoshop.md +++ b/website/docs/artist_hosts_photoshop.md @@ -49,6 +49,12 @@ With the `Creator` you have a variety of options to create: - Uncheck `Use selection`. - This will create a single group named after the `Subset` in the `Creator`. +#### Simplified publish + +There is a simplified workflow for simple use case where only single image should be created containing all visible layers. +No image instances must be present in a workfile and `project_settings/photoshop/publish/CollectInstances/flatten_subset_template` must be filled in Settings. +Then artists just need to hit 'Publish' button in menu. + ### Publish When you are ready to share some work, you will need to publish. This is done by opening the `Pyblish` through the extensions `Publish` button. From 486ccff38f6837465698b107c6682ec4e586415b Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 15:52:56 +0200 Subject: [PATCH 456/854] added missing subset key --- openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index df3f715853..9d5c4dbb62 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -88,6 +88,7 @@ class CollectRenderScene(pyblish.api.ContextPlugin): "family": family, "families": families, "fps": context.data["sceneFps"], + "subset": subset_name, "name": subset_name, "label": "{} [{}-{}]".format( subset_name, From ee827e8dd2e3ec5209969f33392c0dea5c952195 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 16:02:19 +0200 Subject: [PATCH 457/854] fix case family --- openpype/hosts/tvpaint/plugins/publish/extract_sequence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py index 73daf60567..d4fd1dff4b 100644 --- a/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py +++ b/openpype/hosts/tvpaint/plugins/publish/extract_sequence.py @@ -159,7 +159,7 @@ class ExtractSequence(pyblish.api.Extractor): # Fill tags and new families tags = [] - if family_lowered in ("review", "renderlayer", "renderScene"): + if family_lowered in ("review", "renderlayer", "renderscene"): tags.append("review") # Sequence of one frame From 81bbdc972f31e4dc5d7c25f6e3a4a136b1ef220d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 16:05:24 +0200 Subject: [PATCH 458/854] fix family used to get subset name --- openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py index 9d5c4dbb62..0af9a9a400 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_scene_render.py @@ -75,7 +75,7 @@ class CollectRenderScene(pyblish.api.ContextPlugin): task_name = workfile_context["task"] subset_name = get_subset_name_with_asset_doc( - family, + "render", variant, task_name, asset_doc, From 8c1fbf88d5e431156254583380ff24ec251832e5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 16:20:15 +0200 Subject: [PATCH 459/854] added more info into label --- .../schemas/projects_schema/schema_project_tvpaint.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json index e1166dc2bb..20fe5b0855 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_tvpaint.json @@ -31,7 +31,7 @@ }, { "type": "label", - "label": "It is possible to fill 'render_layer' or 'variant' in subset name template with custom value. Value of 'render_pass' is \"Beauty\"." + "label": "It is possible to fill 'render_layer' or 'variant' in subset name template with custom value.
- value of 'render_pass' is always \"beauty\"." }, { "type": "text", From 5014dfddf9b2a2c657d557871ae1e137b9415726 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 16:40:14 +0200 Subject: [PATCH 460/854] nuke: adding node knob for concurrent tasks - and fixing misspelling --- openpype/hosts/nuke/api/lib.py | 8 ++++++-- .../deadline/plugins/publish/submit_nuke_deadline.py | 10 ++++++++-- .../settings/defaults/project_settings/deadline.json | 2 +- .../projects_schema/schema_project_deadline.json | 4 ++-- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index c22488f728..9601244d1d 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1048,12 +1048,16 @@ def add_review_knob(node): def add_deadline_tab(node): node.addKnob(nuke.Tab_Knob("Deadline")) + knob = nuke.Int_Knob("deadlinePriority", "Priority") + knob.setValue(50) + node.addKnob(knob) + knob = nuke.Int_Knob("deadlineChunkSize", "Chunk Size") knob.setValue(0) node.addKnob(knob) - knob = nuke.Int_Knob("deadlinePriority", "Priority") - knob.setValue(50) + knob = nuke.Int_Knob("deadlineConcurrentTasks", "Concurrent tasks") + knob.setValue(0) node.addKnob(knob) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 055d3c8a2c..442fcc1ddf 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -27,7 +27,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): # presets priority = 50 chunk_size = 1 - concurent_task = 1 + concurrent_tasks = 1 primary_pool = "" secondary_pool = "" group = "" @@ -154,6 +154,11 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): if chunk_size == 0 and self.chunk_size: chunk_size = self.chunk_size + # define chunk and priority + concurrent_tasks = instance.data.get("deadlineConcurrentTasks") + if concurrent_tasks == 0 and self.concurrent_tasks: + concurrent_tasks = self.concurrent_tasks + priority = instance.data.get("deadlinePriority") if not priority: priority = self.priority @@ -178,7 +183,8 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): "Priority": priority, "ChunkSize": chunk_size, - "ConcurrentTasks": self.concurent_task, + "ConcurrentTasks": concurrent_tasks, + "Department": self.department, "Pool": self.primary_pool, diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index cfbb92e590..efaaa07be6 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -62,7 +62,7 @@ "use_published": true, "priority": 50, "chunk_size": 10, - "concurent_task": 1, + "concurrent_tasks": 1, "primary_pool": "", "secondary_pool": "", "group": "", diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 700c3863fb..ea1173313b 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -207,8 +207,8 @@ }, { "type": "number", - "key": "concurent_task", - "label": "Number of concurent tasks" + "key": "concurrent_tasks", + "label": "Number of concurrent tasks" }, { "type": "splitter" From 6916cc73d3ca4a8edf9aa59a8e3e1066f0cfc4ab Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 16:42:16 +0200 Subject: [PATCH 461/854] Nuke: fixing unicode type detection in effect loaders --- openpype/hosts/nuke/plugins/load/load_effects.py | 3 ++- openpype/hosts/nuke/plugins/load/load_effects_ip.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_effects.py b/openpype/hosts/nuke/plugins/load/load_effects.py index 1ed32996e1..56c5acbb0a 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects.py +++ b/openpype/hosts/nuke/plugins/load/load_effects.py @@ -1,6 +1,7 @@ import json from collections import OrderedDict import nuke +import six from avalon import io @@ -333,7 +334,7 @@ class LoadEffects(load.LoaderPlugin): for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] - elif isinstance(input, str): + elif isinstance(input, six.text_type): return str(input) else: return input diff --git a/openpype/hosts/nuke/plugins/load/load_effects_ip.py b/openpype/hosts/nuke/plugins/load/load_effects_ip.py index 383776111f..0bc5f5a514 100644 --- a/openpype/hosts/nuke/plugins/load/load_effects_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_effects_ip.py @@ -1,6 +1,6 @@ import json from collections import OrderedDict - +import six import nuke from avalon import io @@ -353,7 +353,7 @@ class LoadEffectsInputProcess(load.LoaderPlugin): for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] - elif isinstance(input, str): + elif isinstance(input, six.text_type): return str(input) else: return input From 1947ccc0f2b8aace0b02e1f8c5581fd35913b673 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 16:47:40 +0200 Subject: [PATCH 462/854] OP-2011 - cleaned up settings --- openpype/settings/defaults/project_settings/deadline.json | 2 +- .../schemas/projects_schema/schema_project_deadline.json | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/openpype/settings/defaults/project_settings/deadline.json b/openpype/settings/defaults/project_settings/deadline.json index 053c50ce8b..7311b64046 100644 --- a/openpype/settings/defaults/project_settings/deadline.json +++ b/openpype/settings/defaults/project_settings/deadline.json @@ -77,7 +77,7 @@ "deadline_pool": "", "deadline_group": "", "deadline_chunk_size": 1, - "deadline_priority": 40, + "deadline_priority": 50, "publishing_script": "", "skip_integration_repre_list": [], "aov_filter": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index 0348543c81..e730c42a8a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -120,14 +120,12 @@ { "type": "number", "key": "priority", - "label": "Priority", - "default": 50 + "label": "Priority" }, { "type": "number", "key": "tile_priority", "label": "Tile Assembler Priority", - "default": 50 }, { "type": "text", From ba622d570e5fef83ce0edb460558889fe307e058 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 16:53:24 +0200 Subject: [PATCH 463/854] OP-2011 - changed back default data type --- .../modules/deadline/plugins/publish/submit_maya_deadline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 2d2d70758f..34147712bc 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -257,8 +257,8 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin): priority = 50 tile_priority = 50 limit_groups = [] - jobInfo = None - pluginInfo = None + jobInfo = {} + pluginInfo = {} group = "none" def process(self, instance): From 087c3e5e5ceb4de4d871b6aa5ffd8d0214399cfa Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 16:59:24 +0200 Subject: [PATCH 464/854] OP-2011 - fix typo --- .../schemas/projects_schema/schema_project_deadline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json index e730c42a8a..b54d44d659 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_deadline.json @@ -125,7 +125,7 @@ { "type": "number", "key": "tile_priority", - "label": "Tile Assembler Priority", + "label": "Tile Assembler Priority" }, { "type": "text", From 4fbf9c305087cd3b8e2795ed65ba30347354342d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 17:08:22 +0200 Subject: [PATCH 465/854] nuke: gizmo loader was also having unicode type tests --- openpype/hosts/nuke/plugins/load/load_gizmo_ip.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py index df52a22364..46134afcf0 100644 --- a/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py +++ b/openpype/hosts/nuke/plugins/load/load_gizmo_ip.py @@ -1,5 +1,5 @@ import nuke - +import six from avalon import io from openpype.pipeline import ( @@ -243,8 +243,8 @@ class LoadGizmoInputProcess(load.LoaderPlugin): for key, value in input.items()} elif isinstance(input, list): return [self.byteify(element) for element in input] - elif isinstance(input, unicode): - return input.encode('utf-8') + elif isinstance(input, six.text_type): + return str(input) else: return input From 4783737bf9c64dcd6527ea24eb3efbc74ea38e84 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 17:13:58 +0200 Subject: [PATCH 466/854] OP-2011 - refactored priority --- .../deadline/plugins/publish/submit_publish_job.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 29a276d3b3..542c91b676 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -235,6 +235,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if mongo_url: environment["OPENPYPE_MONGO"] = mongo_url + priority = self.deadline_priority or instance.data.get("priority") + args = [ "--headless", 'publish', @@ -254,7 +256,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "Department": self.deadline_department, "ChunkSize": self.deadline_chunk_size, - "Priority": job["Props"]["Pri"], + "Priority": priority, "Group": self.deadline_group, "Pool": self.deadline_pool, @@ -931,11 +933,6 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): self.deadline_url = instance.data.get("deadlineUrl") assert self.deadline_url, "Requires Deadline Webservice URL" - if self.deadline_priority: - render_job["Props"]["Pri"] = self.deadline_priority - else: - render_job["Props"]["Pri"] = instance.data.get("priority") - self._submit_deadline_post_job(instance, render_job, instances) # publish job file From 2694d9d557633e06ed51f684e30056c443a4a401 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 9 Mar 2022 10:20:58 +0100 Subject: [PATCH 467/854] OP-2868 - added configuration for default variant value to Settings --- .../plugins/create/create_render.py | 12 +++++++++- .../project_settings/aftereffects.json | 7 ++++++ .../schema_project_aftereffects.json | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/aftereffects/plugins/create/create_render.py b/openpype/hosts/aftereffects/plugins/create/create_render.py index c43ada84b5..aee660673b 100644 --- a/openpype/hosts/aftereffects/plugins/create/create_render.py +++ b/openpype/hosts/aftereffects/plugins/create/create_render.py @@ -18,6 +18,16 @@ class RenderCreator(Creator): create_allow_context_change = False + def __init__( + self, create_context, system_settings, project_settings, headless=False + ): + super(RenderCreator, self).__init__(create_context, system_settings, + project_settings, headless) + self._default_variants = (project_settings["aftereffects"] + ["create"] + ["RenderCreator"] + ["defaults"]) + def get_icon(self): return resources.get_openpype_splash_filepath() @@ -79,7 +89,7 @@ class RenderCreator(Creator): self._add_instance_to_context(new_instance) def get_default_variants(self): - return ["Main"] + return self._default_variants def get_instance_attr_defs(self): return [BoolDef("farm", label="Render on farm")] diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json index 6a9a399069..8083aa0972 100644 --- a/openpype/settings/defaults/project_settings/aftereffects.json +++ b/openpype/settings/defaults/project_settings/aftereffects.json @@ -1,4 +1,11 @@ { + "create": { + "RenderCreator": { + "defaults": [ + "Main" + ] + } + }, "publish": { "ValidateSceneSettings": { "enabled": true, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json index 4c4cd225ab..1a3eaef540 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json @@ -5,6 +5,29 @@ "label": "AfterEffects", "is_file": true, "children": [ + { + "type": "dict", + "collapsible": true, + "key": "create", + "label": "Creator plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "RenderCreator", + "label": "Create render", + "children": [ + { + "type": "list", + "key": "defaults", + "label": "Default Variants", + "object_type": "text", + "docstring": "Fill default variant(s) (like 'Main' or 'Default') used in subset name creation." + } + ] + } + ] + }, { "type": "dict", "collapsible": true, From 55246ce4a77e25b6d8f7479f741b64839213f5a2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 17:30:28 +0200 Subject: [PATCH 468/854] Update openpype/lib/avalon_context.py Co-authored-by: Roy Nieterau --- openpype/lib/avalon_context.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 65575493e0..224d8129a7 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -1995,8 +1995,7 @@ def get_linked_ids_for_representations(project_name, repre_ids, dbcon=None, # Make sure is installed dbcon.install() - if dbcon.Session["AVALON_PROJECT"] != project_name: - dbcon.Session["AVALON_PROJECT"] = project_name + dbcon.Session["AVALON_PROJECT"] = project_name if not isinstance(repre_ids, list): repre_ids = [repre_ids] From ea3cae8bc413474673a8242b47985b91dfd1eee6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 17:36:18 +0200 Subject: [PATCH 469/854] remove path existence checks in 'add_implementation_envs' --- openpype/hosts/blender/__init__.py | 4 ++-- openpype/hosts/hiero/__init__.py | 2 +- openpype/hosts/houdini/__init__.py | 4 ++-- openpype/hosts/maya/__init__.py | 2 +- openpype/hosts/nuke/__init__.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/hosts/blender/__init__.py b/openpype/hosts/blender/__init__.py index 3081d3c9ba..0f27882c7e 100644 --- a/openpype/hosts/blender/__init__.py +++ b/openpype/hosts/blender/__init__.py @@ -29,12 +29,12 @@ def add_implementation_envs(env, _app): env.get("OPENPYPE_BLENDER_USER_SCRIPTS") or "" ) for path in openpype_blender_user_scripts.split(os.pathsep): - if path and os.path.exists(path): + if path: previous_user_scripts.add(os.path.normpath(path)) blender_user_scripts = env.get("BLENDER_USER_SCRIPTS") or "" for path in blender_user_scripts.split(os.pathsep): - if path and os.path.exists(path): + if path: previous_user_scripts.add(os.path.normpath(path)) # Remove implementation path from user script paths as is set to diff --git a/openpype/hosts/hiero/__init__.py b/openpype/hosts/hiero/__init__.py index 2d674b3fa7..d2ac82391b 100644 --- a/openpype/hosts/hiero/__init__.py +++ b/openpype/hosts/hiero/__init__.py @@ -10,7 +10,7 @@ def add_implementation_envs(env, _app): ] old_hiero_path = env.get("HIERO_PLUGIN_PATH") or "" for path in old_hiero_path.split(os.pathsep): - if not path or not os.path.exists(path): + if not path: continue norm_path = os.path.normpath(path) diff --git a/openpype/hosts/houdini/__init__.py b/openpype/hosts/houdini/__init__.py index 8c12d13c81..a3ee38db8d 100644 --- a/openpype/hosts/houdini/__init__.py +++ b/openpype/hosts/houdini/__init__.py @@ -15,7 +15,7 @@ def add_implementation_envs(env, _app): old_houdini_menu_path = env.get("HOUDINI_MENU_PATH") or "" for path in old_houdini_path.split(os.pathsep): - if not path or not os.path.exists(path): + if not path: continue norm_path = os.path.normpath(path) @@ -23,7 +23,7 @@ def add_implementation_envs(env, _app): new_houdini_path.append(norm_path) for path in old_houdini_menu_path.split(os.pathsep): - if not path or not os.path.exists(path): + if not path: continue norm_path = os.path.normpath(path) diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index b7d26a7818..c1c82c62e5 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -9,7 +9,7 @@ def add_implementation_envs(env, _app): ] old_python_path = env.get("PYTHONPATH") or "" for path in old_python_path.split(os.pathsep): - if not path or not os.path.exists(path): + if not path: continue norm_path = os.path.normpath(path) diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index 60b37ce1dd..134a6621c4 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -10,7 +10,7 @@ def add_implementation_envs(env, _app): ] old_nuke_path = env.get("NUKE_PATH") or "" for path in old_nuke_path.split(os.pathsep): - if not path or not os.path.exists(path): + if not path: continue norm_path = os.path.normpath(path) From fd420ff0dad0fe9c6e658613f587dfea65132340 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 17:40:54 +0200 Subject: [PATCH 470/854] nuke: adding concurrent task knob input to instance data --- .../nuke/plugins/publish/precollect_writes.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/precollect_writes.py b/openpype/hosts/nuke/plugins/publish/precollect_writes.py index 85e98db7ed..4826b2788f 100644 --- a/openpype/hosts/nuke/plugins/publish/precollect_writes.py +++ b/openpype/hosts/nuke/plugins/publish/precollect_writes.py @@ -128,13 +128,17 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): } group_node = [x for x in instance if x.Class() == "Group"][0] - deadlineChunkSize = 1 + dl_chunk_size = 1 if "deadlineChunkSize" in group_node.knobs(): - deadlineChunkSize = group_node["deadlineChunkSize"].value() + dl_chunk_size = group_node["deadlineChunkSize"].value() - deadlinePriority = 50 + dl_priority = 50 if "deadlinePriority" in group_node.knobs(): - deadlinePriority = group_node["deadlinePriority"].value() + dl_priority = group_node["deadlinePriority"].value() + + dl_concurrent_tasks = 0 + if "deadlineConcurrentTasks" in group_node.knobs(): + dl_concurrent_tasks = group_node["deadlineConcurrentTasks"].value() instance.data.update({ "versionData": version_data, @@ -144,8 +148,9 @@ class CollectNukeWrites(pyblish.api.InstancePlugin): "label": label, "outputType": output_type, "colorspace": colorspace, - "deadlineChunkSize": deadlineChunkSize, - "deadlinePriority": deadlinePriority + "deadlineChunkSize": dl_chunk_size, + "deadlinePriority": dl_priority, + "deadlineConcurrentTasks": dl_concurrent_tasks }) if self.is_prerender(_families_test): From 26de072f8760e76ee46e864f57578708be20fd43 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 17:42:08 +0200 Subject: [PATCH 471/854] OP-2011 - provide safer default --- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 542c91b676..03adc7b168 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -235,7 +235,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): if mongo_url: environment["OPENPYPE_MONGO"] = mongo_url - priority = self.deadline_priority or instance.data.get("priority") + priority = self.deadline_priority or instance.data.get("priority", 50) args = [ "--headless", From bd61eb99d4b88d640785ea77c10b4a1a5657b279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Fri, 1 Apr 2022 17:56:28 +0200 Subject: [PATCH 472/854] fix support for renderman in maya --- openpype/hosts/maya/api/lib_renderproducts.py | 8 ++--- .../maya/plugins/create/create_render.py | 8 +++-- .../publish/validate_rendersettings.py | 3 +- .../plugins/publish/submit_maya_deadline.py | 18 +++++++++++ .../defaults/system_settings/tools.json | 31 ++++++++++++++++++- 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index 0c34998874..8b282094db 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -1069,7 +1069,7 @@ class RenderProductsRenderman(ARenderProducts): default_ext = "exr" displays = cmds.listConnections("rmanGlobals.displays") for aov in displays: - enabled = self._get_attr(aov, "enabled") + enabled = self._get_attr(aov, "enable") if not enabled: continue @@ -1085,7 +1085,7 @@ class RenderProductsRenderman(ARenderProducts): return products - def get_files(self, product, camera): + def get_files(self, product): """Get expected files. In renderman we hack it with prepending path. This path would @@ -1094,13 +1094,13 @@ class RenderProductsRenderman(ARenderProducts): to mess around with this settings anyway and it is enforced in render settings validator. """ - files = super(RenderProductsRenderman, self).get_files(product, camera) + files = super(RenderProductsRenderman, self).get_files(product) layer_data = self.layer_data new_files = [] for file in files: new_file = "{}/{}/{}".format( - layer_data["sceneName"], layer_data["layerName"], file + layer_data.sceneName, layer_data.layerName, file ) new_files.append(new_file) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 9002ae3876..4d3e6dc9f5 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -76,7 +76,7 @@ class CreateRender(plugin.Creator): 'mentalray': 'defaultRenderGlobals.imageFilePrefix', 'vray': 'vraySettings.fileNamePrefix', 'arnold': 'defaultRenderGlobals.imageFilePrefix', - 'renderman': 'defaultRenderGlobals.imageFilePrefix', + 'renderman': 'rmanGlobals.imageFileFormat', 'redshift': 'defaultRenderGlobals.imageFilePrefix' } @@ -84,7 +84,7 @@ class CreateRender(plugin.Creator): 'mentalray': 'maya///{aov_separator}', # noqa 'vray': 'maya///', 'arnold': 'maya///{aov_separator}', # noqa - 'renderman': 'maya///{aov_separator}', + 'renderman': '_..', # this needs `imageOutputDir` set separately 'redshift': 'maya///' # noqa } @@ -463,6 +463,10 @@ class CreateRender(plugin.Creator): self._set_global_output_settings() + if renderer == "renderman": + cmds.setAttr("rmanGlobals.imageOutputDir", + "/maya//", type="string") + def _set_vray_settings(self, asset): # type: (dict) -> None """Sets important settings for Vray.""" diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index e24e88cab7..966ebac95a 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -121,7 +121,8 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error("Animation needs to be enabled. Use the same " "frame for start and end to render single frame") - if not prefix.lower().startswith("maya/"): + if not prefix.lower().startswith("maya/") and \ + renderer != "renderman": invalid = True cls.log.error("Wrong image prefix [ {} ] - " "doesn't start with: 'maya/'".format(prefix)) diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index 15a6f8d828..498397b81b 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -215,6 +215,24 @@ def get_renderer_variables(renderlayer, root): filename_0 = os.path.normpath(os.path.join(root, filename_0)) elif renderer == "renderman": prefix_attr = "rmanGlobals.imageFileFormat" + # NOTE: This is guessing extensions from renderman display types. + # Some of them are just framebuffers, d_texture format can be + # set in display setting. We set those now to None, but it + # should be handled more gracefully. + display_types = { + "d_deepexr": "exr", + "d_it": None, + "d_null": None, + "d_openexr": "exr", + "d_png": "png", + "d_pointcloud": "ptc", + "d_targa": "tga", + "d_texture": None, + "d_tiff": "tif" + } + extension = display_types.get( + cmds.listConnections("rmanDefaultDisplay.displayType")[0] + ) elif renderer == "redshift": # mapping redshift extension dropdown values to strings ext_mapping = ["iff", "exr", "tif", "png", "tga", "jpg"] diff --git a/openpype/settings/defaults/system_settings/tools.json b/openpype/settings/defaults/system_settings/tools.json index 9e08465195..49c00bec7d 100644 --- a/openpype/settings/defaults/system_settings/tools.json +++ b/openpype/settings/defaults/system_settings/tools.json @@ -52,10 +52,39 @@ "environment": {}, "variants": {} }, + "renderman": { + "environment": {}, + "variants": { + "24-3-maya": { + "host_names": [ + "maya" + ], + "app_variants": [ + "maya/2022" + ], + "environment": { + "RFMTREE": { + "windows": "C:\\Program Files\\Pixar\\RenderManForMaya-24.3", + "darwin": "/Applications/Pixar/RenderManForMaya-24.3", + "linux": "/opt/pixar/RenderManForMaya-24.3" + }, + "RMANTREE": { + "windows": "C:\\Program Files\\Pixar\\RenderManProServer-24.3", + "darwin": "/Applications/Pixar/RenderManProServer-24.3", + "linux": "/opt/pixar/RenderManProServer-24.3" + } + } + }, + "__dynamic_keys_labels__": { + "24-3-maya": "24.3 RFM" + } + } + }, "__dynamic_keys_labels__": { "mtoa": "Autodesk Arnold", "vray": "Chaos Group Vray", - "yeti": "Pergrine Labs Yeti" + "yeti": "Pergrine Labs Yeti", + "renderman": "Pixar Renderman" } } } \ No newline at end of file From 23b31bb23bdc1188e25324deef0fd746f8b984b4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Fri, 1 Apr 2022 18:00:50 +0200 Subject: [PATCH 473/854] ftrack: make nicer name only if set in settings --- .../plugins/publish/integrate_ftrack_instances.py | 12 ++++++++++-- .../settings/defaults/project_settings/ftrack.json | 3 ++- .../projects_schema/schema_project_ftrack.json | 6 ++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index b1a7da58f9..5c0b414d86 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -35,6 +35,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "image": "img", "reference": "reference" } + nicer_asset_name = False def process(self, instance): self.log.debug("instance {}".format(instance)) @@ -175,7 +176,11 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # condition for multiple reviewable representations # expand name to better label componenst - if is_first_review_repre and len(review_representations) > 1: + if ( + self.nicer_asset_name is not False + and is_first_review_repre + and len(review_representations) > 1 + ): asset_name = review_item["asset_data"]["name"] # define new extended name extended_asset_name = "_".join( @@ -278,7 +283,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): other_item = copy.deepcopy(base_component_item) # add extended name if any - if extended_asset_name: + if ( + self.nicer_asset_name is not False + and extended_asset_name is not False + ): other_item["asset_data"]["name"] = extended_asset_name other_item["component_data"] = { diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 89bb41a164..e97258b750 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -395,7 +395,8 @@ "vrayproxy": "cache", "redshiftproxy": "cache", "usd": "usd" - } + }, + "nicer_asset_name": false } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index cb59e9d67e..702b1812a2 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -784,6 +784,12 @@ "object_type": { "type": "text" } + }, + { + "type": "boolean", + "key": "nicer_asset_name", + "label": "Nicer Asset name if multiple reviewable", + "default": false } ] } From 80ee8c523ad20df67ddfd763933b47fc4e6a3b0d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 18:07:47 +0200 Subject: [PATCH 474/854] OP-2766 - clean up logging --- openpype/hosts/photoshop/plugins/create/workfile_creator.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/create/workfile_creator.py b/openpype/hosts/photoshop/plugins/create/workfile_creator.py index 2a2fda3cc4..d66a05cad7 100644 --- a/openpype/hosts/photoshop/plugins/create/workfile_creator.py +++ b/openpype/hosts/photoshop/plugins/create/workfile_creator.py @@ -15,7 +15,6 @@ class PSWorkfileCreator(AutoCreator): return [] def collect_instances(self): - print("coll::{}".format(api.list_instances())) for instance_data in api.list_instances(): creator_id = instance_data.get("creator_identifier") if creator_id == self.identifier: @@ -30,7 +29,6 @@ class PSWorkfileCreator(AutoCreator): pass def create(self, options=None): - print("create") existing_instance = None for instance in self.create_context.instances: if instance.family == self.family: From 4cdcb974b384ec628e83f480a24cdca4ddd1e605 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 18:07:51 +0200 Subject: [PATCH 475/854] removed usage of config callback in maya --- openpype/hosts/maya/api/pipeline.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/openpype/hosts/maya/api/pipeline.py b/openpype/hosts/maya/api/pipeline.py index a8834d1ea3..f6f3472eef 100644 --- a/openpype/hosts/maya/api/pipeline.py +++ b/openpype/hosts/maya/api/pipeline.py @@ -9,8 +9,6 @@ import maya.api.OpenMaya as om import pyblish.api import avalon.api -from avalon.lib import find_submodule - import openpype.hosts.maya from openpype.tools.utils import host_tools from openpype.lib import ( @@ -20,7 +18,6 @@ from openpype.lib import ( ) from openpype.lib.path_tools import HostDirmap from openpype.pipeline import ( - LegacyCreator, register_loader_plugin_path, register_inventory_action_path, register_creator_plugin_path, @@ -270,21 +267,8 @@ def ls(): """ container_names = _ls() - - has_metadata_collector = False - config_host = find_submodule(avalon.api.registered_config(), "maya") - if hasattr(config_host, "collect_container_metadata"): - has_metadata_collector = True - for container in sorted(container_names): - data = parse_container(container) - - # Collect custom data if attribute is present - if has_metadata_collector: - metadata = config_host.collect_container_metadata(container) - data.update(metadata) - - yield data + yield parse_container(container) def containerise(name, From b652170492496a7d64caa34b66db334011a1cdde Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 18:09:05 +0200 Subject: [PATCH 476/854] removed usage of avalon config from houdini --- openpype/hosts/houdini/api/pipeline.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/openpype/hosts/houdini/api/pipeline.py b/openpype/hosts/houdini/api/pipeline.py index 8e093a89bc..6a69814e2e 100644 --- a/openpype/hosts/houdini/api/pipeline.py +++ b/openpype/hosts/houdini/api/pipeline.py @@ -7,8 +7,6 @@ import hou import hdefereval import pyblish.api -import avalon.api -from avalon.lib import find_submodule from openpype.pipeline import ( register_creator_plugin_path, @@ -215,24 +213,12 @@ def ls(): "pyblish.mindbender.container"): containers += lib.lsattr("id", identifier) - has_metadata_collector = False - config_host = find_submodule(avalon.api.registered_config(), "houdini") - if hasattr(config_host, "collect_container_metadata"): - has_metadata_collector = True - for container in sorted(containers, # Hou 19+ Python 3 hou.ObjNode are not # sortable due to not supporting greater # than comparisons key=lambda node: node.path()): - data = parse_container(container) - - # Collect custom data if attribute is present - if has_metadata_collector: - metadata = config_host.collect_container_metadata(container) - data.update(metadata) - - yield data + yield parse_container(container) def before_save(): From 9efa30d7569f0025cdd8a2d1f9a970edfdbb1aad Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 18:09:38 +0200 Subject: [PATCH 477/854] OP-2766 - revert unwanted commit --- .../aftereffects/plugins/publish/collect_workfile.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py index 1983851028..c1c2be4855 100644 --- a/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py +++ b/openpype/hosts/aftereffects/plugins/publish/collect_workfile.py @@ -38,13 +38,7 @@ class CollectWorkfile(pyblish.api.ContextPlugin): # workfile instance family = "workfile" - subset = get_subset_name( - family, - "", - task, - context.data["assetEntity"]["_id"], - host_name="photoshop" - ) + subset = family + task.capitalize() # Create instance instance = context.create_instance(subset) From d92ccf8c2ee97e38d236cd20764f9a3432a3e1a3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 1 Apr 2022 18:11:26 +0200 Subject: [PATCH 478/854] OP-2766 - cleanup logging --- openpype/hosts/photoshop/plugins/publish/extract_image.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/hosts/photoshop/plugins/publish/extract_image.py b/openpype/hosts/photoshop/plugins/publish/extract_image.py index 75e6323da7..a133e33409 100644 --- a/openpype/hosts/photoshop/plugins/publish/extract_image.py +++ b/openpype/hosts/photoshop/plugins/publish/extract_image.py @@ -16,8 +16,6 @@ class ExtractImage(openpype.api.Extractor): formats = ["png", "jpg"] def process(self, instance): - print("PPPPPP") - self.log.info("fdfdsfdfs") staging_dir = self.staging_dir(instance) self.log.info("Outputting image to {}".format(staging_dir)) From f3f06444ac7ddd193932549d00dc97a2d827d306 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 18:31:48 +0200 Subject: [PATCH 479/854] created process_context with installation functions --- openpype/pipeline/process_context.py | 333 +++++++++++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 openpype/pipeline/process_context.py diff --git a/openpype/pipeline/process_context.py b/openpype/pipeline/process_context.py new file mode 100644 index 0000000000..65e891c100 --- /dev/null +++ b/openpype/pipeline/process_context.py @@ -0,0 +1,333 @@ +"""Core pipeline functionality""" + +import os +import sys +import json +import types +import logging +import inspect +import platform + +import pyblish.api +from pyblish.lib import MessageHandler + +from avalon import io, Session + +import openpype +from openpype.modules import load_modules +from openpype.settings import get_project_settings +from openpype.lib import ( + Anatomy, + register_event_callback, + filter_pyblish_plugins, + change_timer_to_current_context, +) + +from . import ( + register_loader_plugin_path, + register_inventory_action, + register_creator_plugin_path, + deregister_loader_plugin_path, +) + + +_is_installed = False +_registered_root = {"_": ""} +_registered_host = {"_": None} + +log = logging.getLogger(__name__) + +PACKAGE_DIR = os.path.dirname(os.path.abspath(openpype.__file__)) +PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") + +# Global plugin paths +PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") +LOAD_PATH = os.path.join(PLUGINS_DIR, "load") + + +def register_root(path): + """Register currently active root""" + log.info("Registering root: %s" % path) + _registered_root["_"] = path + + +def registered_root(): + """Return currently registered root""" + root = _registered_root["_"] + if root: + return root + + root = Session.get("AVALON_PROJECTS") + if root: + return os.path.normpath(root) + return "" + + +def install(host): + """Install `host` into the running Python session. + + Args: + host (module): A Python module containing the Avalon + avalon host-interface. + """ + global _is_installed + + io.install() + + missing = list() + for key in ("AVALON_PROJECT", "AVALON_ASSET"): + if key not in Session: + missing.append(key) + + assert not missing, ( + "%s missing from environment, %s" % ( + ", ".join(missing), + json.dumps(Session, indent=4, sort_keys=True) + )) + + project_name = Session["AVALON_PROJECT"] + log.info("Activating %s.." % project_name) + + # Optional host install function + if hasattr(host, "install"): + host.install() + + register_host(host) + + _is_installed = True + + # Make sure modules are loaded + load_modules() + + def modified_emit(obj, record): + """Method replacing `emit` in Pyblish's MessageHandler.""" + record.msg = record.getMessage() + obj.records.append(record) + + MessageHandler.emit = modified_emit + + log.info("Registering global plug-ins..") + pyblish.api.register_plugin_path(PUBLISH_PATH) + pyblish.api.register_discovery_filter(filter_pyblish_plugins) + register_loader_plugin_path(LOAD_PATH) + + project_name = os.environ.get("AVALON_PROJECT") + + # Register studio specific plugins + if project_name: + anatomy = Anatomy(project_name) + anatomy.set_root_environments() + register_root(anatomy.roots) + + project_settings = get_project_settings(project_name) + platform_name = platform.system().lower() + project_plugins = ( + project_settings + .get("global", {}) + .get("project_plugins", {}) + .get(platform_name) + ) or [] + for path in project_plugins: + try: + path = str(path.format(**os.environ)) + except KeyError: + pass + + if not path or not os.path.exists(path): + continue + + pyblish.api.register_plugin_path(path) + register_loader_plugin_path(path) + register_creator_plugin_path(path) + register_inventory_action(path) + + # apply monkey patched discover to original one + log.info("Patching discovery") + + register_event_callback("taskChanged", _on_task_change) + + +def _on_task_change(): + change_timer_to_current_context() + + +def uninstall(): + """Undo all of what `install()` did""" + host = registered_host() + + try: + host.uninstall() + except AttributeError: + pass + + log.info("Deregistering global plug-ins..") + pyblish.api.deregister_plugin_path(PUBLISH_PATH) + pyblish.api.deregister_discovery_filter(filter_pyblish_plugins) + deregister_loader_plugin_path(LOAD_PATH) + log.info("Global plug-ins unregistred") + + deregister_host() + + io.uninstall() + + log.info("Successfully uninstalled Avalon!") + + +def is_installed(): + """Return state of installation + + Returns: + True if installed, False otherwise + + """ + + return _is_installed + + +def register_host(host): + """Register a new host for the current process + + Arguments: + host (ModuleType): A module implementing the + Host API interface. See the Host API + documentation for details on what is + required, or browse the source code. + + """ + signatures = { + "ls": [] + } + + _validate_signature(host, signatures) + _registered_host["_"] = host + + +def _validate_signature(module, signatures): + # Required signatures for each member + + missing = list() + invalid = list() + success = True + + for member in signatures: + if not hasattr(module, member): + missing.append(member) + success = False + + else: + attr = getattr(module, member) + if sys.version_info.major >= 3: + signature = inspect.getfullargspec(attr)[0] + else: + signature = inspect.getargspec(attr)[0] + required_signature = signatures[member] + + assert isinstance(signature, list) + assert isinstance(required_signature, list) + + if not all(member in signature + for member in required_signature): + invalid.append({ + "member": member, + "signature": ", ".join(signature), + "required": ", ".join(required_signature) + }) + success = False + + if not success: + report = list() + + if missing: + report.append( + "Incomplete interface for module: '%s'\n" + "Missing: %s" % (module, ", ".join( + "'%s'" % member for member in missing)) + ) + + if invalid: + report.append( + "'%s': One or more members were found, but didn't " + "have the right argument signature." % module.__name__ + ) + + for member in invalid: + report.append( + " Found: {member}({signature})".format(**member) + ) + report.append( + " Expected: {member}({required})".format(**member) + ) + + raise ValueError("\n".join(report)) + + +def registered_host(): + """Return currently registered host""" + return _registered_host["_"] + + +def deregister_host(): + _registered_host["_"] = default_host() + + +def default_host(): + """A default host, in place of anything better + + This may be considered as reference for the + interface a host must implement. It also ensures + that the system runs, even when nothing is there + to support it. + + """ + + host = types.ModuleType("defaultHost") + + def ls(): + return list() + + host.__dict__.update({ + "ls": ls + }) + + return host + + +def debug_host(): + """A debug host, useful to debugging features that depend on a host""" + + host = types.ModuleType("debugHost") + + def ls(): + containers = [ + { + "representation": "ee-ft-a-uuid1", + "schema": "openpype:container-1.0", + "name": "Bruce01", + "objectName": "Bruce01_node", + "namespace": "_bruce01_", + "version": 3, + }, + { + "representation": "aa-bc-s-uuid2", + "schema": "openpype:container-1.0", + "name": "Bruce02", + "objectName": "Bruce01_node", + "namespace": "_bruce02_", + "version": 2, + } + ] + + for container in containers: + yield container + + host.__dict__.update({ + "ls": ls, + "open_file": lambda fname: None, + "save_file": lambda fname: None, + "current_file": lambda: os.path.expanduser("~/temp.txt"), + "has_unsaved_changes": lambda: False, + "work_root": lambda: os.path.expanduser("~/temp"), + "file_extensions": lambda: ["txt"], + }) + + return host From 85e2601022e0f5bf4596293c30f0bc653992013a Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 1 Apr 2022 18:36:29 +0200 Subject: [PATCH 480/854] =?UTF-8?q?fix=20=F0=9F=90=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../maya/plugins/publish/collect_unreal_skeletalmesh.py | 2 -- .../maya/plugins/publish/extract_unreal_skeletalmesh.py | 6 ------ .../maya/plugins/publish/extract_unreal_staticmesh.py | 3 +-- .../plugins/publish/validate_unreal_staticmesh_naming.py | 9 +-------- 4 files changed, 2 insertions(+), 18 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py index 2b176e3a6d..79693bb35e 100644 --- a/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/collect_unreal_skeletalmesh.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- from maya import cmds # noqa import pyblish.api -from avalon.api import Session -from openpype.api import get_project_settings class CollectUnrealSkeletalMesh(pyblish.api.InstancePlugin): diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index 98dbf117dc..4dcad47e8c 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -7,10 +7,6 @@ from maya import cmds # noqa import pyblish.api import openpype.api -from openpype.hosts.maya.api.lib import ( - parent_nodes, - maintained_selection -) from openpype.hosts.maya.api import fbx @@ -42,8 +38,6 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): geo = instance.data.get("geometry") joints = instance.data.get("joints") - joints_parent = cmds.listRelatives(joints, p=True) - to_extract = geo + joints # The export requires forward slashes because we need diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py index 92fa1b5933..69d51f9ff1 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_staticmesh.py @@ -8,8 +8,7 @@ import pyblish.api import openpype.api from openpype.hosts.maya.api.lib import ( parent_nodes, - maintained_selection, - delete_after + maintained_selection ) from openpype.hosts.maya.api import fbx diff --git a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py index c0eeb82688..43f6c85827 100644 --- a/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py +++ b/openpype/hosts/maya/plugins/publish/validate_unreal_staticmesh_naming.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +"""Validator for correct naming of Static Meshes.""" from maya import cmds # noqa import pyblish.api import openpype.api @@ -71,13 +71,6 @@ class ValidateUnrealStaticMeshName(pyblish.api.InstancePlugin): ["CreateUnrealStaticMesh"] ["collision_prefixes"] ) - static_mesh_prefix = ( - project_settings - ["maya"] - ["create"] - ["CreateUnrealStaticMesh"] - ["static_mesh_prefix"] - ) if cls.validate_mesh: # compile regex for testing names From c49791258a5b714c84085f465e05b43f72f08266 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 19:36:08 +0200 Subject: [PATCH 481/854] changed function names and separated install in 2 parts --- openpype/pipeline/__init__.py | 33 ++++++++++++++++++++++++++++ openpype/pipeline/process_context.py | 26 ++++++++++++---------- 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/openpype/pipeline/__init__.py b/openpype/pipeline/__init__.py index 8460d20ef1..914606cc2f 100644 --- a/openpype/pipeline/__init__.py +++ b/openpype/pipeline/__init__.py @@ -68,6 +68,22 @@ from .actions import ( deregister_inventory_action_path, ) +from .process_context import ( + install_openpype_plugins, + install_host, + uninstall_host, + is_installed, + + register_root, + registered_root, + + register_host, + registered_host, + deregister_host, +) +install = install_host +uninstall = uninstall_host + __all__ = ( "AVALON_CONTAINER_ID", @@ -135,4 +151,21 @@ __all__ = ( "register_inventory_action_path", "deregister_inventory_action", "deregister_inventory_action_path", + + # --- Process context --- + "install_openpype_plugins", + "install_host", + "uninstall_host", + "is_installed", + + "register_root", + "registered_root", + + "register_host", + "registered_host", + "deregister_host", + + # Backwards compatible function names + "install", + "uninstall", ) diff --git a/openpype/pipeline/process_context.py b/openpype/pipeline/process_context.py index 65e891c100..1bef260ec9 100644 --- a/openpype/pipeline/process_context.py +++ b/openpype/pipeline/process_context.py @@ -63,7 +63,7 @@ def registered_root(): return "" -def install(host): +def install_host(host): """Install `host` into the running Python session. Args: @@ -72,6 +72,8 @@ def install(host): """ global _is_installed + _is_installed = True + io.install() missing = list() @@ -94,10 +96,7 @@ def install(host): register_host(host) - _is_installed = True - - # Make sure modules are loaded - load_modules() + register_event_callback("taskChanged", _on_task_change) def modified_emit(obj, record): """Method replacing `emit` in Pyblish's MessageHandler.""" @@ -106,12 +105,20 @@ def install(host): MessageHandler.emit = modified_emit + install_openpype_plugins() + + +def install_openpype_plugins(project_name=None): + # Make sure modules are loaded + load_modules() + log.info("Registering global plug-ins..") pyblish.api.register_plugin_path(PUBLISH_PATH) pyblish.api.register_discovery_filter(filter_pyblish_plugins) register_loader_plugin_path(LOAD_PATH) - project_name = os.environ.get("AVALON_PROJECT") + if project_name is None: + project_name = os.environ.get("AVALON_PROJECT") # Register studio specific plugins if project_name: @@ -141,17 +148,12 @@ def install(host): register_creator_plugin_path(path) register_inventory_action(path) - # apply monkey patched discover to original one - log.info("Patching discovery") - - register_event_callback("taskChanged", _on_task_change) - def _on_task_change(): change_timer_to_current_context() -def uninstall(): +def uninstall_host(): """Undo all of what `install()` did""" host = registered_host() From eabe2fe56960a098608b10599e5d0c1cee550f9a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 19:37:58 +0200 Subject: [PATCH 482/854] changed usage of registered root --- .../vendor/husdoutputprocessors/avalon_uri_processor.py | 3 ++- openpype/lib/usdlib.py | 3 ++- openpype/pipeline/load/utils.py | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py b/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py index 499b733570..8cd51e6641 100644 --- a/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py +++ b/openpype/hosts/houdini/vendor/husdoutputprocessors/avalon_uri_processor.py @@ -134,6 +134,7 @@ class AvalonURIOutputProcessor(base.OutputProcessorBase): """ from avalon import api, io + from openpype.pipeline import registered_root PROJECT = api.Session["AVALON_PROJECT"] asset_doc = io.find_one({"name": asset, @@ -141,7 +142,7 @@ class AvalonURIOutputProcessor(base.OutputProcessorBase): if not asset_doc: raise RuntimeError("Invalid asset name: '%s'" % asset) - root = api.registered_root() + root = registered_root() path = self._template.format(**{ "root": root, "project": PROJECT, diff --git a/openpype/lib/usdlib.py b/openpype/lib/usdlib.py index 89021156b4..7b3b7112de 100644 --- a/openpype/lib/usdlib.py +++ b/openpype/lib/usdlib.py @@ -9,6 +9,7 @@ except ImportError: from mvpxr import Usd, UsdGeom, Sdf, Kind from avalon import io, api +from openpype.pipeline import registered_root log = logging.getLogger(__name__) @@ -323,7 +324,7 @@ def get_usd_master_path(asset, subset, representation): path = template.format( **{ - "root": api.registered_root(), + "root": registered_root(), "project": api.Session["AVALON_PROJECT"], "asset": asset_doc["name"], "subset": subset, diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 53ac6b626d..cb7c76f133 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -10,7 +10,7 @@ import six from bson.objectid import ObjectId from avalon import io, schema -from avalon.api import Session, registered_root +from avalon.api import Session from openpype.lib import Anatomy @@ -532,6 +532,8 @@ def get_representation_path(representation, root=None, dbcon=None): dbcon = io if root is None: + from openpype.pipeline import registered_root + root = registered_root() def path_from_represenation(): From 729131738a5ef8d618f3877da2bb4635e0c2d8be Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 19:40:23 +0200 Subject: [PATCH 483/854] changed installation of hosts --- openpype/hosts/aftereffects/api/lib.py | 4 ++-- openpype/hosts/blender/api/pipeline.py | 4 ++-- .../hosts/blender/blender_addon/startup/init.py | 4 ++-- openpype/hosts/celaction/api/cli.py | 10 ++++------ openpype/hosts/flame/startup/openpype_in_flame.py | 6 +++--- openpype/hosts/fusion/scripts/fusion_switch_shot.py | 8 ++++++-- .../fusion/utility_scripts/__OpenPype_Menu__.py | 13 ++++++------- openpype/hosts/fusion/utility_scripts/switch_ui.py | 6 +++--- openpype/hosts/harmony/api/lib.py | 4 ++-- openpype/hosts/hiero/api/pipeline.py | 9 +-------- .../hiero/api/startup/Python/Startup/Startup.py | 4 ++-- .../hosts/houdini/startup/python2.7libs/pythonrc.py | 4 ++-- .../hosts/houdini/startup/python3.7libs/pythonrc.py | 4 ++-- openpype/hosts/maya/startup/userSetup.py | 5 ++--- openpype/hosts/nuke/startup/menu.py | 4 ++-- openpype/hosts/photoshop/api/lib.py | 5 ++--- .../utility_scripts/OpenPype_sync_util_scripts.py | 5 +++-- .../resolve/utility_scripts/__OpenPype__Menu__.py | 9 ++------- .../utility_scripts/tests/test_otio_as_edl.py | 10 +++++----- .../tests/testing_create_timeline_item_from_path.py | 8 +++----- .../tests/testing_load_media_pool_item.py | 8 +++----- openpype/hosts/tvpaint/api/launch_script.py | 4 ++-- openpype/hosts/tvpaint/api/pipeline.py | 11 ++++------- .../integration/Content/Python/init_unreal.py | 10 ++-------- openpype/hosts/webpublisher/api/__init__.py | 1 - openpype/lib/remote_publish.py | 7 ++----- openpype/tests/test_avalon_plugin_presets.py | 12 +++++------- 27 files changed, 74 insertions(+), 105 deletions(-) diff --git a/openpype/hosts/aftereffects/api/lib.py b/openpype/hosts/aftereffects/api/lib.py index dac6b5d28f..ce4cbf09af 100644 --- a/openpype/hosts/aftereffects/api/lib.py +++ b/openpype/hosts/aftereffects/api/lib.py @@ -6,6 +6,7 @@ import logging from Qt import QtWidgets +from openpype.pipeline import install_host from openpype.lib.remote_publish import headless_publish from openpype.tools.utils import host_tools @@ -22,10 +23,9 @@ def safe_excepthook(*args): def main(*subprocess_args): sys.excepthook = safe_excepthook - import avalon.api from openpype.hosts.aftereffects import api - avalon.api.install(api) + install_host(api) os.environ["OPENPYPE_LOG_NO_COLORS"] = "False" app = QtWidgets.QApplication([]) diff --git a/openpype/hosts/blender/api/pipeline.py b/openpype/hosts/blender/api/pipeline.py index b9ec2cfea4..0ea579970e 100644 --- a/openpype/hosts/blender/api/pipeline.py +++ b/openpype/hosts/blender/api/pipeline.py @@ -19,6 +19,7 @@ from openpype.pipeline import ( deregister_loader_plugin_path, deregister_creator_plugin_path, AVALON_CONTAINER_ID, + uninstall_host, ) from openpype.api import Logger from openpype.lib import ( @@ -209,11 +210,10 @@ def reload_pipeline(*args): """ - avalon.api.uninstall() + uninstall_host() for module in ( "avalon.io", - "avalon.lib", "avalon.pipeline", "avalon.api", ): diff --git a/openpype/hosts/blender/blender_addon/startup/init.py b/openpype/hosts/blender/blender_addon/startup/init.py index e43373bc6c..13a4b8a7a1 100644 --- a/openpype/hosts/blender/blender_addon/startup/init.py +++ b/openpype/hosts/blender/blender_addon/startup/init.py @@ -1,4 +1,4 @@ -from avalon import pipeline +from openpype.pipeline import install_host from openpype.hosts.blender import api -pipeline.install(api) +install_host(api) diff --git a/openpype/hosts/celaction/api/cli.py b/openpype/hosts/celaction/api/cli.py index bc1e3eaf89..85e210f21a 100644 --- a/openpype/hosts/celaction/api/cli.py +++ b/openpype/hosts/celaction/api/cli.py @@ -3,8 +3,6 @@ import sys import copy import argparse -from avalon import io - import pyblish.api import pyblish.util @@ -13,6 +11,8 @@ import openpype import openpype.hosts.celaction from openpype.hosts.celaction import api as celaction from openpype.tools.utils import host_tools +from openpype.pipeline.process_context import install_openpype_plugins + log = Logger().get_logger("Celaction_cli_publisher") @@ -21,9 +21,6 @@ publish_host = "celaction" HOST_DIR = os.path.dirname(os.path.abspath(openpype.hosts.celaction.__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 cli(): @@ -74,7 +71,8 @@ def main(): _prepare_publish_environments() # Registers pype's Global pyblish plugins - openpype.install() + # - use fake host + install_openpype_plugins() if os.path.exists(PUBLISH_PATH): log.info(f"Registering path: {PUBLISH_PATH}") diff --git a/openpype/hosts/flame/startup/openpype_in_flame.py b/openpype/hosts/flame/startup/openpype_in_flame.py index 931c5a1b79..7015abc7f4 100644 --- a/openpype/hosts/flame/startup/openpype_in_flame.py +++ b/openpype/hosts/flame/startup/openpype_in_flame.py @@ -3,16 +3,16 @@ import sys from Qt import QtWidgets from pprint import pformat import atexit -import openpype + import avalon import openpype.hosts.flame.api as opfapi +from openpype.pipeline import install_host def openpype_install(): """Registering OpenPype in context """ - openpype.install() - avalon.api.install(opfapi) + install_host(opfapi) print("Avalon registered hosts: {}".format( avalon.api.registered_host())) diff --git a/openpype/hosts/fusion/scripts/fusion_switch_shot.py b/openpype/hosts/fusion/scripts/fusion_switch_shot.py index ca7efb9136..ca8e5c9e37 100644 --- a/openpype/hosts/fusion/scripts/fusion_switch_shot.py +++ b/openpype/hosts/fusion/scripts/fusion_switch_shot.py @@ -7,6 +7,10 @@ import logging import avalon.api from avalon import io +from openpype.pipeline import ( + install_host, + registered_host, +) from openpype.lib import version_up from openpype.hosts.fusion import api from openpype.hosts.fusion.api import lib @@ -218,7 +222,7 @@ def switch(asset_name, filepath=None, new=True): assert current_comp is not None, ( "Fusion could not load '{}'").format(filepath) - host = avalon.api.registered_host() + host = registered_host() containers = list(host.ls()) assert containers, "Nothing to update" @@ -279,7 +283,7 @@ if __name__ == '__main__': args, unknown = parser.parse_args() - avalon.api.install(api) + install_host(api) switch(args.asset_name, args.file_path) sys.exit(0) diff --git a/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py b/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py index 4b5e8f91a0..aa98563785 100644 --- a/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py +++ b/openpype/hosts/fusion/utility_scripts/__OpenPype_Menu__.py @@ -1,24 +1,23 @@ import os import sys -import openpype from openpype.api import Logger +from openpype.pipeline import ( + install_host, + registered_host, +) log = Logger().get_logger(__name__) def main(env): - import avalon.api from openpype.hosts.fusion import api from openpype.hosts.fusion.api import menu - # Registers pype's Global pyblish plugins - openpype.install() - # activate resolve from pype - avalon.api.install(api) + install_host(api) - log.info(f"Avalon registered hosts: {avalon.api.registered_host()}") + log.info(f"Avalon registered hosts: {registered_host()}") menu.launch_openpype_menu() diff --git a/openpype/hosts/fusion/utility_scripts/switch_ui.py b/openpype/hosts/fusion/utility_scripts/switch_ui.py index d9eeae25ea..37306c7a2a 100644 --- a/openpype/hosts/fusion/utility_scripts/switch_ui.py +++ b/openpype/hosts/fusion/utility_scripts/switch_ui.py @@ -1,14 +1,15 @@ import os +import sys import glob import logging from Qt import QtWidgets, QtCore -import avalon.api from avalon import io import qtawesome as qta from openpype import style +from openpype.pipeline import install_host from openpype.hosts.fusion import api from openpype.lib.avalon_context import get_workdir_from_session @@ -181,8 +182,7 @@ class App(QtWidgets.QWidget): if __name__ == '__main__': - import sys - avalon.api.install(api) + install_host(api) app = QtWidgets.QApplication(sys.argv) window = App() diff --git a/openpype/hosts/harmony/api/lib.py b/openpype/hosts/harmony/api/lib.py index 66eeac1e3a..53fd0f07dd 100644 --- a/openpype/hosts/harmony/api/lib.py +++ b/openpype/hosts/harmony/api/lib.py @@ -183,10 +183,10 @@ def launch(application_path, *args): application_path (str): Path to Harmony. """ - from avalon import api + from openpype.pipeline import install_host from openpype.hosts.harmony import api as harmony - api.install(harmony) + install_host(harmony) ProcessContext.port = random.randrange(49152, 65535) os.environ["AVALON_HARMONY_PORT"] = str(ProcessContext.port) diff --git a/openpype/hosts/hiero/api/pipeline.py b/openpype/hosts/hiero/api/pipeline.py index b334102129..616ff53fd8 100644 --- a/openpype/hosts/hiero/api/pipeline.py +++ b/openpype/hosts/hiero/api/pipeline.py @@ -34,14 +34,7 @@ AVALON_CONTAINERS = ":AVALON_CONTAINERS" def install(): - """ - Installing Hiero integration for avalon - - Args: - config (obj): avalon config module `pype` in our case, it is not - used but required by avalon.api.install() - - """ + """Installing Hiero integration.""" # adding all events events.register_events() diff --git a/openpype/hosts/hiero/api/startup/Python/Startup/Startup.py b/openpype/hosts/hiero/api/startup/Python/Startup/Startup.py index 21c21cd7c3..2e638c2088 100644 --- a/openpype/hosts/hiero/api/startup/Python/Startup/Startup.py +++ b/openpype/hosts/hiero/api/startup/Python/Startup/Startup.py @@ -1,9 +1,9 @@ import traceback # activate hiero from pype -import avalon.api +from openpype.pipeline import install_host import openpype.hosts.hiero.api as phiero -avalon.api.install(phiero) +install_host(phiero) try: __import__("openpype.hosts.hiero.api") diff --git a/openpype/hosts/houdini/startup/python2.7libs/pythonrc.py b/openpype/hosts/houdini/startup/python2.7libs/pythonrc.py index eb33b49759..afadbffd3e 100644 --- a/openpype/hosts/houdini/startup/python2.7libs/pythonrc.py +++ b/openpype/hosts/houdini/startup/python2.7libs/pythonrc.py @@ -1,10 +1,10 @@ -import avalon.api +from openpype.pipeline import install_host from openpype.hosts.houdini import api def main(): print("Installing OpenPype ...") - avalon.api.install(api) + install_host(api) main() diff --git a/openpype/hosts/houdini/startup/python3.7libs/pythonrc.py b/openpype/hosts/houdini/startup/python3.7libs/pythonrc.py index eb33b49759..afadbffd3e 100644 --- a/openpype/hosts/houdini/startup/python3.7libs/pythonrc.py +++ b/openpype/hosts/houdini/startup/python3.7libs/pythonrc.py @@ -1,10 +1,10 @@ -import avalon.api +from openpype.pipeline import install_host from openpype.hosts.houdini import api def main(): print("Installing OpenPype ...") - avalon.api.install(api) + install_host(api) main() diff --git a/openpype/hosts/maya/startup/userSetup.py b/openpype/hosts/maya/startup/userSetup.py index b89244817a..a3ab483add 100644 --- a/openpype/hosts/maya/startup/userSetup.py +++ b/openpype/hosts/maya/startup/userSetup.py @@ -1,11 +1,10 @@ import os -import avalon.api from openpype.api import get_project_settings +from openpype.pipeline import install_host from openpype.hosts.maya import api -import openpype.hosts.maya.api.lib as mlib from maya import cmds -avalon.api.install(api) +install_host(api) print("starting OpenPype usersetup") diff --git a/openpype/hosts/nuke/startup/menu.py b/openpype/hosts/nuke/startup/menu.py index 2cac6d09e7..9ed43b2110 100644 --- a/openpype/hosts/nuke/startup/menu.py +++ b/openpype/hosts/nuke/startup/menu.py @@ -1,7 +1,7 @@ import nuke -import avalon.api from openpype.api import Logger +from openpype.pipeline import install_host from openpype.hosts.nuke import api from openpype.hosts.nuke.api.lib import ( on_script_load, @@ -13,7 +13,7 @@ from openpype.hosts.nuke.api.lib import ( log = Logger.get_logger(__name__) -avalon.api.install(api) +install_host(api) # fix ffmpeg settings on script nuke.addOnScriptLoad(on_script_load) diff --git a/openpype/hosts/photoshop/api/lib.py b/openpype/hosts/photoshop/api/lib.py index 6d2a493a94..2f57d64464 100644 --- a/openpype/hosts/photoshop/api/lib.py +++ b/openpype/hosts/photoshop/api/lib.py @@ -5,9 +5,8 @@ import traceback from Qt import QtWidgets -import avalon.api - from openpype.api import Logger +from openpype.pipeline import install_host from openpype.tools.utils import host_tools from openpype.lib.remote_publish import headless_publish from openpype.lib import env_value_to_bool @@ -24,7 +23,7 @@ def safe_excepthook(*args): def main(*subprocess_args): from openpype.hosts.photoshop import api - avalon.api.install(api) + install_host(api) sys.excepthook = safe_excepthook # coloring in StdOutBroker diff --git a/openpype/hosts/resolve/utility_scripts/OpenPype_sync_util_scripts.py b/openpype/hosts/resolve/utility_scripts/OpenPype_sync_util_scripts.py index ac66916b91..3a16b9c966 100644 --- a/openpype/hosts/resolve/utility_scripts/OpenPype_sync_util_scripts.py +++ b/openpype/hosts/resolve/utility_scripts/OpenPype_sync_util_scripts.py @@ -1,13 +1,14 @@ #!/usr/bin/env python import os import sys -import openpype + +from openpype.pipeline import install_host def main(env): import openpype.hosts.resolve as bmdvr # Registers openpype's Global pyblish plugins - openpype.install() + install_host(bmdvr) bmdvr.setup(env) diff --git a/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py b/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py index b0cef1838a..89ade9238b 100644 --- a/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py +++ b/openpype/hosts/resolve/utility_scripts/__OpenPype__Menu__.py @@ -1,8 +1,7 @@ import os import sys -import avalon.api as avalon -import openpype +from openpype.pipeline import install_host from openpype.api import Logger log = Logger().get_logger(__name__) @@ -10,13 +9,9 @@ log = Logger().get_logger(__name__) def main(env): import openpype.hosts.resolve as bmdvr - # Registers openpype's Global pyblish plugins - openpype.install() # activate resolve from openpype - avalon.install(bmdvr) - - log.info(f"Avalon registered hosts: {avalon.registered_host()}") + install_host(bmdvr) bmdvr.launch_pype_menu() diff --git a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py b/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py index 5430ad32df..8433bd9172 100644 --- a/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py +++ b/openpype/hosts/resolve/utility_scripts/tests/test_otio_as_edl.py @@ -1,9 +1,11 @@ #! python3 import os import sys -import avalon.api as avalon -import openpype + import opentimelineio as otio + +from openpype.pipeline import install_host + from openpype.hosts.resolve import TestGUI import openpype.hosts.resolve as bmdvr from openpype.hosts.resolve.otio import davinci_export as otio_export @@ -14,10 +16,8 @@ class ThisTestGUI(TestGUI): def __init__(self): super(ThisTestGUI, self).__init__() - # Registers openpype's Global pyblish plugins - openpype.install() # activate resolve from openpype - avalon.install(bmdvr) + install_host(bmdvr) def _open_dir_button_pressed(self, event): # selected_path = self.fu.RequestFile(os.path.expanduser("~")) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py b/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py index afa311e0b8..477955d527 100644 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py +++ b/openpype/hosts/resolve/utility_scripts/tests/testing_create_timeline_item_from_path.py @@ -1,8 +1,8 @@ #! python3 import os import sys -import avalon.api as avalon -import openpype + +from openpype.pipeline import install_host from openpype.hosts.resolve import TestGUI import openpype.hosts.resolve as bmdvr import clique @@ -13,10 +13,8 @@ class ThisTestGUI(TestGUI): def __init__(self): super(ThisTestGUI, self).__init__() - # Registers openpype's Global pyblish plugins - openpype.install() # activate resolve from openpype - avalon.install(bmdvr) + install_host(bmdvr) def _open_dir_button_pressed(self, event): # selected_path = self.fu.RequestFile(os.path.expanduser("~")) diff --git a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py b/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py index cfdbe890e5..872d620162 100644 --- a/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py +++ b/openpype/hosts/resolve/utility_scripts/tests/testing_load_media_pool_item.py @@ -1,6 +1,5 @@ #! python3 -import avalon.api as avalon -import openpype +from openpype.pipeline import install_host import openpype.hosts.resolve as bmdvr @@ -15,8 +14,7 @@ def file_processing(fpath): if __name__ == "__main__": path = "C:/CODE/__openpype_projects/jtest03dev/shots/sq01/mainsq01sh030/publish/plate/plateMain/v006/jt3d_mainsq01sh030_plateMain_v006.0996.exr" - openpype.install() # activate resolve from openpype - avalon.install(bmdvr) + install_host(bmdvr) - file_processing(path) \ No newline at end of file + file_processing(path) diff --git a/openpype/hosts/tvpaint/api/launch_script.py b/openpype/hosts/tvpaint/api/launch_script.py index e66bf61df6..0b25027fc6 100644 --- a/openpype/hosts/tvpaint/api/launch_script.py +++ b/openpype/hosts/tvpaint/api/launch_script.py @@ -8,8 +8,8 @@ import logging from Qt import QtWidgets, QtCore, QtGui -from avalon import api from openpype import style +from openpype.pipeline import install_host from openpype.hosts.tvpaint.api.communication_server import ( CommunicationWrapper ) @@ -31,7 +31,7 @@ def main(launch_args): qt_app = QtWidgets.QApplication([]) # Execute pipeline installation - api.install(tvpaint_host) + install_host(tvpaint_host) # Create Communicator object and trigger launch # - this must be done before anything is processed diff --git a/openpype/hosts/tvpaint/api/pipeline.py b/openpype/hosts/tvpaint/api/pipeline.py index cafdf0701d..78c10c3dae 100644 --- a/openpype/hosts/tvpaint/api/pipeline.py +++ b/openpype/hosts/tvpaint/api/pipeline.py @@ -67,11 +67,8 @@ instances=2 def install(): - """Install Maya-specific functionality of avalon-core. + """Install TVPaint-specific functionality.""" - This function is called automatically on calling `api.install(maya)`. - - """ log.info("OpenPype - Installing TVPaint integration") io.install() @@ -96,11 +93,11 @@ def install(): def uninstall(): - """Uninstall TVPaint-specific functionality of avalon-core. - - This function is called automatically on calling `api.uninstall()`. + """Uninstall TVPaint-specific functionality. + This function is called automatically on calling `uninstall_host()`. """ + log.info("OpenPype - Uninstalling TVPaint integration") pyblish.api.deregister_host("tvpaint") pyblish.api.deregister_plugin_path(PUBLISH_PATH) diff --git a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py index 2ecd301c25..4bb03b07ed 100644 --- a/openpype/hosts/unreal/integration/Content/Python/init_unreal.py +++ b/openpype/hosts/unreal/integration/Content/Python/init_unreal.py @@ -2,13 +2,7 @@ import unreal 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.pipeline import install_host from openpype.hosts.unreal import api as openpype_host except ImportError as exc: openpype_host = None @@ -16,7 +10,7 @@ except ImportError as exc: unreal.log_error("OpenPype: cannot load OpenPype [ {} ]".format(exc)) if openpype_detected: - api.install(openpype_host) + install_host(openpype_host) @unreal.uclass() diff --git a/openpype/hosts/webpublisher/api/__init__.py b/openpype/hosts/webpublisher/api/__init__.py index dbeb628073..72bbffd099 100644 --- a/openpype/hosts/webpublisher/api/__init__.py +++ b/openpype/hosts/webpublisher/api/__init__.py @@ -1,7 +1,6 @@ import os import logging -from avalon import api as avalon from avalon import io from pyblish import api as pyblish import openpype.hosts.webpublisher diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py index 9d97671a61..8a42daf4e9 100644 --- a/openpype/lib/remote_publish.py +++ b/openpype/lib/remote_publish.py @@ -1,13 +1,12 @@ import os from datetime import datetime -import sys -from bson.objectid import ObjectId import collections +from bson.objectid import ObjectId + import pyblish.util import pyblish.api -from openpype import uninstall from openpype.lib.mongo import OpenPypeMongoConnection from openpype.lib.plugin_tools import parse_json @@ -81,7 +80,6 @@ def publish(log, close_plugin_name=None): if result["error"]: log.error(error_format.format(**result)) - uninstall() if close_plugin: # close host app explicitly after error context = pyblish.api.Context() close_plugin().process(context) @@ -118,7 +116,6 @@ def publish_and_log(dbcon, _id, log, close_plugin_name=None, batch_id=None): if result["error"]: log.error(error_format.format(**result)) - uninstall() log_lines = [error_format.format(**result)] + log_lines dbcon.update_one( {"_id": _id}, diff --git a/openpype/tests/test_avalon_plugin_presets.py b/openpype/tests/test_avalon_plugin_presets.py index c491be1c05..464c216d6f 100644 --- a/openpype/tests/test_avalon_plugin_presets.py +++ b/openpype/tests/test_avalon_plugin_presets.py @@ -1,6 +1,5 @@ -import avalon.api as api -import openpype from openpype.pipeline import ( + install_host, LegacyCreator, register_creator_plugin, discover_creator_plugins, @@ -23,15 +22,14 @@ class Test: __name__ = "test" ls = len - def __call__(self): - pass + @staticmethod + def install(): + register_creator_plugin(MyTestCreator) def test_avalon_plugin_presets(monkeypatch, printer): + install_host(Test) - openpype.install() - api.register_host(Test()) - register_creator_plugin(MyTestCreator) plugins = discover_creator_plugins() printer("Test if we got our test plugin") assert MyTestCreator in plugins From 331f87bd15c15099eb30d678c871fe3809dba885 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 19:41:18 +0200 Subject: [PATCH 484/854] changed usages of registered host and config --- openpype/hosts/testhost/run_publish.py | 4 ++-- openpype/pype_commands.py | 10 ++++++---- openpype/tools/loader/app.py | 10 ---------- openpype/tools/utils/host_tools.py | 3 ++- openpype/tools/utils/lib.py | 15 ++++++++------- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/openpype/hosts/testhost/run_publish.py b/openpype/hosts/testhost/run_publish.py index 44860a30e4..cc80bdc604 100644 --- a/openpype/hosts/testhost/run_publish.py +++ b/openpype/hosts/testhost/run_publish.py @@ -48,8 +48,8 @@ from openpype.tools.publisher.window import PublisherWindow def main(): """Main function for testing purposes.""" - import avalon.api import pyblish.api + from openpype.pipeline import install_host from openpype.modules import ModulesManager from openpype.hosts.testhost import api as testhost @@ -57,7 +57,7 @@ def main(): for plugin_path in manager.collect_plugin_paths()["publish"]: pyblish.api.register_plugin_path(plugin_path) - avalon.api.install(testhost) + install_host(testhost) QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling) app = QtWidgets.QApplication([]) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index c05eece2be..e0c8847040 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -101,7 +101,8 @@ class PypeCommands: RuntimeError: When there is no path to process. """ from openpype.modules import ModulesManager - from openpype import install, uninstall + from openpype.pipeline import install_openpype_plugins + from openpype.api import Logger from openpype.tools.utils.host_tools import show_publish from openpype.tools.utils.lib import qt_app_context @@ -112,7 +113,7 @@ class PypeCommands: log = Logger.get_logger() - install() + install_openpype_plugins() manager = ModulesManager() @@ -294,7 +295,8 @@ class PypeCommands: # Register target and host import pyblish.api import pyblish.util - import avalon.api + + from openpype.pipeline import install_host from openpype.hosts.webpublisher import api as webpublisher log = PypeLogger.get_logger() @@ -315,7 +317,7 @@ class PypeCommands: for target in targets: pyblish.api.register_target(target) - avalon.api.install(webpublisher) + install_host(webpublisher) log.info("Running publish ...") diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index 923a1fabdb..23c0909f2b 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -608,14 +608,4 @@ def cli(args): # Store settings api.Session["AVALON_PROJECT"] = project - from avalon import pipeline - - # Find the set config - _config = pipeline.find_config() - if hasattr(_config, "install"): - _config.install() - else: - print("Config `%s` has no function `install`" % - _config.__name__) - show() diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 2d9733ec94..b0c30f6dfb 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -6,6 +6,7 @@ use singleton approach with global functions (using helper anyway). import os import avalon.api import pyblish.api +from openpype.pipeline import registered_host from .lib import qt_app_context @@ -47,7 +48,7 @@ class HostToolsHelper: Window, validate_host_requirements ) # Host validation - host = avalon.api.registered_host() + host = registered_host() validate_host_requirements(host) workfiles_window = Window(parent=parent) diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 422d0f5389..12dd637e6a 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -6,16 +6,17 @@ import collections from Qt import QtWidgets, QtCore, QtGui import qtawesome -import avalon.api - -from openpype.style import get_default_entity_icon_color +from openpype.style import ( + get_default_entity_icon_color, + get_objected_colors, +) +from openpype.resources import get_image_path +from openpype.lib import filter_profiles from openpype.api import ( get_project_settings, Logger ) -from openpype.lib import filter_profiles -from openpype.style import get_objected_colors -from openpype.resources import get_image_path +from openpype.pipeline import registered_host log = Logger.get_logger(__name__) @@ -402,7 +403,7 @@ class FamilyConfigCache: self.family_configs.clear() # Skip if we're not in host context - if not avalon.api.registered_host(): + if not registered_host(): return # Update the icons from the project configuration From f9043329b49573a3c3c89406a25bb266e2ca0106 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 19:41:29 +0200 Subject: [PATCH 485/854] removed unused imports --- .../ftrack/event_handlers_user/action_create_folders.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_folders.py b/openpype/modules/ftrack/event_handlers_user/action_create_folders.py index d15a865124..0ed12bd03e 100644 --- a/openpype/modules/ftrack/event_handlers_user/action_create_folders.py +++ b/openpype/modules/ftrack/event_handlers_user/action_create_folders.py @@ -1,11 +1,6 @@ import os from openpype_modules.ftrack.lib import BaseAction, statics_icon -from avalon import lib as avalonlib -from openpype.api import ( - Anatomy, - get_project_settings -) -from openpype.lib import ApplicationManager +from openpype.api import Anatomy class CreateFolders(BaseAction): From adc27e5186d30ba87c41b1f973a69d225f9f1174 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 19:41:46 +0200 Subject: [PATCH 486/854] changed how library loader is shown in tray --- openpype/modules/avalon_apps/avalon_app.py | 47 ++++++++++++---------- openpype/tools/libraryloader/app.py | 10 ----- openpype/tools/libraryloader/lib.py | 21 ---------- 3 files changed, 26 insertions(+), 52 deletions(-) delete mode 100644 openpype/tools/libraryloader/lib.py diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py index 51a22323f1..1d21de129b 100644 --- a/openpype/modules/avalon_apps/avalon_app.py +++ b/openpype/modules/avalon_apps/avalon_app.py @@ -1,5 +1,5 @@ import os -import openpype + from openpype.modules import OpenPypeModule from openpype_interfaces import ITrayModule @@ -26,7 +26,8 @@ class AvalonModule(OpenPypeModule, ITrayModule): self.avalon_mongo_timeout = avalon_mongo_timeout # Tray attributes - self.libraryloader = None + self._library_loader_imported = None + self._library_loader_window = None self.rest_api_obj = None def get_global_environments(self): @@ -41,21 +42,11 @@ class AvalonModule(OpenPypeModule, ITrayModule): def tray_init(self): # Add library tool + self._library_loader_imported = False try: - from Qt import QtCore from openpype.tools.libraryloader import LibraryLoaderWindow - libraryloader = LibraryLoaderWindow( - show_projects=True, - show_libraries=True - ) - # Remove always on top flag for tray - window_flags = libraryloader.windowFlags() - if window_flags | QtCore.Qt.WindowStaysOnTopHint: - window_flags ^= QtCore.Qt.WindowStaysOnTopHint - libraryloader.setWindowFlags(window_flags) - self.libraryloader = libraryloader - + self._library_loader_imported = True except Exception: self.log.warning( "Couldn't load Library loader tool for tray.", @@ -64,7 +55,7 @@ class AvalonModule(OpenPypeModule, ITrayModule): # Definition of Tray menu def tray_menu(self, tray_menu): - if self.libraryloader is None: + if not self._library_loader_imported: return from Qt import QtWidgets @@ -84,17 +75,31 @@ class AvalonModule(OpenPypeModule, ITrayModule): return def show_library_loader(self): - if self.libraryloader is None: - return + if self._library_loader_window is None: + from Qt import QtCore + from openpype.tools.libraryloader import LibraryLoaderWindow + from openpype.pipeline import install_openpype_plugins - self.libraryloader.show() + libraryloader = LibraryLoaderWindow( + show_projects=True, + show_libraries=True + ) + # Remove always on top flag for tray + window_flags = libraryloader.windowFlags() + if window_flags | QtCore.Qt.WindowStaysOnTopHint: + window_flags ^= QtCore.Qt.WindowStaysOnTopHint + libraryloader.setWindowFlags(window_flags) + self._library_loader_window = libraryloader + + install_openpype_plugins() + + self._library_loader_window.show() # Raise and activate the window # for MacOS - self.libraryloader.raise_() + self._library_loader_window.raise_() # for Windows - self.libraryloader.activateWindow() - self.libraryloader.refresh() + self._library_loader_window.activateWindow() # Webserver module implementation def webserver_initialization(self, server_manager): diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py index b73b415128..328e16205c 100644 --- a/openpype/tools/libraryloader/app.py +++ b/openpype/tools/libraryloader/app.py @@ -16,8 +16,6 @@ from openpype.tools.utils.assets_widget import MultiSelectAssetsWidget from openpype.modules import ModulesManager -from . import lib - module = sys.modules[__name__] module.window = None @@ -260,14 +258,6 @@ class LibraryLoaderWindow(QtWidgets.QDialog): self.dbcon.Session["AVALON_PROJECT"] = project_name - _config = lib.find_config() - if hasattr(_config, "install"): - _config.install() - else: - print( - "Config `%s` has no function `install`" % _config.__name__ - ) - self._subsets_widget.on_project_change(project_name) if self._repres_widget: self._repres_widget.on_project_change(project_name) diff --git a/openpype/tools/libraryloader/lib.py b/openpype/tools/libraryloader/lib.py deleted file mode 100644 index 182b48893a..0000000000 --- a/openpype/tools/libraryloader/lib.py +++ /dev/null @@ -1,21 +0,0 @@ -import os -import importlib -import logging - -log = logging.getLogger(__name__) - - -# `find_config` from `pipeline` -def find_config(): - log.info("Finding configuration for project..") - - config = os.environ["AVALON_CONFIG"] - - if not config: - raise EnvironmentError( - "No configuration found in " - "the project nor environment" - ) - - log.info("Found %s, loading.." % config) - return importlib.import_module(config) From 4364cd55c39e686348818ff0ea1b4de49631e396 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 1 Apr 2022 19:42:07 +0200 Subject: [PATCH 487/854] cleaned up openpype init file --- openpype/__init__.py | 97 -------------------------------------------- 1 file changed, 97 deletions(-) diff --git a/openpype/__init__.py b/openpype/__init__.py index 7fc7e63e61..810664707a 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -1,102 +1,5 @@ -# -*- coding: utf-8 -*- -"""Pype module.""" import os -import platform -import logging - -from .settings import get_project_settings -from .lib import ( - Anatomy, - filter_pyblish_plugins, - change_timer_to_current_context, - register_event_callback, -) - -log = logging.getLogger(__name__) PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__)) PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins") - -# Global plugin paths -PUBLISH_PATH = os.path.join(PLUGINS_DIR, "publish") -LOAD_PATH = os.path.join(PLUGINS_DIR, "load") - - -def install(): - """Install OpenPype to Avalon.""" - import avalon.api - import pyblish.api - from pyblish.lib import MessageHandler - from openpype.modules import load_modules - from openpype.pipeline import ( - register_loader_plugin_path, - register_inventory_action, - register_creator_plugin_path, - ) - - # Make sure modules are loaded - load_modules() - - def modified_emit(obj, record): - """Method replacing `emit` in Pyblish's MessageHandler.""" - record.msg = record.getMessage() - obj.records.append(record) - - MessageHandler.emit = modified_emit - - log.info("Registering global plug-ins..") - pyblish.api.register_plugin_path(PUBLISH_PATH) - pyblish.api.register_discovery_filter(filter_pyblish_plugins) - register_loader_plugin_path(LOAD_PATH) - - project_name = os.environ.get("AVALON_PROJECT") - - # Register studio specific plugins - if project_name: - anatomy = Anatomy(project_name) - anatomy.set_root_environments() - avalon.api.register_root(anatomy.roots) - - project_settings = get_project_settings(project_name) - platform_name = platform.system().lower() - project_plugins = ( - project_settings - .get("global", {}) - .get("project_plugins", {}) - .get(platform_name) - ) or [] - for path in project_plugins: - try: - path = str(path.format(**os.environ)) - except KeyError: - pass - - if not path or not os.path.exists(path): - continue - - pyblish.api.register_plugin_path(path) - register_loader_plugin_path(path) - register_creator_plugin_path(path) - register_inventory_action(path) - - # apply monkey patched discover to original one - log.info("Patching discovery") - - register_event_callback("taskChanged", _on_task_change) - - -def _on_task_change(): - change_timer_to_current_context() - - -def uninstall(): - """Uninstall Pype from Avalon.""" - import pyblish.api - from openpype.pipeline import deregister_loader_plugin_path - - log.info("Deregistering global plug-ins..") - pyblish.api.deregister_plugin_path(PUBLISH_PATH) - pyblish.api.deregister_discovery_filter(filter_pyblish_plugins) - deregister_loader_plugin_path(LOAD_PATH) - log.info("Global plug-ins unregistred") From aeb0e4d060ebbb33cc672dc8fbe78077706dfa8e Mon Sep 17 00:00:00 2001 From: OpenPype Date: Sat, 2 Apr 2022 03:39:54 +0000 Subject: [PATCH 488/854] [Automated] Bump version --- CHANGELOG.md | 64 +++++++++++++++++++-------------------------- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 29 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f767bc71d5..d6bbef702a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,34 +1,50 @@ # Changelog -## [3.9.2-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.2-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...HEAD) ### πŸ“– Documentation +- Documentation: Added mention of adding My Drive as a root [\#2999](https://github.com/pypeclub/OpenPype/pull/2999) - Docs: Added MongoDB requirements [\#2951](https://github.com/pypeclub/OpenPype/pull/2951) +- Documentation: New publisher develop docs [\#2896](https://github.com/pypeclub/OpenPype/pull/2896) **πŸ†• New features** -- Multiverse: First PR [\#2908](https://github.com/pypeclub/OpenPype/pull/2908) +- nuke: bypass baking [\#2992](https://github.com/pypeclub/OpenPype/pull/2992) +- Multiverse: Initial Support [\#2908](https://github.com/pypeclub/OpenPype/pull/2908) **πŸš€ Enhancements** +- Photoshop: create image without instance [\#3001](https://github.com/pypeclub/OpenPype/pull/3001) +- TVPaint: Render scene family [\#3000](https://github.com/pypeclub/OpenPype/pull/3000) +- Nuke: ReviewDataMov Read RAW attribute [\#2985](https://github.com/pypeclub/OpenPype/pull/2985) +- General: `METADATA\_KEYS` constant as `frozenset` for optimal immutable lookup [\#2980](https://github.com/pypeclub/OpenPype/pull/2980) +- General: Tools with host filters [\#2975](https://github.com/pypeclub/OpenPype/pull/2975) +- Hero versions: Use custom templates [\#2967](https://github.com/pypeclub/OpenPype/pull/2967) - Slack: Added configurable maximum file size of review upload to Slack [\#2945](https://github.com/pypeclub/OpenPype/pull/2945) - NewPublisher: Prepared implementation of optional pyblish plugin [\#2943](https://github.com/pypeclub/OpenPype/pull/2943) +- TVPaint: Extractor to convert PNG into EXR [\#2942](https://github.com/pypeclub/OpenPype/pull/2942) - Workfiles: Open published workfiles [\#2925](https://github.com/pypeclub/OpenPype/pull/2925) - General: Default modules loaded dynamically [\#2923](https://github.com/pypeclub/OpenPype/pull/2923) -- CI: change the version bump logic [\#2919](https://github.com/pypeclub/OpenPype/pull/2919) -- Deadline: Add headless argument [\#2916](https://github.com/pypeclub/OpenPype/pull/2916) - Nuke: Add no-audio Tag [\#2911](https://github.com/pypeclub/OpenPype/pull/2911) -- Ftrack: Fill workfile in custom attribute [\#2906](https://github.com/pypeclub/OpenPype/pull/2906) - Nuke: improving readability [\#2903](https://github.com/pypeclub/OpenPype/pull/2903) -- Settings UI: Add simple tooltips for settings entities [\#2901](https://github.com/pypeclub/OpenPype/pull/2901) **πŸ› Bug fixes** +- Hosts: Remove path existence checks in 'add\_implementation\_envs' [\#3004](https://github.com/pypeclub/OpenPype/pull/3004) +- Fix - remove doubled dot in workfile created from template [\#2998](https://github.com/pypeclub/OpenPype/pull/2998) +- PS: fix renaming subset incorrectly in PS [\#2991](https://github.com/pypeclub/OpenPype/pull/2991) +- Fix: Disable setuptools auto discovery [\#2990](https://github.com/pypeclub/OpenPype/pull/2990) +- AEL: fix opening existing workfile if no scene opened [\#2989](https://github.com/pypeclub/OpenPype/pull/2989) +- Maya: Don't do hardlinks on windows for look publishing [\#2986](https://github.com/pypeclub/OpenPype/pull/2986) +- Settings UI: Fix version completer on linux [\#2981](https://github.com/pypeclub/OpenPype/pull/2981) +- Photoshop: Fix creation of subset names in PS review and workfile [\#2969](https://github.com/pypeclub/OpenPype/pull/2969) - Slack: Added default for review\_upload\_limit for Slack [\#2965](https://github.com/pypeclub/OpenPype/pull/2965) +- General: OIIO conversion for ffmeg can handle sequences [\#2958](https://github.com/pypeclub/OpenPype/pull/2958) - Settings: Conditional dictionary avoid invalid logs [\#2956](https://github.com/pypeclub/OpenPype/pull/2956) +- General: Smaller fixes and typos [\#2950](https://github.com/pypeclub/OpenPype/pull/2950) - LogViewer: Don't refresh on initialization [\#2949](https://github.com/pypeclub/OpenPype/pull/2949) - nuke: python3 compatibility issue with `iteritems` [\#2948](https://github.com/pypeclub/OpenPype/pull/2948) - General: anatomy data with correct task short key [\#2947](https://github.com/pypeclub/OpenPype/pull/2947) @@ -39,20 +55,21 @@ - Settings UI: Collapsed of collapsible wrapper works as expected [\#2934](https://github.com/pypeclub/OpenPype/pull/2934) - Maya: Do not pass `set` to maya commands \(fixes support for older maya versions\) [\#2932](https://github.com/pypeclub/OpenPype/pull/2932) - General: Don't print log record on OSError [\#2926](https://github.com/pypeclub/OpenPype/pull/2926) -- Hiero: Fix import of 'register\_event\_callback' [\#2924](https://github.com/pypeclub/OpenPype/pull/2924) -- Ftrack: Missing Ftrack id after editorial publish [\#2905](https://github.com/pypeclub/OpenPype/pull/2905) -- AfterEffects: Fix rendering for single frame in DL [\#2875](https://github.com/pypeclub/OpenPype/pull/2875) +- Flame: centos related debugging [\#2922](https://github.com/pypeclub/OpenPype/pull/2922) **πŸ”€ Refactored code** +- General: Move plugins register and discover [\#2935](https://github.com/pypeclub/OpenPype/pull/2935) - General: Move Attribute Definitions from pipeline [\#2931](https://github.com/pypeclub/OpenPype/pull/2931) - General: Removed silo references and terminal splash [\#2927](https://github.com/pypeclub/OpenPype/pull/2927) - General: Move pipeline constants to OpenPype [\#2918](https://github.com/pypeclub/OpenPype/pull/2918) -- General: Move formatting and workfile functions [\#2914](https://github.com/pypeclub/OpenPype/pull/2914) - General: Move remaining plugins from avalon [\#2912](https://github.com/pypeclub/OpenPype/pull/2912) **Merged pull requests:** +- Bump paramiko from 2.9.2 to 2.10.1 [\#2973](https://github.com/pypeclub/OpenPype/pull/2973) +- Bump minimist from 1.2.5 to 1.2.6 in /website [\#2954](https://github.com/pypeclub/OpenPype/pull/2954) +- Bump node-forge from 1.2.1 to 1.3.0 in /website [\#2953](https://github.com/pypeclub/OpenPype/pull/2953) - Maya - added transparency into review creator [\#2952](https://github.com/pypeclub/OpenPype/pull/2952) ## [3.9.1](https://github.com/pypeclub/OpenPype/tree/3.9.1) (2022-03-18) @@ -77,7 +94,6 @@ - General: Remove forgotten use of avalon Creator [\#2885](https://github.com/pypeclub/OpenPype/pull/2885) - General: Avoid circular import [\#2884](https://github.com/pypeclub/OpenPype/pull/2884) - Fixes for attaching loaded containers \(\#2837\) [\#2874](https://github.com/pypeclub/OpenPype/pull/2874) -- Maya: Deformer node ids validation plugin [\#2826](https://github.com/pypeclub/OpenPype/pull/2826) **πŸ”€ Refactored code** @@ -88,10 +104,6 @@ [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.9.0-nightly.9...3.9.0) -**Deprecated:** - -- AssetCreator: Remove the tool [\#2845](https://github.com/pypeclub/OpenPype/pull/2845) - ### πŸ“– Documentation - Documentation: Change Photoshop & AfterEffects plugin path [\#2878](https://github.com/pypeclub/OpenPype/pull/2878) @@ -102,10 +114,6 @@ - NewPublisher: Descriptions and Icons in creator dialog [\#2867](https://github.com/pypeclub/OpenPype/pull/2867) - NewPublisher: Changing task on publishing instance [\#2863](https://github.com/pypeclub/OpenPype/pull/2863) - TrayPublisher: Choose project widget is more clear [\#2859](https://github.com/pypeclub/OpenPype/pull/2859) -- New: Validation exceptions [\#2841](https://github.com/pypeclub/OpenPype/pull/2841) -- Maya: add loaded containers to published instance [\#2837](https://github.com/pypeclub/OpenPype/pull/2837) -- Ftrack: Can sync fps as string [\#2836](https://github.com/pypeclub/OpenPype/pull/2836) -- General: Custom function for find executable [\#2822](https://github.com/pypeclub/OpenPype/pull/2822) **πŸ› Bug fixes** @@ -119,29 +127,11 @@ - General: Fix getattr clalback on dynamic modules [\#2855](https://github.com/pypeclub/OpenPype/pull/2855) - Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853) - WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) -- WebPublisher: Fix wrong number of frames for video file [\#2851](https://github.com/pypeclub/OpenPype/pull/2851) -- Nuke: Fix family test in validate\_write\_legacy to work with stillImage [\#2847](https://github.com/pypeclub/OpenPype/pull/2847) -- Nuke: fix multiple baking profile farm publishing [\#2842](https://github.com/pypeclub/OpenPype/pull/2842) -- Blender: Fixed parameters for FBX export of the camera [\#2840](https://github.com/pypeclub/OpenPype/pull/2840) -- Maya: Stop creation of reviews for Cryptomattes [\#2832](https://github.com/pypeclub/OpenPype/pull/2832) -- Deadline: Remove recreated event [\#2828](https://github.com/pypeclub/OpenPype/pull/2828) -- Deadline: Added missing events folder [\#2827](https://github.com/pypeclub/OpenPype/pull/2827) -- Settings: Missing document with OP versions may break start of OpenPype [\#2825](https://github.com/pypeclub/OpenPype/pull/2825) -- Deadline: more detailed temp file name for environment json [\#2824](https://github.com/pypeclub/OpenPype/pull/2824) -- General: Host name was formed from obsolete code [\#2821](https://github.com/pypeclub/OpenPype/pull/2821) -- Settings UI: Fix "Apply from" action [\#2820](https://github.com/pypeclub/OpenPype/pull/2820) -- Ftrack: Job killer with missing user [\#2819](https://github.com/pypeclub/OpenPype/pull/2819) -- Nuke: Use AVALON\_APP to get value for "app" key [\#2818](https://github.com/pypeclub/OpenPype/pull/2818) **πŸ”€ Refactored code** - Refactor: move webserver tool to openpype [\#2876](https://github.com/pypeclub/OpenPype/pull/2876) - General: Move create logic from avalon to OpenPype [\#2854](https://github.com/pypeclub/OpenPype/pull/2854) -- General: Add vendors from avalon [\#2848](https://github.com/pypeclub/OpenPype/pull/2848) -- General: Basic event system [\#2846](https://github.com/pypeclub/OpenPype/pull/2846) -- General: Move change context functions [\#2839](https://github.com/pypeclub/OpenPype/pull/2839) -- Tools: Don't use avalon tools code [\#2829](https://github.com/pypeclub/OpenPype/pull/2829) -- Move Unreal Implementation to OpenPype [\#2823](https://github.com/pypeclub/OpenPype/pull/2823) ## [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 6d55672aca..c7ee5f0415 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.2-nightly.3" +__version__ = "3.9.2-nightly.4" diff --git a/pyproject.toml b/pyproject.toml index 479cd731fe..01bbaf48ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.2-nightly.3" # OpenPype +version = "3.9.2-nightly.4" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 986c3287494edb194724c402ae50615c38c07a42 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Sat, 2 Apr 2022 12:04:25 +0200 Subject: [PATCH 489/854] Resolve environment variable in credential path with accre --- openpype/modules/sync_server/providers/gdrive.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 0b586613b5..6a8d2b3422 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -3,7 +3,7 @@ import os.path import time import sys import six -import platform +import acre from openpype.api import Logger from openpype.api import get_system_settings @@ -73,8 +73,13 @@ class GDriveHandler(AbstractProvider): format(site_name)) return - cred_path = self.presets.get("credentials_url", {}).\ - get(platform.system().lower()) or '' + cred_data = { + 'cred_path': self.presets.get("credentials_url", {}) + } + cred_data = acre.parse(cred_data) + cred_data = acre.merge(cred_data, current_env=os.environ) + cred_path = cred_data['cred_path'] + if not os.path.exists(cred_path): msg = "Sync Server: No credentials for gdrive provider " + \ "for '{}' on path '{}'!".format(site_name, cred_path) From 8253ee7d3a7f08531b215acf243196b5fb497a03 Mon Sep 17 00:00:00 2001 From: OpenPype Date: Mon, 4 Apr 2022 08:19:23 +0000 Subject: [PATCH 490/854] [Automated] Release --- CHANGELOG.md | 5 ++--- openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bbef702a..88623cee3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ # Changelog -## [3.9.2-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.9.2](https://github.com/pypeclub/OpenPype/tree/3.9.2) (2022-04-04) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.9.1...3.9.2) ### πŸ“– Documentation @@ -126,7 +126,6 @@ - New Publisher: Error dialog got right styles [\#2857](https://github.com/pypeclub/OpenPype/pull/2857) - General: Fix getattr clalback on dynamic modules [\#2855](https://github.com/pypeclub/OpenPype/pull/2855) - Nuke: slate resolution to input video resolution [\#2853](https://github.com/pypeclub/OpenPype/pull/2853) -- WebPublisher: Fix username stored in DB [\#2852](https://github.com/pypeclub/OpenPype/pull/2852) **πŸ”€ Refactored code** diff --git a/openpype/version.py b/openpype/version.py index c7ee5f0415..3d759096c8 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.9.2-nightly.4" +__version__ = "3.9.2" diff --git a/pyproject.toml b/pyproject.toml index 01bbaf48ae..520234325c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.9.2-nightly.4" # OpenPype +version = "3.9.2" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 8ec4d9c8d4bc203c820910ac3b5bd879cfa4b210 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Apr 2022 10:59:43 +0200 Subject: [PATCH 491/854] remove irrelevant comment --- openpype/hosts/celaction/api/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/celaction/api/cli.py b/openpype/hosts/celaction/api/cli.py index 85e210f21a..ef73c7457a 100644 --- a/openpype/hosts/celaction/api/cli.py +++ b/openpype/hosts/celaction/api/cli.py @@ -71,7 +71,6 @@ def main(): _prepare_publish_environments() # Registers pype's Global pyblish plugins - # - use fake host install_openpype_plugins() if os.path.exists(PUBLISH_PATH): From f186b99e22ab82d58e89fee7308356e6479042b4 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Apr 2022 11:16:59 +0200 Subject: [PATCH 492/854] nuke: add comment to code --- openpype/hosts/nuke/api/lib.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 9601244d1d..b1717ea7ff 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1057,12 +1057,19 @@ def add_deadline_tab(node): node.addKnob(knob) knob = nuke.Int_Knob("deadlineConcurrentTasks", "Concurrent tasks") + # zero as default will trigger value from Setting during collection + # look to precollect_write.py knob.setValue(0) node.addKnob(knob) def get_deadline_knob_names(): - return ["Deadline", "deadlineChunkSize", "deadlinePriority"] + return [ + "Deadline", + "deadlineChunkSize", + "deadlinePriority", + "deadlineConcurrentTasks" + ] def create_backdrop(label="", color=None, layer=0, From 71384d8cd6b049f5341167b583a3c830d7104c11 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Apr 2022 11:32:39 +0200 Subject: [PATCH 493/854] deadline: adding strict method for getting --- .../deadline/plugins/publish/submit_nuke_deadline.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 442fcc1ddf..9b5800c33f 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -150,16 +150,16 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin): pass # define chunk and priority - chunk_size = instance.data.get("deadlineChunkSize") + chunk_size = instance.data["deadlineChunkSize"] if chunk_size == 0 and self.chunk_size: chunk_size = self.chunk_size # define chunk and priority - concurrent_tasks = instance.data.get("deadlineConcurrentTasks") + concurrent_tasks = instance.data["deadlineConcurrentTasks"] if concurrent_tasks == 0 and self.concurrent_tasks: concurrent_tasks = self.concurrent_tasks - priority = instance.data.get("deadlinePriority") + priority = instance.data["deadlinePriority"] if not priority: priority = self.priority From 97dca92bbe930b2063119dceba2c306a0ea69e6c Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Apr 2022 11:56:42 +0200 Subject: [PATCH 494/854] global | nuke: generalizing attribute detection --- .../hosts/nuke/plugins/publish/extract_review_data_mov.py | 1 + .../modules/deadline/plugins/publish/submit_publish_job.py | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py index 31a8ff18ee..22b371d8e9 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py +++ b/openpype/hosts/nuke/plugins/publish/extract_review_data_mov.py @@ -123,6 +123,7 @@ class ExtractReviewDataMov(openpype.api.Extractor): if generated_repres: # assign to representations instance.data["representations"] += generated_repres + instance.data["hasReviewableRepresentations"] = True else: instance.data["families"].remove("review") self.log.info(( diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 6730c6a7dd..5755619292 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -528,11 +528,8 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): # preview video rendering for app in self.aov_filter.keys(): if os.environ.get("AVALON_APP", "") == app: - # no need to add review if baking in nuke present - if ( - app == "nuke" - and instance.get("bakingNukeScripts") - ): + # no need to add review if `hasReviewableRepresentations` + if instance.get("hasReviewableRepresentations"): break # iteratre all aov filters From c9edd81e17cf5605890a237fff9cc6d2f12addc9 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Apr 2022 12:04:56 +0200 Subject: [PATCH 495/854] ftrack: renameing attribute to `keep_first_subset_name_for_review` --- .../ftrack/plugins/publish/integrate_ftrack_instances.py | 6 +++--- openpype/settings/defaults/project_settings/ftrack.json | 2 +- .../schemas/projects_schema/schema_project_ftrack.json | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index 5c0b414d86..f79bdb31d7 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -35,7 +35,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): "image": "img", "reference": "reference" } - nicer_asset_name = False + keep_first_subset_name_for_review = True def process(self, instance): self.log.debug("instance {}".format(instance)) @@ -177,7 +177,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # condition for multiple reviewable representations # expand name to better label componenst if ( - self.nicer_asset_name is not False + not self.keep_first_subset_name_for_review and is_first_review_repre and len(review_representations) > 1 ): @@ -284,7 +284,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # add extended name if any if ( - self.nicer_asset_name is not False + not self.keep_first_subset_name_for_review and extended_asset_name is not False ): other_item["asset_data"]["name"] = extended_asset_name diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index e97258b750..ca1cfe1e12 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -396,7 +396,7 @@ "redshiftproxy": "cache", "usd": "usd" }, - "nicer_asset_name": false + "keep_first_subset_name_for_review": true } } } \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index 702b1812a2..fb384882c6 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -787,9 +787,9 @@ }, { "type": "boolean", - "key": "nicer_asset_name", - "label": "Nicer Asset name if multiple reviewable", - "default": false + "key": "keep_first_subset_name_for_review", + "label": "Make subset name as first asset name", + "default": true } ] } From c32b0ec7703e2821204667afb354a18186e36c13 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Apr 2022 12:21:17 +0200 Subject: [PATCH 496/854] ftrack: simplification of logic --- .../publish/integrate_ftrack_instances.py | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index f79bdb31d7..c11d5b9c68 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -169,7 +169,8 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Change asset name of each new component for review is_first_review_repre = True not_first_components = [] - extended_asset_name = False + extended_asset_name = "" + multiple_reviewable = len(review_representations) > 1 for repre in review_representations: # Create copy of base comp item and append it review_item = copy.deepcopy(base_component_item) @@ -178,8 +179,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # expand name to better label componenst if ( not self.keep_first_subset_name_for_review - and is_first_review_repre - and len(review_representations) > 1 + and multiple_reviewable ): asset_name = review_item["asset_data"]["name"] # define new extended name @@ -187,18 +187,21 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): (asset_name, repre["name"]) ) review_item["asset_data"]["name"] = extended_asset_name - # and rename all already created components - for _ci in component_list: - _ci["asset_data"]["name"] = extended_asset_name - # and rename all already created src components - for _sci in src_components_to_add: - _sci["asset_data"]["name"] = extended_asset_name + # rename asset name only if multiple reviewable repre + if is_first_review_repre: + # and rename all already created components + for _ci in component_list: + _ci["asset_data"]["name"] = extended_asset_name - # rename also first thumbnail component if any - if first_thumbnail_component is not None: - first_thumbnail_component[ - "asset_data"]["name"] = extended_asset_name + # and rename all already created src components + for _sci in src_components_to_add: + _sci["asset_data"]["name"] = extended_asset_name + + # rename also first thumbnail component if any + if first_thumbnail_component is not None: + first_thumbnail_component[ + "asset_data"]["name"] = extended_asset_name frame_start = repre.get("frameStartFtrack") frame_end = repre.get("frameEndFtrack") @@ -230,15 +233,9 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): } } - # rename asset name only if multiple reviewable repre if is_first_review_repre: is_first_review_repre = False else: - # Add representation name to asset name of "not first" review - asset_name = review_item["asset_data"]["name"] - review_item["asset_data"]["name"] = "_".join( - (asset_name, repre["name"]) - ) not_first_components.append(review_item) # Create copy of item before setting location @@ -283,10 +280,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): other_item = copy.deepcopy(base_component_item) # add extended name if any - if ( - not self.keep_first_subset_name_for_review - and extended_asset_name is not False - ): + if extended_asset_name: other_item["asset_data"]["name"] = extended_asset_name other_item["component_data"] = { From 582e35a4829f6494c9d145cbf027e4f8446f6ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Mon, 4 Apr 2022 16:11:04 +0200 Subject: [PATCH 497/854] Update openpype/hosts/nuke/api/lib.py Co-authored-by: Roy Nieterau --- openpype/hosts/nuke/api/lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index b1717ea7ff..e05c6aecbd 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -1057,8 +1057,8 @@ def add_deadline_tab(node): node.addKnob(knob) knob = nuke.Int_Knob("deadlineConcurrentTasks", "Concurrent tasks") - # zero as default will trigger value from Setting during collection - # look to precollect_write.py + # zero as default will get value from Settings during collection + # instead of being an explicit user override, see precollect_write.py knob.setValue(0) node.addKnob(knob) From c8a886d6ce575f1cedccecd1429876746bbaf0a1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Apr 2022 17:52:23 +0200 Subject: [PATCH 498/854] added install_openpype_plugins into load cli --- openpype/tools/loader/app.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index 23c0909f2b..ab57f63c38 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -5,6 +5,7 @@ from avalon import api, io from openpype import style from openpype.lib import register_event_callback +from openpype.pipeline import install_openpype_plugins from openpype.tools.utils import ( lib, PlaceholderLineEdit @@ -608,4 +609,6 @@ def cli(args): # Store settings api.Session["AVALON_PROJECT"] = project + install_openpype_plugins() + show() From 578a0469c9a487948494f30a036bc14b8882323d Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 4 Apr 2022 17:57:27 +0200 Subject: [PATCH 499/854] pass project name to plugin install --- openpype/tools/loader/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py index ab57f63c38..fad284d82b 100644 --- a/openpype/tools/loader/app.py +++ b/openpype/tools/loader/app.py @@ -609,6 +609,6 @@ def cli(args): # Store settings api.Session["AVALON_PROJECT"] = project - install_openpype_plugins() + install_openpype_plugins(project) show() From 4235a7674f6b37ff3b13fa9083a0114f558acb06 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 4 Apr 2022 19:39:24 +0200 Subject: [PATCH 500/854] Use format to fill cred_path --- .../modules/sync_server/providers/gdrive.py | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 6a8d2b3422..f7bb2d36df 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -3,7 +3,7 @@ import os.path import time import sys import six -import acre +import platform from openpype.api import Logger from openpype.api import get_system_settings @@ -73,12 +73,22 @@ class GDriveHandler(AbstractProvider): format(site_name)) return - cred_data = { - 'cred_path': self.presets.get("credentials_url", {}) - } - cred_data = acre.parse(cred_data) - cred_data = acre.merge(cred_data, current_env=os.environ) - cred_path = cred_data['cred_path'] + current_platform = platform.system().lower() + cred_path = self.presets.get("credentials_url", {}). \ + get(current_platform) or '' + + if not cred_path: + msg = "Sync Server: Please, fill the credentials for gdrive "\ + "provider for platform '{}' !".format(current_platform) + log.info(msg) + return + + try: + cred_path = cred_path.format(**os.environ) + except KeyError as e: + log.info("the key(s) {} does not exist in the environment " + "variables".format(" ".join(e.args))) + return if not os.path.exists(cred_path): msg = "Sync Server: No credentials for gdrive provider " + \ From 8896b36ef0c1a0295c09ef69097589aad765245b Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 4 Apr 2022 19:41:27 +0200 Subject: [PATCH 501/854] Replace t by T in log message --- openpype/modules/sync_server/providers/gdrive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index f7bb2d36df..d6369d39e6 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -86,7 +86,7 @@ class GDriveHandler(AbstractProvider): try: cred_path = cred_path.format(**os.environ) except KeyError as e: - log.info("the key(s) {} does not exist in the environment " + log.info("The key(s) {} does not exist in the environment " "variables".format(" ".join(e.args))) return From 57ecd9adfaadc7e81e686a0bb74d68efaaf85b61 Mon Sep 17 00:00:00 2001 From: "clement.hector" Date: Mon, 4 Apr 2022 19:43:13 +0200 Subject: [PATCH 502/854] Better log message with Sync Server --- openpype/modules/sync_server/providers/gdrive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index d6369d39e6..b783f7958b 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -86,8 +86,8 @@ class GDriveHandler(AbstractProvider): try: cred_path = cred_path.format(**os.environ) except KeyError as e: - log.info("The key(s) {} does not exist in the environment " - "variables".format(" ".join(e.args))) + log.info("Sync Server: The key(s) {} does not exist in the " + "environment variables".format(" ".join(e.args))) return if not os.path.exists(cred_path): From 1926e107659790d10a63770f70b72a6f7cf88ef1 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Apr 2022 20:50:58 +0200 Subject: [PATCH 503/854] flame: redundant code --- openpype/hosts/flame/plugins/publish/integrate_batch_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index 4dd6081170..fc5f4cfcd0 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -7,7 +7,7 @@ import openpype.hosts.flame.api as opfapi import openpype.pipeline as op_pipeline -@pyblish.api.log + class IntegrateBatchGroup(pyblish.api.InstancePlugin): """Integrate published shot to batch group""" From 54897163caee3dbb783bcafd68b7648c99434c9d Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 4 Apr 2022 20:52:09 +0200 Subject: [PATCH 504/854] haunch catch --- openpype/hosts/flame/plugins/publish/integrate_batch_group.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index fc5f4cfcd0..a9ccd6b4a1 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -7,7 +7,6 @@ import openpype.hosts.flame.api as opfapi import openpype.pipeline as op_pipeline - class IntegrateBatchGroup(pyblish.api.InstancePlugin): """Integrate published shot to batch group""" From ee7ae9edfb9657320e1b2e0610ae9df07f0182bc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Apr 2022 10:27:47 +0200 Subject: [PATCH 505/854] fix registration of creator paths --- openpype/hosts/traypublisher/api/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py index a39e5641ae..24175883d9 100644 --- a/openpype/hosts/traypublisher/api/pipeline.py +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -7,7 +7,7 @@ from avalon import io import avalon.api import pyblish.api -from openpype.pipeline import BaseCreator +from openpype.pipeline import register_creator_plugin_path ROOT_DIR = os.path.dirname(os.path.dirname( os.path.abspath(__file__) @@ -169,7 +169,7 @@ def install(): pyblish.api.register_host("traypublisher") pyblish.api.register_plugin_path(PUBLISH_PATH) - avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) def set_project_name(project_name): From 38652d2c6a620634d5b5ec3ebc58169de94beed3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Apr 2022 10:28:03 +0200 Subject: [PATCH 506/854] added some titles to validation errors --- .../plugins/publish/validate_workfile.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py index e8eeb46065..7501051669 100644 --- a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py +++ b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py @@ -14,11 +14,22 @@ class ValidateWorkfilePath(pyblish.api.InstancePlugin): def process(self, instance): filepath = instance.data["sourceFilepath"] if not filepath: - raise PublishValidationError(( - "Filepath of 'workfile' instance \"{}\" is not set" - ).format(instance.data["name"])) + raise PublishValidationError( + ( + "Filepath of 'workfile' instance \"{}\" is not set" + ).format(instance.data["name"]), + "File not filled", + "## Missing file\nYou are supposed to fill the path." + ) if not os.path.exists(filepath): - raise PublishValidationError(( - "Filepath of 'workfile' instance \"{}\" does not exist: {}" - ).format(instance.data["name"], filepath)) + raise PublishValidationError( + ( + "Filepath of 'workfile' instance \"{}\" does not exist: {}" + ).format(instance.data["name"], filepath), + "File not found", + ( + "## File was not found\nFile \"{}\" was not found." + " Check if the path is still available." + ).format(filepath) + ) From dded71a5d41d90c55bace8e06732a8c8f558a667 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Apr 2022 10:29:28 +0200 Subject: [PATCH 507/854] moved "published" checkbox after filter --- openpype/tools/workfiles/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index edfcb17722..b4ff830459 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -113,8 +113,8 @@ class FilesWidget(QtWidgets.QWidget): filter_layout = QtWidgets.QHBoxLayout(filter_widget) filter_layout.setContentsMargins(0, 0, 0, 0) - filter_layout.addWidget(published_checkbox, 0) filter_layout.addWidget(filter_input, 1) + filter_layout.addWidget(published_checkbox, 0) # Create the Files models extensions = set(self.host.file_extensions()) From 1bb36035f94487abaff9abd9b4efd3423c4c9e83 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Tue, 5 Apr 2022 10:29:51 +0200 Subject: [PATCH 508/854] swapped cancel and copy & open buttons --- openpype/tools/workfiles/files_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/workfiles/files_widget.py b/openpype/tools/workfiles/files_widget.py index b4ff830459..56af7752da 100644 --- a/openpype/tools/workfiles/files_widget.py +++ b/openpype/tools/workfiles/files_widget.py @@ -210,8 +210,8 @@ class FilesWidget(QtWidgets.QWidget): publish_btns_layout.setContentsMargins(0, 0, 0, 0) publish_btns_layout.addWidget(btn_save_as_published, 1) publish_btns_layout.addWidget(btn_change_context, 1) - publish_btns_layout.addWidget(btn_cancel_published, 1) publish_btns_layout.addWidget(btn_select_context_published, 1) + publish_btns_layout.addWidget(btn_cancel_published, 1) btns_layout = QtWidgets.QHBoxLayout(btns_widget) btns_layout.setContentsMargins(0, 0, 0, 0) From edac05b8507ee53cd485750b8821bfd49d7e72a1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 5 Apr 2022 11:04:32 +0200 Subject: [PATCH 509/854] Added default subset template {family}{Task} for workfile family --- openpype/settings/defaults/project_settings/global.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 24334b0045..ffa63a8d81 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -243,6 +243,15 @@ "tasks": [], "template": "{family}{variant}" }, + { + "families": [ + "workfile" + ], + "hosts": [], + "task_types": [], + "tasks": [], + "template": "{family}{Task}" + }, { "families": [ "render" From 77d017bae2d97b1f22dda8e1bcf50ea9179adb9d Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 5 Apr 2022 11:06:26 +0200 Subject: [PATCH 510/854] Updated assert message for comparing results --- tests/lib/assert_classes.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/lib/assert_classes.py b/tests/lib/assert_classes.py index 98f758767d..7f4d8efc10 100644 --- a/tests/lib/assert_classes.py +++ b/tests/lib/assert_classes.py @@ -24,16 +24,18 @@ class DBAssert: else: args[key] = val - msg = None - no_of_docs = dbcon.count_documents(args) - if expected != no_of_docs: - msg = "Not expected no of versions. "\ - "Expected {}, found {}".format(expected, no_of_docs) - args.pop("type") detail_str = " " if args: - detail_str = " with {}".format(args) + detail_str = " with '{}'".format(args) + + msg = None + no_of_docs = dbcon.count_documents(args) + if expected != no_of_docs: + msg = "Not expected no of '{}'{}."\ + "Expected {}, found {}".format(queried_type, + detail_str, + expected, no_of_docs) status = "successful" if msg: @@ -42,7 +44,5 @@ class DBAssert: print("Comparing count of {}{} {}".format(queried_type, detail_str, status)) - if msg: - print(msg) return msg From 0dfca2ff4589e996708c9d27108e282d9107d847 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 5 Apr 2022 11:38:00 +0200 Subject: [PATCH 511/854] Flame: refining the code for better understanding of flow --- .../plugins/publish/integrate_batch_group.py | 78 ++++++++----------- 1 file changed, 33 insertions(+), 45 deletions(-) diff --git a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py index a9ccd6b4a1..979134bbfe 100644 --- a/openpype/hosts/flame/plugins/publish/integrate_batch_group.py +++ b/openpype/hosts/flame/plugins/publish/integrate_batch_group.py @@ -38,44 +38,40 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): def _load_clip_to_context(self, instance, bgroup): # get all loaders for host - loaders = op_pipeline.discover_loader_plugins() + loaders_by_name = { + loader.__name__: loader + for loader in op_pipeline.discover_loader_plugins() + } # get all published representations published_representations = instance.data["published_representations"] + repres_db_id_by_name = { + repre_info["representation"]["name"]: repre_id + for repre_id, repre_info in published_representations.items() + } # get all loadable representations - representations = instance.data["representations"] + repres_by_name = { + repre["name"]: repre for repre in instance.data["representations"] + } # get repre_id for the loadable representations - loadable_representations = [ - { - "name": _repr["name"], - "loader": _repr.get("batch_group_loader_name"), - # match loader to the loadable representation - "_id": next( - ( - id - for id, repr in published_representations.items() - if repr["representation"]["name"] == _repr["name"] - ), - None - ) + loader_name_by_repre_id = { + repres_db_id_by_name[repr_name]: { + "loader": repr_data["batch_group_loader_name"], + # add repre data for exception logging + "_repre_data": repr_data } - for _repr in representations - if _repr.get("load_to_batch_group") is not None - ] + for repr_name, repr_data in repres_by_name.items() + if repr_data.get("load_to_batch_group") + } - self.log.debug("__ loadable_representations: {}".format(pformat( - loadable_representations))) + self.log.debug("__ loader_name_by_repre_id: {}".format(pformat( + loader_name_by_repre_id))) # get representation context from the repre_id - representation_ids = [ - repre["_id"] - for repre in loadable_representations - if repre["_id"] is not None - ] repre_contexts = op_pipeline.load.get_repres_contexts( - representation_ids) + loader_name_by_repre_id.keys()) self.log.debug("__ repre_contexts: {}".format(pformat( repre_contexts))) @@ -84,45 +80,37 @@ class IntegrateBatchGroup(pyblish.api.InstancePlugin): for repre_id, repre_context in repre_contexts.items(): self.log.debug("__ repre_id: {}".format(repre_id)) # get loader name by representation id - loader_name = next( - ( - repr["loader"] - for repr in loadable_representations - if repr["_id"] == repre_id - )) or self.default_loader + loader_name = ( + loader_name_by_repre_id[repre_id]["loader"] + # if nothing was added to settings fallback to default + or self.default_loader + ) # get loader plugin - Loader = next( - ( - loader_plugin - for loader_plugin in loaders - if loader_plugin.__name__ == loader_name - ), - None - ) - if Loader: + loader_plugin = loaders_by_name.get(loader_name) + if loader_plugin: # load to flame by representation context try: op_pipeline.load.load_with_repre_context( - Loader, repre_context, **{ + loader_plugin, repre_context, **{ "data": {"workdir": self.task_workdir} }) except op_pipeline.load.IncompatibleLoaderError as msg: self.log.error( "Check allowed representations for Loader `{}` " "in settings > error: {}".format( - Loader.__name__, msg)) + loader_plugin.__name__, msg)) self.log.error( "Representaton context >>{}<< is not compatible " "with loader `{}`".format( - pformat(repre_context), Loader.__name__ + pformat(repre_context), loader_plugin.__name__ ) ) else: self.log.warning( "Something got wrong and there is not Loader found for " "following data: {}".format( - pformat(loadable_representations)) + pformat(loader_name_by_repre_id)) ) def _get_batch_group(self, instance, task_data): From 0437d0a1304347d9bf803083900a78f8e8a8a1fa Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 5 Apr 2022 13:54:26 +0200 Subject: [PATCH 512/854] general: adding `hasReviewableRepresentations` to skeleton data --- .../modules/deadline/plugins/publish/submit_publish_job.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 5755619292..a8f4fec563 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -729,7 +729,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): "resolutionWidth": data.get("resolutionWidth", 1920), "resolutionHeight": data.get("resolutionHeight", 1080), "multipartExr": data.get("multipartExr", False), - "jobBatchName": data.get("jobBatchName", "") + "jobBatchName": data.get("jobBatchName", ""), + "hasReviewableRepresentations": data.get( + "hasReviewableRepresentations") } if "prerender" in instance.data["families"]: From 6893fa22e90e5d36d6a0bcd5726636534a903bec Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 5 Apr 2022 14:01:35 +0200 Subject: [PATCH 513/854] ftrack: improving code logic it was not correctly distributing components --- .../publish/integrate_ftrack_instances.py | 57 +++++++++++-------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py index c11d5b9c68..b54db918a6 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_instances.py @@ -175,33 +175,40 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): # Create copy of base comp item and append it review_item = copy.deepcopy(base_component_item) - # condition for multiple reviewable representations - # expand name to better label componenst + # get asset name and define extended name variant + asset_name = review_item["asset_data"]["name"] + extended_asset_name = "_".join( + (asset_name, repre["name"]) + ) + + # reset extended if no need for extended asset name if ( - not self.keep_first_subset_name_for_review - and multiple_reviewable + self.keep_first_subset_name_for_review + and is_first_review_repre ): - asset_name = review_item["asset_data"]["name"] - # define new extended name - extended_asset_name = "_".join( - (asset_name, repre["name"]) - ) - review_item["asset_data"]["name"] = extended_asset_name + extended_asset_name = "" + else: + # only rename if multiple reviewable + if multiple_reviewable: + review_item["asset_data"]["name"] = extended_asset_name + else: + extended_asset_name = "" - # rename asset name only if multiple reviewable repre - if is_first_review_repre: - # and rename all already created components - for _ci in component_list: - _ci["asset_data"]["name"] = extended_asset_name + # rename all already created components + # only if first repre and extended name available + if is_first_review_repre and extended_asset_name: + # and rename all already created components + for _ci in component_list: + _ci["asset_data"]["name"] = extended_asset_name - # and rename all already created src components - for _sci in src_components_to_add: - _sci["asset_data"]["name"] = extended_asset_name + # and rename all already created src components + for _sci in src_components_to_add: + _sci["asset_data"]["name"] = extended_asset_name - # rename also first thumbnail component if any - if first_thumbnail_component is not None: - first_thumbnail_component[ - "asset_data"]["name"] = extended_asset_name + # rename also first thumbnail component if any + if first_thumbnail_component is not None: + first_thumbnail_component[ + "asset_data"]["name"] = extended_asset_name frame_start = repre.get("frameStartFtrack") frame_end = repre.get("frameEndFtrack") @@ -236,6 +243,7 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): if is_first_review_repre: is_first_review_repre = False else: + # later detection for thumbnail duplication not_first_components.append(review_item) # Create copy of item before setting location @@ -280,7 +288,10 @@ class IntegrateFtrackInstance(pyblish.api.InstancePlugin): other_item = copy.deepcopy(base_component_item) # add extended name if any - if extended_asset_name: + if ( + not self.keep_first_subset_name_for_review + and extended_asset_name + ): other_item["asset_data"]["name"] = extended_asset_name other_item["component_data"] = { From 557aafdae326e5799f9258f02e5f27ef5ecb5f48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 5 Apr 2022 14:36:11 +0200 Subject: [PATCH 514/854] fixed skeletal root --- .../publish/extract_unreal_skeletalmesh.py | 16 +++++----------- openpype/plugins/publish/integrate_new.py | 2 +- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py index 4dcad47e8c..7ef7f2f181 100644 --- a/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py +++ b/openpype/hosts/maya/plugins/publish/extract_unreal_skeletalmesh.py @@ -55,25 +55,19 @@ class ExtractUnrealSkeletalMesh(openpype.api.Extractor): # variant we extract we need to rename top node of the rig correctly. # It is finally done in context manager so it won't affect current # scene. - parent = "{}{}".format( - instance.data["asset"], - instance.data.get("variant", "") - ) - joints_parents = cmds.ls(joints, long=True) - geo_parents = cmds.ls(geo, long=True) + # we rely on hierarchy under one root. + original_parent = to_extract[0].split("|")[1] - parent_node = { - parent.split("|")[1] for parent in (joints_parents + geo_parents) - }.pop() + parent_node = instance.data.get("asset") renamed_to_extract = [] for node in to_extract: node_path = node.split("|") - node_path[1] = parent + node_path[1] = parent_node renamed_to_extract.append("|".join(node_path)) - with renamed(parent_node, parent): + with renamed(original_parent, parent_node): self.log.info("Extracting: {}".format(renamed_to_extract, path)) fbx_exporter.export(renamed_to_extract, path) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index afa4e0a9cf..acdb05dd93 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -107,7 +107,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): "hda", "usd", "staticMesh", - "skeletalMesh" + "skeletalMesh", "usdComposition", "usdOverride" ] From 5b260afc6af67bbcda403e7ce35922e7a8350ff6 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 5 Apr 2022 15:47:57 +0200 Subject: [PATCH 515/854] flame: avoid hidden segment processing --- openpype/hosts/flame/otio/flame_export.py | 43 +++++++++++------------ 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 8c240fc9d5..f9dbe68421 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -401,8 +401,10 @@ def get_clips_in_reels(project): version = clip.versions[-1] track = version.tracks[-1] + # each reel clip is also having one segment for segment in track.segments: - segment_data = _get_segment_attributes(segment) + segment_data = _get_segment_attributes( + segment, from_clip=True) clip_data.update(segment_data) output_clips.append(clip_data) @@ -489,12 +491,14 @@ def add_otio_metadata(otio_item, item, **kwargs): otio_item.metadata.update({key: value}) -def _get_shot_tokens_values(clip, tokens): +def _get_shot_tokens_values(clip, tokens, from_clip=False): old_value = None output = {} - if not clip.shot_name: - return output + # in case it is segment from reel clip + # avoiding duplicity of segement data + if from_clip: + return {} old_value = clip.shot_name.get_value() @@ -512,16 +516,19 @@ def _get_shot_tokens_values(clip, tokens): return output -def _get_segment_attributes(segment): +def _get_segment_attributes(segment, from_clip=False): # log.debug(dir(segment)) - - if str(segment.name)[1:-1] == "": + if ( + segment.name.get_value() == "" + or segment.hidden + ): return None # Add timeline segment to tree clip_data = { "segment_name": segment.name.get_value(), "segment_comment": segment.comment.get_value(), + "shot_name": segment.shot_name.get_value(), "tape_name": segment.tape_name, "source_name": segment.source_name, "fpath": segment.file_path, @@ -531,7 +538,7 @@ def _get_segment_attributes(segment): # add all available shot tokens shot_tokens = _get_shot_tokens_values(segment, [ "", "", "", "", - ]) + ], from_clip) clip_data.update(shot_tokens) # populate shot source metadata @@ -597,11 +604,7 @@ def create_otio_timeline(sequence): continue all_segments.append(clip_data) - segments_ordered = { - itemindex: clip_data - for itemindex, clip_data in enumerate( - all_segments) - } + segments_ordered = dict(enumerate(all_segments)) log.debug("_ segments_ordered: {}".format( pformat(segments_ordered) )) @@ -612,15 +615,11 @@ def create_otio_timeline(sequence): log.debug("_ itemindex: {}".format(itemindex)) # Add Gap if needed - if itemindex == 0: - # if it is first track item at track then add - # it to previous item - prev_item = segment_data - - else: - # get previous item - prev_item = segments_ordered[itemindex - 1] - + prev_item = ( + segment_data + if itemindex == 0 + else segments_ordered[itemindex - 1] + ) log.debug("_ segment_data: {}".format(segment_data)) # calculate clip frame range difference from each other From 575898490f4aad68256207da0dea4f9960e2948f Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 5 Apr 2022 15:57:16 +0200 Subject: [PATCH 516/854] flame: fixing broken get_clips_in_reels --- openpype/hosts/flame/otio/flame_export.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index f9dbe68421..78e5ceecb6 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -405,7 +405,8 @@ def get_clips_in_reels(project): for segment in track.segments: segment_data = _get_segment_attributes( segment, from_clip=True) - clip_data.update(segment_data) + if segment_data: + clip_data.update(segment_data) output_clips.append(clip_data) From 8d4541d68da458f5121029353119ab8ae7ff4791 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 5 Apr 2022 16:29:04 +0200 Subject: [PATCH 517/854] flame: hidden attribute is PyAttribute so need to get value --- openpype/hosts/flame/otio/flame_export.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/flame/otio/flame_export.py b/openpype/hosts/flame/otio/flame_export.py index 78e5ceecb6..1b5980b40a 100644 --- a/openpype/hosts/flame/otio/flame_export.py +++ b/openpype/hosts/flame/otio/flame_export.py @@ -518,10 +518,13 @@ def _get_shot_tokens_values(clip, tokens, from_clip=False): def _get_segment_attributes(segment, from_clip=False): - # log.debug(dir(segment)) + + log.debug("Segment name|hidden: {}|{}".format( + segment.name.get_value(), segment.hidden + )) if ( segment.name.get_value() == "" - or segment.hidden + or segment.hidden.get_value() ): return None @@ -591,7 +594,12 @@ def create_otio_timeline(sequence): # create otio tracks and clips for ver in sequence.versions: for track in ver.tracks: - if len(track.segments) == 0 and track.hidden: + # avoid all empty tracks + # or hidden tracks + if ( + len(track.segments) == 0 + or track.hidden.get_value() + ): return None # convert track to otio From 60118298b6c3dcf2f41488624a3b6d3bf9166990 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 5 Apr 2022 16:29:31 +0200 Subject: [PATCH 518/854] flame: make reel clip validation optional --- openpype/hosts/flame/plugins/publish/validate_source_clip.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/hosts/flame/plugins/publish/validate_source_clip.py b/openpype/hosts/flame/plugins/publish/validate_source_clip.py index 9ff015f628..345c00e05a 100644 --- a/openpype/hosts/flame/plugins/publish/validate_source_clip.py +++ b/openpype/hosts/flame/plugins/publish/validate_source_clip.py @@ -9,6 +9,8 @@ class ValidateSourceClip(pyblish.api.InstancePlugin): label = "Validate Source Clip" hosts = ["flame"] families = ["clip"] + optional = True + active = False def process(self, instance): flame_source_clip = instance.data["flameSourceClip"] From b16b1ee5c48df8438cbe716561df437f941e24c1 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 5 Apr 2022 16:39:51 +0200 Subject: [PATCH 519/854] OP-2766 - fix broken merge --- openpype/hosts/photoshop/api/pipeline.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/hosts/photoshop/api/pipeline.py b/openpype/hosts/photoshop/api/pipeline.py index 54db09be2d..2e2717d420 100644 --- a/openpype/hosts/photoshop/api/pipeline.py +++ b/openpype/hosts/photoshop/api/pipeline.py @@ -78,8 +78,7 @@ def install(): pyblish.api.register_plugin_path(PUBLISH_PATH) register_loader_plugin_path(LOAD_PATH) - avalon.api.register_plugin_path(LegacyCreator, CREATE_PATH) - avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) + register_creator_plugin_path(CREATE_PATH) log.info(PUBLISH_PATH) pyblish.api.register_callback( From 43a6863dc534ab514a91a9ade561c9c82e87f277 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 5 Apr 2022 16:40:16 +0200 Subject: [PATCH 520/854] OP-2766 - added documentation and resources for New Publisher --- website/docs/artist_hosts_photoshop.md | 64 ++++++++++++++++++ ...rtist_photoshop_new_publisher_instance.png | Bin 0 -> 21366 bytes ...otoshop_new_publisher_instance_created.png | Bin 0 -> 27811 bytes ...photoshop_new_publisher_publish_failed.png | Bin 0 -> 27081 bytes ...rtist_photoshop_new_publisher_workfile.png | Bin 0 -> 22231 bytes .../docs/assets/experimental_tools_menu.png | Bin 0 -> 9307 bytes .../assets/experimental_tools_settings.png | Bin 0 -> 8543 bytes 7 files changed, 64 insertions(+) create mode 100644 website/docs/assets/artist_photoshop_new_publisher_instance.png create mode 100644 website/docs/assets/artist_photoshop_new_publisher_instance_created.png create mode 100644 website/docs/assets/artist_photoshop_new_publisher_publish_failed.png create mode 100644 website/docs/assets/artist_photoshop_new_publisher_workfile.png create mode 100644 website/docs/assets/experimental_tools_menu.png create mode 100644 website/docs/assets/experimental_tools_settings.png diff --git a/website/docs/artist_hosts_photoshop.md b/website/docs/artist_hosts_photoshop.md index a140170c49..36670054ee 100644 --- a/website/docs/artist_hosts_photoshop.md +++ b/website/docs/artist_hosts_photoshop.md @@ -111,3 +111,67 @@ You can switch to a previous version of the image or update to the latest. ![Loader](assets/photoshop_manage_switch.gif) ![Loader](assets/photoshop_manage_update.gif) + + +### New Publisher + +All previous screenshot came from regular [pyblish](https://pyblish.com/) process, there is also a different UI available. This process extends existing implementation and adds new functionalities. + +To test this in Photoshop, the artist needs first to enable experimental `New publisher` in Settings. (Tray > Settings > Experimental tools) +![Settings](assets/experimental_tools_settings.png) + +New dialog opens after clicking on `Experimental tools` button in Openpype extension menu. +![Menu](assets/experimental_tools_menu.png) + +After you click on this button, this dialog will show up. + +![Menu](assets/artist_photoshop_new_publisher_workfile.png) + +You can see the first instance, called `workfileYourTaskName`. (Name depends on studio naming convention for Photoshop's workfiles.). This instance is so called "automatic", +it was created without instigation by the artist. You shouldn't delete this instance as it might hold necessary values for future publishing, but you can choose to skip it +from publishing (by toggling the pill button inside of the rectangular object denoting instance). + +New publisher allows publishing into different context, just click on a workfile instance, update `Variant`, `Asset` or `Task` in the form in the middle and don't forget to click on the 'Confirm' button. + +Similarly to the old publishing approach, you need to create instances for everything you want to publish. You will initiate by clicking on the '+' sign in the bottom left corner. + +![Instance creator](assets/artist_photoshop_new_publisher_instance.png) + +In this dialog you can select the family for the published layer or group. Currently only 'image' is implemented. + +On right hand side you can see creator attributes: +- `Create only for selected` - mimics `Use selected` option of regular publish +- `Create separate instance for each selected` - if separate instance should be created for each layer if multiple selected + +![Instance created](assets/artist_photoshop_new_publisher_instance_created.png) + +Here you can see a newly created instance of image family. (Name depends on studio naming convention for image family.) You can disable instance from publishing in the same fashion as a workfile instance. +You could also decide delete instance by selecting it and clicking on a trashcan icon (next to plus button on left button) + +Buttons on the bottom right are for: +- `Refresh publishing` - set publishing process to starting position - useful if previous publish failed, or you changed configuration of a publish +- `Stop/pause publishing` - if you would like to pause publishing process at any time +- `Validate` - if you would like to run only collecting and validating phases (nothing will be published yet) +- `Publish` - standard way how to kick off full publishing process + +In the unfortunate case of some error during publishing, you would receive this kind of error dialog. + +![Publish failed](assets/artist_photoshop_new_publisher_publish_failed.png) + +In this case there is an issue that you are publishing two or more instances with the same subset name ('imageMaing'). If the error is recoverable by the artist, you should +see helpful information in a `How to repair?` section or fix it automatically by clicking on a 'Wrench' button on the right if present. + +If you would like to ask for help admin or support, you could use any of the three buttons on bottom left: +- `Copy report` - stash full publishing log to a clipboard +- `Export and save report` - save log into a file for sending it via mail or any communication tool +- `Show details` - switches into a more detailed list of published instances and plugins. Similar to the old pyblish list. + +If you are able to fix the workfile yourself, use the first button on the right to set the UI to initial state before publish. (Click the `Publish` button to start again.) + +New publishing process should be backward compatible, eg. if you have a workfile with instances created in the previous publishing approach, they will be translated automatically and +could be used right away. + +If you would create instances in a new publisher, you cannot use them in the old approach though! + +If you would hit on unexpected behaviour with old instances, contact support first, then you could try some steps to recover your publish. Delete instances in New publisher UI, or try `Subset manager` in the extension menu. +Nuclear option is to purge workfile metadata in `File > File Info > Origin > Headline`. This is only for most determined daredevils though! diff --git a/website/docs/assets/artist_photoshop_new_publisher_instance.png b/website/docs/assets/artist_photoshop_new_publisher_instance.png new file mode 100644 index 0000000000000000000000000000000000000000..723a032c94488abf6ee60e11ab86b178e1cd5ade GIT binary patch literal 21366 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 literal 0 HcmV?d00001 diff --git a/website/docs/assets/artist_photoshop_new_publisher_publish_failed.png b/website/docs/assets/artist_photoshop_new_publisher_publish_failed.png new file mode 100644 index 0000000000000000000000000000000000000000..e34497b77da37b31ebbf26882242ed73040eba64 GIT binary patch literal 27081 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