From 267bad5ba6bd0d0b7558d6d3a106fd7c2c106998 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 22 May 2023 16:59:12 +0200 Subject: [PATCH 01/24] adding multiple reposition nodes attribute to settings --- .../defaults/project_settings/nuke.json | 38 +++++++++++++++++-- .../schemas/schema_nuke_publish.json | 35 ++++++++++++++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/openpype/settings/defaults/project_settings/nuke.json b/openpype/settings/defaults/project_settings/nuke.json index 85dee73176..f01bdf7d50 100644 --- a/openpype/settings/defaults/project_settings/nuke.json +++ b/openpype/settings/defaults/project_settings/nuke.json @@ -358,12 +358,12 @@ "optional": true, "active": true }, - "ValidateGizmo": { + "ValidateBackdrop": { "enabled": true, "optional": true, "active": true }, - "ValidateBackdrop": { + "ValidateGizmo": { "enabled": true, "optional": true, "active": true @@ -401,7 +401,39 @@ false ] ] - } + }, + "reposition_nodes": [ + { + "node_class": "Reformat", + "knobs": [ + { + "type": "text", + "name": "type", + "value": "to format" + }, + { + "type": "text", + "name": "format", + "value": "HD_1080" + }, + { + "type": "text", + "name": "filter", + "value": "Lanczos6" + }, + { + "type": "bool", + "name": "black_outside", + "value": true + }, + { + "type": "bool", + "name": "pbb", + "value": false + } + ] + } + ] }, "ExtractReviewData": { "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 ce9fa04c6a..3019c9b1b5 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 @@ -158,10 +158,43 @@ "label": "Nodes", "collapsible": true, "children": [ + { + "type": "label", + "label": "Nodes attribute will be deprecated in future releases. Use reposition_nodes instead." + }, { "type": "raw-json", "key": "nodes", - "label": "Nodes" + "label": "Nodes [depricated]" + }, + { + "type": "label", + "label": "Reposition knobs supported only. You can add multiple reformat nodes
and set their knobs. Order of reformat nodes is important. First reformat node
will be applied first and last reformat node will be applied last." + }, + { + "key": "reposition_nodes", + "type": "list", + "label": "Reposition nodes", + "object_type": { + "type": "dict", + "children": [ + { + "key": "node_class", + "label": "Node class", + "type": "text" + }, + { + "type": "schema_template", + "name": "template_nuke_knob_inputs", + "template_data": [ + { + "label": "Node knobs", + "key": "knobs" + } + ] + } + ] + } } ] } From b62d066390861ac9aa13e10fb58f2e33d17bdb58 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Mon, 22 May 2023 17:30:09 +0200 Subject: [PATCH 02/24] adding multi repositional nodes support to thumbnail exporter --- .../nuke/plugins/publish/extract_thumbnail.py | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index f391ca1e7c..2336487b37 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -5,6 +5,8 @@ import pyblish.api from openpype.pipeline import publish from openpype.hosts.nuke import api as napi +from openpype.hosts.nuke.api.lib import set_node_knobs_from_settings + if sys.version_info[0] >= 3: unicode = str @@ -28,7 +30,7 @@ class ExtractThumbnail(publish.Extractor): bake_viewer_process = True bake_viewer_input_process = True nodes = {} - + reposition_nodes = [] def process(self, instance): if instance.data.get("farm"): @@ -123,18 +125,32 @@ class ExtractThumbnail(publish.Extractor): temporary_nodes.append(rnode) previous_node = rnode - reformat_node = nuke.createNode("Reformat") - ref_node = self.nodes.get("Reformat", None) - if ref_node: - for k, v in ref_node: - self.log.debug("k, v: {0}:{1}".format(k, v)) - if isinstance(v, unicode): - v = str(v) - reformat_node[k].setValue(v) + if not self.reposition_nodes: + # [deprecated] create reformat node old way + reformat_node = nuke.createNode("Reformat") + ref_node = self.nodes.get("Reformat", None) + if ref_node: + for k, v in ref_node: + self.log.debug("k, v: {0}:{1}".format(k, v)) + if isinstance(v, unicode): + v = str(v) + reformat_node[k].setValue(v) - reformat_node.setInput(0, previous_node) - previous_node = reformat_node - temporary_nodes.append(reformat_node) + reformat_node.setInput(0, previous_node) + previous_node = reformat_node + temporary_nodes.append(reformat_node) + else: + # create reformat node new way + for repo_node in self.reposition_nodes: + node_class = repo_node["node_class"] + knobs = repo_node["knobs"] + node = nuke.createNode(node_class) + set_node_knobs_from_settings(node, knobs) + + # connect in order + node.setInput(0, previous_node) + previous_node = node + temporary_nodes.append(node) # only create colorspace baking if toggled on if bake_viewer_process: From a762b310e8407a233185b2de18de09c4b0f44a27 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 23 May 2023 11:51:23 +0200 Subject: [PATCH 03/24] inverting logic for `ignoreFrameHandleCheck` this was ignoring settings in frame range target. --- openpype/hosts/fusion/plugins/publish/collect_render.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index d0b7f1c4ff..551a365099 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -84,7 +84,7 @@ class CollectFusionRender( handleStart=inst.data["handleStart"], handleEnd=inst.data["handleEnd"], ignoreFrameHandleCheck=( - inst.data["frame_range_source"] == "render_range"), + inst.data["frame_range_source"] == "asset_db"), frameStep=1, fps=comp_frame_format_prefs.get("Rate"), app_version=comp.GetApp().Version, From a68aa029e4246aa77dcb67b003b3da7e7b650430 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 23 May 2023 11:52:09 +0200 Subject: [PATCH 04/24] Renaming attribute to make more sense in Fusion context --- openpype/hosts/fusion/plugins/create/create_saver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/create/create_saver.py b/openpype/hosts/fusion/plugins/create/create_saver.py index f1e7791972..04898d0a45 100644 --- a/openpype/hosts/fusion/plugins/create/create_saver.py +++ b/openpype/hosts/fusion/plugins/create/create_saver.py @@ -233,7 +233,7 @@ class CreateSaver(NewCreator): def _get_frame_range_enum(self): frame_range_options = { "asset_db": "Current asset context", - "render_range": "From viewer render in/out", + "render_range": "From render in/out", "comp_range": "From composition timeline" } From 1b3b7b1a737004416042c00f8ab48ac263200351 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 23 May 2023 12:41:33 +0200 Subject: [PATCH 05/24] Render instances with their explicit frame ranges --- .../plugins/publish/extract_render_local.py | 125 ++++++++++++------ 1 file changed, 85 insertions(+), 40 deletions(-) diff --git a/openpype/hosts/fusion/plugins/publish/extract_render_local.py b/openpype/hosts/fusion/plugins/publish/extract_render_local.py index f801f30577..1663ca04fa 100644 --- a/openpype/hosts/fusion/plugins/publish/extract_render_local.py +++ b/openpype/hosts/fusion/plugins/publish/extract_render_local.py @@ -1,6 +1,7 @@ import os import logging import contextlib +import collections import pyblish.api from openpype.pipeline import publish @@ -52,11 +53,14 @@ class FusionRenderLocal( hosts = ["fusion"] families = ["render.local"] + is_rendered_key = "_fusionrenderlocal_has_rendered" + def process(self, instance): - context = instance.context # Start render - self.render_once(context) + result = self.render(instance) + if result is False: + raise RuntimeError(f"Comp render failed for {instance}") self._add_representation(instance) @@ -69,52 +73,61 @@ class FusionRenderLocal( ) ) - def render_once(self, context): - """Render context comp only once, even with more render instances""" + def render(self, instance): + """Render instance. - # This plug-in assumes all render nodes get rendered at the same time - # to speed up the rendering. The check below makes sure that we only - # execute the rendering once and not for each instance. - key = f"__hasRun{self.__class__.__name__}" + We try to render the minimal amount of times by combining the instances + that have a matching frame range in one Fusion render. Then for the + batch of instances we store whether the - savers_to_render = [ - # Get the saver tool from the instance - instance.data["tool"] for instance in context if - # Only active instances - instance.data.get("publish", True) and - # Only render.local instances - "render.local" in instance.data.get("families", []) - ] + """ - if key not in context.data: - # We initialize as false to indicate it wasn't successful yet - # so we can keep track of whether Fusion succeeded - context.data[key] = False + if self.is_rendered_key in instance.data: + # This instance was already processed in batch with another + # instance, so we just return the render result directly + self.log.debug(f"Instance {instance} was already rendered") + return instance.data[self.is_rendered_key] - current_comp = context.data["currentComp"] - frame_start = context.data["frameStartHandle"] - frame_end = context.data["frameEndHandle"] + instances_by_frame_range = self.get_render_instances_by_frame_range( + instance.context + ) - self.log.info("Starting Fusion render") - self.log.info(f"Start frame: {frame_start}") - self.log.info(f"End frame: {frame_end}") - saver_names = ", ".join(saver.Name for saver in savers_to_render) - self.log.info(f"Rendering tools: {saver_names}") + # Render matching batch of instances that share the same frame range + frame_range = self.get_instance_render_frame_range(instance) + render_instances = instances_by_frame_range[frame_range] - with comp_lock_and_undo_chunk(current_comp): - with enabled_savers(current_comp, savers_to_render): - result = current_comp.Render( - { - "Start": frame_start, - "End": frame_end, - "Wait": True, - } - ) + # We initialize render state false to indicate it wasn't successful + # yet to keep track of whether Fusion succeeded. This is for cases + # where an error below this might cause the comp render result not + # to be stored for the instances of this batch + for render_instance in render_instances: + render_instance.data[self.is_rendered_key] = False - context.data[key] = bool(result) + savers_to_render = [inst.data["tool"] for inst in render_instances] + current_comp = instance.context.data["currentComp"] + frame_start, frame_end = frame_range - if context.data[key] is False: - raise RuntimeError("Comp render failed") + self.log.info( + f"Starting Fusion render frame range {frame_start}-{frame_end}" + ) + saver_names = ", ".join(saver.Name for saver in savers_to_render) + self.log.info(f"Rendering tools: {saver_names}") + + with comp_lock_and_undo_chunk(current_comp): + with enabled_savers(current_comp, savers_to_render): + result = current_comp.Render( + { + "Start": frame_start, + "End": frame_end, + "Wait": True, + } + ) + + # Store the render state for all the rendered instances + for render_instance in render_instances: + render_instance.data[self.is_rendered_key] = bool(result) + + return result def _add_representation(self, instance): """Add representation to instance""" @@ -151,3 +164,35 @@ class FusionRenderLocal( instance.data["representations"].append(repre) return instance + + def get_render_instances_by_frame_range(self, context): + """Return enabled render.local instances grouped by their frame range. + + Arguments: + context (pyblish.Context): The pyblish context + + Returns: + dict: (start, end): instances mapping + + """ + + instances_to_render = [ + instance for instance in context if + # Only active instances + instance.data.get("publish", True) and + # Only render.local instances + "render.local" in instance.data.get("families", []) + ] + + # Instances by frame ranges + instances_by_frame_range = collections.defaultdict(list) + for instance in instances_to_render: + start, end = self.get_instance_render_frame_range(instance) + instances_by_frame_range[(start, end)].append(instance) + + return dict(instances_by_frame_range) + + def get_instance_render_frame_range(self, instance): + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] + return start, end From a2007887299de2f65b21e93522fbbe4de3277c63 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 23 May 2023 11:50:34 +0100 Subject: [PATCH 06/24] Removed get_full_name() calls because of unexpected behaviour --- openpype/hosts/unreal/plugins/load/load_animation.py | 4 ++-- openpype/hosts/unreal/plugins/load/load_camera.py | 6 +++--- openpype/hosts/unreal/plugins/load/load_layout.py | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/unreal/plugins/load/load_animation.py b/openpype/hosts/unreal/plugins/load/load_animation.py index 778ddf693d..a5ecb677e8 100644 --- a/openpype/hosts/unreal/plugins/load/load_animation.py +++ b/openpype/hosts/unreal/plugins/load/load_animation.py @@ -156,7 +156,7 @@ class AnimationFBXLoader(plugin.Loader): package_paths=[f"{root}/{hierarchy[0]}"], recursive_paths=False) levels = ar.get_assets(_filter) - master_level = levels[0].get_full_name() + master_level = levels[0].get_asset().get_path_name() hierarchy_dir = root for h in hierarchy: @@ -168,7 +168,7 @@ class AnimationFBXLoader(plugin.Loader): package_paths=[f"{hierarchy_dir}/"], recursive_paths=True) levels = ar.get_assets(_filter) - level = levels[0].get_full_name() + level = levels[0].get_asset().get_path_name() unreal.EditorLevelLibrary.save_all_dirty_levels() unreal.EditorLevelLibrary.load_level(level) diff --git a/openpype/hosts/unreal/plugins/load/load_camera.py b/openpype/hosts/unreal/plugins/load/load_camera.py index 1bd398349f..072b3b1467 100644 --- a/openpype/hosts/unreal/plugins/load/load_camera.py +++ b/openpype/hosts/unreal/plugins/load/load_camera.py @@ -365,7 +365,7 @@ class CameraLoader(plugin.Loader): maps = ar.get_assets(filter) # There should be only one map in the list - EditorLevelLibrary.load_level(maps[0].get_full_name()) + EditorLevelLibrary.load_level(maps[0].get_asset().get_path_name()) level_sequence = sequences[0].get_asset() @@ -513,7 +513,7 @@ class CameraLoader(plugin.Loader): map = maps[0] EditorLevelLibrary.save_all_dirty_levels() - EditorLevelLibrary.load_level(map.get_full_name()) + EditorLevelLibrary.load_level(map.get_asset().get_path_name()) # Remove the camera from the level. actors = EditorLevelLibrary.get_all_level_actors() @@ -523,7 +523,7 @@ class CameraLoader(plugin.Loader): EditorLevelLibrary.destroy_actor(a) EditorLevelLibrary.save_all_dirty_levels() - EditorLevelLibrary.load_level(world.get_full_name()) + EditorLevelLibrary.load_level(world.get_asset().get_path_name()) # There should be only one sequence in the path. sequence_name = sequences[0].asset_name diff --git a/openpype/hosts/unreal/plugins/load/load_layout.py b/openpype/hosts/unreal/plugins/load/load_layout.py index e5f32c3412..d94e6e5837 100644 --- a/openpype/hosts/unreal/plugins/load/load_layout.py +++ b/openpype/hosts/unreal/plugins/load/load_layout.py @@ -740,7 +740,7 @@ class LayoutLoader(plugin.Loader): loaded_assets = self._process(self.fname, asset_dir, shot) for s in sequences: - EditorAssetLibrary.save_asset(s.get_full_name()) + EditorAssetLibrary.save_asset(s.get_path_name()) EditorLevelLibrary.save_current_level() @@ -819,7 +819,7 @@ class LayoutLoader(plugin.Loader): recursive_paths=False) levels = ar.get_assets(filter) - layout_level = levels[0].get_full_name() + layout_level = levels[0].get_asset().get_path_name() EditorLevelLibrary.save_all_dirty_levels() EditorLevelLibrary.load_level(layout_level) @@ -919,7 +919,7 @@ class LayoutLoader(plugin.Loader): package_paths=[f"{root}/{ms_asset}"], recursive_paths=False) levels = ar.get_assets(_filter) - master_level = levels[0].get_full_name() + master_level = levels[0].get_asset().get_path_name() sequences = [master_sequence] From 4f24356139425df00ad13e5dce943d739c2f63ee Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 23 May 2023 14:20:30 +0200 Subject: [PATCH 07/24] Add validator for instance frame range to be within comp global in/out --- .../publish/validate_instance_frame_range.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py diff --git a/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py b/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py new file mode 100644 index 0000000000..06cd0ca186 --- /dev/null +++ b/openpype/hosts/fusion/plugins/publish/validate_instance_frame_range.py @@ -0,0 +1,41 @@ +import pyblish.api + +from openpype.pipeline import PublishValidationError + + +class ValidateInstanceFrameRange(pyblish.api.InstancePlugin): + """Validate instance frame range is within comp's global render range.""" + + order = pyblish.api.ValidatorOrder + label = "Validate Filename Has Extension" + families = ["render"] + hosts = ["fusion"] + + def process(self, instance): + + context = instance.context + global_start = context.data["compFrameStart"] + global_end = context.data["compFrameEnd"] + + render_start = instance.data["frameStartHandle"] + render_end = instance.data["frameEndHandle"] + + if render_start < global_start or render_end > global_end: + + message = ( + f"Instance {instance} render frame range " + f"({render_start}-{render_end}) is outside of the comp's " + f"global render range ({global_start}-{global_end}) and thus " + f"can't be rendered. " + ) + description = ( + f"{message}\n\n" + f"Either update the comp's global range or the instance's " + f"frame range to ensure the comp's frame range includes the " + f"to render frame range for the instance." + ) + raise PublishValidationError( + title="Frame range outside of comp range", + message=message, + description=description + ) From af6ce0bf9fd9ac34f9afb7dfd39168f3258ea76f Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 23 May 2023 14:21:38 +0200 Subject: [PATCH 08/24] Fix docstring --- openpype/hosts/fusion/plugins/publish/extract_render_local.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/fusion/plugins/publish/extract_render_local.py b/openpype/hosts/fusion/plugins/publish/extract_render_local.py index 1663ca04fa..564dca1796 100644 --- a/openpype/hosts/fusion/plugins/publish/extract_render_local.py +++ b/openpype/hosts/fusion/plugins/publish/extract_render_local.py @@ -78,7 +78,7 @@ class FusionRenderLocal( We try to render the minimal amount of times by combining the instances that have a matching frame range in one Fusion render. Then for the - batch of instances we store whether the + batch of instances we store whether the render succeeded or failed. """ From 81d41bb0edbd74f8196072c59f773592c94242f8 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Tue, 23 May 2023 16:18:58 +0200 Subject: [PATCH 09/24] fixing frame range data passing from instance --- .../hosts/fusion/plugins/publish/collect_render.py | 6 ++++-- .../pipeline/publish/abstract_collect_render.py | 13 ++++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_render.py b/openpype/hosts/fusion/plugins/publish/collect_render.py index 551a365099..a20a142701 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_render.py +++ b/openpype/hosts/fusion/plugins/publish/collect_render.py @@ -17,6 +17,8 @@ class FusionRenderInstance(RenderInstance): tool = attr.ib(default=None) workfileComp = attr.ib(default=None) publish_attributes = attr.ib(default={}) + frameStartHandle = attr.ib(default=None) + frameEndHandle = attr.ib(default=None) class CollectFusionRender( @@ -83,8 +85,8 @@ class CollectFusionRender( frameEnd=inst.data["frameEnd"], handleStart=inst.data["handleStart"], handleEnd=inst.data["handleEnd"], - ignoreFrameHandleCheck=( - inst.data["frame_range_source"] == "asset_db"), + frameStartHandle=inst.data["frameStartHandle"], + frameEndHandle=inst.data["frameEndHandle"], frameStep=1, fps=comp_frame_format_prefs.get("Rate"), app_version=comp.GetApp().Version, diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py index fd35ddb719..1e392d25e3 100644 --- a/openpype/pipeline/publish/abstract_collect_render.py +++ b/openpype/pipeline/publish/abstract_collect_render.py @@ -167,16 +167,27 @@ class AbstractCollectRender(pyblish.api.ContextPlugin): frame_start_render = int(render_instance.frameStart) frame_end_render = int(render_instance.frameEnd) + """ TODO: Needs to be refofactored because this + seems to be very hacky + """ if (render_instance.ignoreFrameHandleCheck or int(context.data['frameStartHandle']) == frame_start_render and int(context.data['frameEndHandle']) == frame_end_render): # noqa: W503, E501 - + # only for Harmony where frame range cannot be set by DB handle_start = context.data['handleStart'] handle_end = context.data['handleEnd'] frame_start = context.data['frameStart'] frame_end = context.data['frameEnd'] frame_start_handle = context.data['frameStartHandle'] frame_end_handle = context.data['frameEndHandle'] + elif (hasattr(render_instance, "frameStartHandle") + and hasattr(render_instance, "frameEndHandle")): + handle_start = int(render_instance.handleStart) + handle_end = int(render_instance.handleEnd) + frame_start = int(render_instance.frameStart) + frame_end = int(render_instance.frameEnd) + frame_start_handle = int(render_instance.frameStartHandle) + frame_end_handle = int(render_instance.frameEndHandle) else: handle_start = 0 handle_end = 0 From 561b4cb5d52e2f201a82f029a09b9b450a02085b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Tue, 23 May 2023 16:31:28 +0200 Subject: [PATCH 10/24] Update openpype/pipeline/publish/abstract_collect_render.py Co-authored-by: Roy Nieterau --- openpype/pipeline/publish/abstract_collect_render.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/pipeline/publish/abstract_collect_render.py b/openpype/pipeline/publish/abstract_collect_render.py index 1e392d25e3..6877d556c3 100644 --- a/openpype/pipeline/publish/abstract_collect_render.py +++ b/openpype/pipeline/publish/abstract_collect_render.py @@ -167,9 +167,7 @@ class AbstractCollectRender(pyblish.api.ContextPlugin): frame_start_render = int(render_instance.frameStart) frame_end_render = int(render_instance.frameEnd) - """ TODO: Needs to be refofactored because this - seems to be very hacky - """ + # TODO: Refactor hacky frame range workaround below if (render_instance.ignoreFrameHandleCheck or int(context.data['frameStartHandle']) == frame_start_render and int(context.data['frameEndHandle']) == frame_end_render): # noqa: W503, E501 From b70051b768e1b4b126b3f17fd2a734ab486edc58 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 23 May 2023 17:55:54 +0200 Subject: [PATCH 11/24] Preserve comp frame range after rendering --- openpype/hosts/fusion/api/lib.py | 33 +++++++++++++++++-- .../plugins/publish/extract_render_local.py | 19 ++++++----- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index c33209823e..1c486783be 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -256,8 +256,11 @@ def switch_item(container, @contextlib.contextmanager -def maintained_selection(): - comp = get_current_comp() +def maintained_selection(comp=None): + """Reset comp selection from before the context after the context""" + if comp is None: + comp = get_current_comp() + previous_selection = comp.GetToolList(True).values() try: yield @@ -268,6 +271,32 @@ def maintained_selection(): for tool in previous_selection: flow.Select(tool, True) +@contextlib.contextmanager +def maintained_comp_range(comp=None, + global_start=True, + global_end=True, + render_start=True, + render_end=True): + """Reset comp frame ranges from before the context after the context""" + if comp is None: + comp = get_current_comp() + + comp_attrs = comp.GetAttrs() + preserve_attrs = {} + if global_start: + preserve_attrs["COMPN_GlobalStart"] = comp_attrs["COMPN_GlobalStart"] + if global_end: + preserve_attrs["COMPN_GlobalEnd"] = comp_attrs["COMPN_GlobalEnd"] + if render_start: + preserve_attrs["COMPN_RenderStart"] = comp_attrs["COMPN_RenderStart"] + if render_end: + preserve_attrs["COMPN_RenderEnd"] = comp_attrs["COMPN_RenderEnd"] + + try: + yield + finally: + comp.SetAttrs(preserve_attrs) + def get_frame_path(path): """Get filename for the Fusion Saver with padded number as '#' diff --git a/openpype/hosts/fusion/plugins/publish/extract_render_local.py b/openpype/hosts/fusion/plugins/publish/extract_render_local.py index 564dca1796..25c101cf00 100644 --- a/openpype/hosts/fusion/plugins/publish/extract_render_local.py +++ b/openpype/hosts/fusion/plugins/publish/extract_render_local.py @@ -6,7 +6,7 @@ import pyblish.api from openpype.pipeline import publish from openpype.hosts.fusion.api import comp_lock_and_undo_chunk -from openpype.hosts.fusion.api.lib import get_frame_path +from openpype.hosts.fusion.api.lib import get_frame_path, maintained_comp_range log = logging.getLogger(__name__) @@ -114,14 +114,15 @@ class FusionRenderLocal( self.log.info(f"Rendering tools: {saver_names}") with comp_lock_and_undo_chunk(current_comp): - with enabled_savers(current_comp, savers_to_render): - result = current_comp.Render( - { - "Start": frame_start, - "End": frame_end, - "Wait": True, - } - ) + with maintained_comp_range(current_comp): + with enabled_savers(current_comp, savers_to_render): + result = current_comp.Render( + { + "Start": frame_start, + "End": frame_end, + "Wait": True, + } + ) # Store the render state for all the rendered instances for render_instance in render_instances: From 409929c3b8233d922463410a12f452766ee94123 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Tue, 23 May 2023 17:57:23 +0200 Subject: [PATCH 12/24] Cosmetics --- openpype/hosts/fusion/api/lib.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/fusion/api/lib.py b/openpype/hosts/fusion/api/lib.py index 1c486783be..cba8c38c2f 100644 --- a/openpype/hosts/fusion/api/lib.py +++ b/openpype/hosts/fusion/api/lib.py @@ -271,6 +271,7 @@ def maintained_selection(comp=None): for tool in previous_selection: flow.Select(tool, True) + @contextlib.contextmanager def maintained_comp_range(comp=None, global_start=True, From a73d19b612c6c4d423a8784b0598e71263213c9f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 May 2023 18:16:05 +0200 Subject: [PATCH 13/24] Publisher: Show instances in report page (#4915) * renamed 'validations_widget.py' to 'report_page.py' * Implemented base logic and widgets for logs * make one report page * added missing imports * added missing constants * move and rename 'VerticallScrollArea' to 'VerticalScrollArea' * Validation erro item have id * use 'ReportPageWidget' in window * change 'bg-button-hover' key to 'bg-buttons-hover' in style colors * move publish actions widgets * Refactored how validation error title is showed * remove item id from validation error item but add id to group items * remove margins from actions widget * shrink publish frame on finished publishing * fix dash line draw * add missing styles * fix dash line in thumbnail widget * added crash widget and changed layout a little * added infor overlay message * export and copy report happens in main window * fix docstrings * added per plugin filtering for validation errors * added implementation of 'FlowLayout' * actions buttons are in flow layout * fix actions order * implemented expanding text edit widget * expand button has some signals and properties * description and details are separated widgets * fix typo * added constans to '__all__' * parse icon def is a function * change layout of widgets * fix log filtering * added state icon to instances * fix pyside6 issues * implemented 'ClassicExpandBtnLabel' with arrow images * modified details separator * added some spacing to layouts * fix syle of description inputs and progress color * removed unused import * add 'is_validation_error' to errored result * validation error has different icon in logs view * added plugin name to ValueError if happens * spacer before detail inputs moved out of detals widget * fix actions visible in craash report * ignore pyblish base classes * filter base plugins in discovery * use 'is' comparison instead of '__eq__' * fix action error handling * Fix handling of 'None' values in comparison * formatting fix * Report instance card have same margins as in create mode * publish instances are grouped by family * log messages are rstripped --- openpype/pipeline/publish/lib.py | 8 + openpype/style/data.json | 9 +- openpype/style/style.css | 64 +- openpype/tools/attribute_defs/files_widget.py | 24 +- openpype/tools/publisher/constants.py | 6 + openpype/tools/publisher/control.py | 41 +- .../publish_report_viewer/widgets.py | 6 +- openpype/tools/publisher/widgets/__init__.py | 4 +- .../publisher/widgets/card_view_widgets.py | 6 +- .../tools/publisher/widgets/images/error.png | Bin 0 -> 14667 bytes .../publisher/widgets/images/success.png | Bin 0 -> 14514 bytes .../publisher/widgets/images/warning.png | Bin 9748 -> 11546 bytes .../tools/publisher/widgets/publish_frame.py | 39 +- .../tools/publisher/widgets/report_page.py | 1876 +++++++++++++++++ .../publisher/widgets/thumbnail_widget.py | 21 +- .../publisher/widgets/validations_widget.py | 715 ------- openpype/tools/publisher/widgets/widgets.py | 65 +- openpype/tools/publisher/window.py | 58 +- openpype/tools/utils/__init__.py | 7 + openpype/tools/utils/layouts.py | 150 ++ openpype/tools/utils/widgets.py | 108 +- 21 files changed, 2373 insertions(+), 834 deletions(-) create mode 100644 openpype/tools/publisher/widgets/images/error.png create mode 100644 openpype/tools/publisher/widgets/images/success.png create mode 100644 openpype/tools/publisher/widgets/report_page.py delete mode 100644 openpype/tools/publisher/widgets/validations_widget.py create mode 100644 openpype/tools/utils/layouts.py diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 080f93e514..40186238aa 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -320,6 +320,14 @@ def publish_plugins_discover(paths=None): continue for plugin in pyblish.plugin.plugins_from_module(module): + # Ignore base plugin classes + # NOTE 'pyblish.api.discover' does not ignore them! + if ( + plugin is pyblish.api.Plugin + or plugin is pyblish.api.ContextPlugin + or plugin is pyblish.api.InstancePlugin + ): + continue if not allow_duplicates and plugin.__name__ in plugin_names: result.duplicated_plugins.append(plugin) log.debug("Duplicate plug-in found: %s", plugin) diff --git a/openpype/style/data.json b/openpype/style/data.json index bea2a3d407..7389387d97 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -26,8 +26,8 @@ "bg": "#2C313A", "bg-inputs": "#21252B", - "bg-buttons": "#434a56", - "bg-button-hover": "rgb(81, 86, 97)", + "bg-buttons": "rgb(67, 74, 86)", + "bg-buttons-hover": "rgb(81, 86, 97)", "bg-inputs-disabled": "#2C313A", "bg-buttons-disabled": "#434a56", @@ -66,7 +66,9 @@ "bg-success": "#458056", "bg-success-hover": "#55a066", "bg-error": "#AD2E2E", - "bg-error-hover": "#C93636" + "bg-error-hover": "#C93636", + "bg-info": "rgb(63, 98, 121)", + "bg-info-hover": "rgb(81, 146, 181)" }, "tab-widget": { "bg": "#21252B", @@ -94,6 +96,7 @@ "crash": "#FF6432", "success": "#458056", "warning": "#ffc671", + "progress": "rgb(194, 226, 236)", "tab-bg": "#16191d", "list-view-group": { "bg": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index 827b103f94..5ce55aa658 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -136,7 +136,7 @@ QPushButton { } QPushButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; color: {color:font-hover}; } @@ -166,7 +166,7 @@ QToolButton { } QToolButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; color: {color:font-hover}; } @@ -722,6 +722,13 @@ OverlayMessageWidget[type="error"]:hover { background: {color:overlay-messages:bg-error-hover}; } +OverlayMessageWidget[type="info"] { + background: {color:overlay-messages:bg-info}; +} +OverlayMessageWidget[type="info"]:hover { + background: {color:overlay-messages:bg-info-hover}; +} + OverlayMessageWidget QWidget { background: transparent; } @@ -749,10 +756,11 @@ OverlayMessageWidget QWidget { } #InfoText { - padding-left: 30px; - padding-top: 20px; + padding-left: 0px; + padding-top: 0px; + padding-right: 20px; background: transparent; - border: 1px solid {color:border}; + border: none; } #TypeEditor, #ToolEditor, #NameEditor, #NumberEditor { @@ -914,7 +922,7 @@ PixmapButton{ background: {color:bg-buttons}; } PixmapButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } PixmapButton:disabled { background: {color:bg-buttons-disabled}; @@ -925,7 +933,7 @@ PixmapButton:disabled { background: {color:bg-view}; } #ThumbnailPixmapHoverButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } #CreatorDetailedDescription { @@ -946,7 +954,7 @@ PixmapButton:disabled { } #CreateDialogHelpButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } #CreateDialogHelpButton QWidget { background: transparent; @@ -1005,7 +1013,7 @@ PixmapButton:disabled { border-radius: 0.2em; } #CardViewWidget:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } #CardViewWidget[state="selected"] { background: {color:bg-view-selection}; @@ -1032,7 +1040,7 @@ PixmapButton:disabled { } #PublishInfoFrame[state="3"], #PublishInfoFrame[state="4"] { - background: rgb(194, 226, 236); + background: {color:publisher:progress}; } #PublishInfoFrame QLabel { @@ -1040,6 +1048,11 @@ PixmapButton:disabled { font-style: bold; } +#PublishReportHeader { + font-size: 14pt; + font-weight: bold; +} + #PublishInfoMainLabel { font-size: 12pt; } @@ -1060,7 +1073,7 @@ ValidationArtistMessage QLabel { } #ValidationActionButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; color: {color:font-hover}; } @@ -1090,6 +1103,35 @@ ValidationArtistMessage QLabel { border-left: 1px solid {color:border}; } +#PublishInstancesDetails { + border: 1px solid {color:border}; + border-radius: 0.3em; +} + +#InstancesLogsView { + border: 1px solid {color:border}; + background: {color:bg-view}; + border-radius: 0.3em; +} + +#PublishLogMessage { + font-family: "Noto Sans Mono"; +} + +#PublishInstanceLogsLabel { + font-weight: bold; +} + +#PublishCrashMainLabel{ + font-weight: bold; + font-size: 16pt; +} + +#PublishCrashReportLabel { + font-weight: bold; + font-size: 13pt; +} + #AssetNameInputWidget { background: {color:bg-inputs}; border: 1px solid {color:border}; diff --git a/openpype/tools/attribute_defs/files_widget.py b/openpype/tools/attribute_defs/files_widget.py index 067866035f..076b33fb7c 100644 --- a/openpype/tools/attribute_defs/files_widget.py +++ b/openpype/tools/attribute_defs/files_widget.py @@ -198,29 +198,33 @@ class DropEmpty(QtWidgets.QWidget): def paintEvent(self, event): super(DropEmpty, self).paintEvent(event) - painter = QtGui.QPainter(self) + pen = QtGui.QPen() - pen.setWidth(1) pen.setBrush(QtCore.Qt.darkGray) pen.setStyle(QtCore.Qt.DashLine) - painter.setPen(pen) - content_margins = self.layout().contentsMargins() + pen.setWidth(1) - left_m = content_margins.left() - top_m = content_margins.top() - rect = QtCore.QRect( + content_margins = self.layout().contentsMargins() + rect = self.rect() + left_m = content_margins.left() + pen.width() + top_m = content_margins.top() + pen.width() + new_rect = QtCore.QRect( left_m, top_m, ( - self.rect().width() + rect.width() - (left_m + content_margins.right() + pen.width()) ), ( - self.rect().height() + rect.height() - (top_m + content_margins.bottom() + pen.width()) ) ) - painter.drawRect(rect) + + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(pen) + painter.drawRect(new_rect) class FilesModel(QtGui.QStandardItemModel): diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py index 660fccecf1..4630eb144b 100644 --- a/openpype/tools/publisher/constants.py +++ b/openpype/tools/publisher/constants.py @@ -35,9 +35,13 @@ ResetKeySequence = QtGui.QKeySequence( __all__ = ( "CONTEXT_ID", + "CONTEXT_LABEL", "VARIANT_TOOLTIP", + "INPUTS_LAYOUT_HSPACING", + "INPUTS_LAYOUT_VSPACING", + "INSTANCE_ID_ROLE", "SORT_VALUE_ROLE", "IS_GROUP_ROLE", @@ -47,4 +51,6 @@ __all__ = ( "FAMILY_ROLE", "GROUP_ROLE", "CONVERTER_IDENTIFIER_ROLE", + + "ResetKeySequence", ) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 4b083d4bc8..8095d00103 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -47,6 +47,7 @@ PLUGIN_ORDER_OFFSET = 0.5 class CardMessageTypes: standard = None + info = "info" error = "error" @@ -220,7 +221,12 @@ class PublishReportMaker: def _add_plugin_data_item(self, plugin): if plugin in self._stored_plugins: - raise ValueError("Plugin is already stored") + # A plugin would be processed more than once. What can cause it: + # - there is a bug in controller + # - plugin class is imported into multiple files + # - this can happen even with base classes from 'pyblish' + raise ValueError( + "Plugin '{}' is already stored".format(str(plugin))) self._stored_plugins.append(plugin) @@ -239,6 +245,7 @@ class PublishReportMaker: label = plugin.label return { + "id": plugin.id, "name": plugin.__name__, "label": label, "order": plugin.order, @@ -324,7 +331,7 @@ class PublishReportMaker: "instances": instances_details, "context": self._extract_context_data(self._current_context), "crashed_file_paths": crashed_file_paths, - "id": str(uuid.uuid4()), + "id": uuid.uuid4().hex, "report_version": "1.0.0" } @@ -342,7 +349,9 @@ class PublishReportMaker: "label": instance.data.get("label"), "family": instance.data["family"], "families": instance.data.get("families") or [], - "exists": exists + "exists": exists, + "creator_identifier": instance.data.get("creator_identifier"), + "instance_id": instance.data.get("instance_id"), } def _extract_instance_log_items(self, result): @@ -388,8 +397,11 @@ class PublishReportMaker: exception = result.get("error") if exception: fname, line_no, func, exc = exception.traceback + # Action result does not have 'is_validation_error' + is_validation_error = result.get("is_validation_error", False) output.append({ "type": "error", + "is_validation_error": is_validation_error, "msg": str(exception), "filename": str(fname), "lineno": str(line_no), @@ -426,13 +438,15 @@ class PublishPluginsProxy: plugin_id = plugin.id plugins_by_id[plugin_id] = plugin - action_ids = set() + action_ids = [] action_ids_by_plugin_id[plugin_id] = action_ids actions = getattr(plugin, "actions", None) or [] for action in actions: action_id = action.id - action_ids.add(action_id) + if action_id in actions_by_id: + continue + action_ids.append(action_id) actions_by_id[action_id] = action self._plugins_by_id = plugins_by_id @@ -461,7 +475,7 @@ class PublishPluginsProxy: return plugin.id def get_plugin_action_items(self, plugin_id): - """Get plugin action items for plugin by it's id. + """Get plugin action items for plugin by its id. Args: plugin_id (str): Publish plugin id. @@ -568,7 +582,7 @@ class ValidationErrorItem: context_validation, title, description, - detail, + detail ): self.instance_id = instance_id self.instance_label = instance_label @@ -677,6 +691,8 @@ class PublishValidationErrorsReport: for title in titles: grouped_error_items.append({ + "id": uuid.uuid4().hex, + "plugin_id": plugin_id, "plugin_action_items": list(plugin_action_items), "error_items": error_items_by_title[title], "title": title @@ -2379,7 +2395,8 @@ class PublisherController(BasePublisherController): yield MainThreadItem(self.stop_publish) # Add plugin to publish report - self._publish_report.add_plugin_iter(plugin, self._publish_context) + self._publish_report.add_plugin_iter( + plugin, self._publish_context) # WARNING This is hack fix for optional plugins if not self._is_publish_plugin_active(plugin): @@ -2461,14 +2478,14 @@ class PublisherController(BasePublisherController): plugin, self._publish_context, instance ) - self._publish_report.add_result(result) - exception = result.get("error") if exception: + has_validation_error = False if ( isinstance(exception, PublishValidationError) and not self.publish_has_validated ): + has_validation_error = True self._add_validation_error(result) else: @@ -2482,6 +2499,10 @@ class PublisherController(BasePublisherController): self.publish_error_msg = msg self.publish_has_crashed = True + result["is_validation_error"] = has_validation_error + + self._publish_report.add_result(result) + self._publish_next_process() diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index dc449b6b69..02c9b63a4e 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -163,7 +163,11 @@ class ZoomPlainText(QtWidgets.QPlainTextEdit): super(ZoomPlainText, self).wheelEvent(event) return - degrees = float(event.delta()) / 8 + if hasattr(event, "angleDelta"): + delta = event.angleDelta().y() + else: + delta = event.delta() + degrees = float(delta) / 8 steps = int(ceil(degrees / 5)) self._scheduled_scalings += steps if (self._scheduled_scalings * steps < 0): diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py index f18e6cc61e..87a5f3914a 100644 --- a/openpype/tools/publisher/widgets/__init__.py +++ b/openpype/tools/publisher/widgets/__init__.py @@ -18,7 +18,7 @@ from .help_widget import ( from .publish_frame import PublishFrame from .tabs_widget import PublisherTabsWidget from .overview_widget import OverviewWidget -from .validations_widget import ValidationsWidget +from .report_page import ReportPageWidget __all__ = ( @@ -40,5 +40,5 @@ __all__ = ( "PublisherTabsWidget", "OverviewWidget", - "ValidationsWidget", + "ReportPageWidget", ) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index 13715bc73c..eae8e0420a 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -93,7 +93,7 @@ class BaseGroupWidget(QtWidgets.QWidget): return self._group def get_widget_by_item_id(self, item_id): - """Get instance widget by it's id.""" + """Get instance widget by its id.""" return self._widgets_by_id.get(item_id) @@ -702,8 +702,8 @@ class InstanceCardView(AbstractInstanceView): for group_name in sorted_group_names: group_icons = { - idenfier: self._controller.get_creator_icon(idenfier) - for idenfier in identifiers_by_group[group_name] + identifier: self._controller.get_creator_icon(identifier) + for identifier in identifiers_by_group[group_name] } if group_name in self._widgets_by_group: group_widget = self._widgets_by_group[group_name] diff --git a/openpype/tools/publisher/widgets/images/error.png b/openpype/tools/publisher/widgets/images/error.png new file mode 100644 index 0000000000000000000000000000000000000000..7b09a57d7dff44b31b5f18746fa22a42b7d9f15d GIT binary patch literal 14667 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%0DjRy{e1C?ePOpy6!-*e$z;|Xnn(?D zpXQkrLy+DjASj+-wx6t?@ebwUR`0uxctB97l~{FM{J+PyxI!(i%57)%H{wYr^v%Sc zuD@pzD{>YcX>>qj2wnglaUl5^|1)Jgx$pYJQuoBT27 z!HS(8dUMi^=z60EypoUky_~5%M$mxL8 z2R1h>A<69oL)PUk=F_}VsHXhV+s);w-(V*MX|aT0=GY2jm{4v#fZN-9d$Km~UqOX; zb4%faGqpee0khM`5FU*t_mgJ9$((#;S(c_SEOkqj11tove#5k(1E=MV zrt04k$G+Mx3_*o})b*Wd0Tx^67_|8Kbh~i57CU1&5hHe!NzG}N+55G7pG6cut$r(O z4`N7-ISsy2R9lg_^yq^L@IC1WEefb-@J^4rG0Uu=!QK&opnN zp9Ov5g677MUB?iLBadN4ze7gkKA(A3dw?-YbCO;^yE3E{k;+^}a=ZkyG3Er=WF2o% z_YoURGSAYFvox+4-E*~yQ2uE&P1>*h6y9kqk)6)^p_|)Dos;y%7NLy3$FvKj-p6qF zKbBh{;^VXEG-<+f=CI~1SATBqWEYk1_F^4+7DpOS@4{m7{!fL6P`W!M?$F{i_asl~ zQ852cI~rG;wI*`@!?9b@rR6o>c!JHN9iNnJ$}t!@#|ub$Plbj!n|9?_*e1zVzXp!< zkGb~@8NgR?I-Mzs=&?{Y0oZz`a za)sOnG`CdsbCC;y2$yf?K)_2})y=DD(K9a^h>ay?D@VxO4xY8!JkA6P@!|`(Naao) zz=C{oXdbkqw0X)xO)=U?V-Aq#6o8F5y{N`k=dA#023iQ}db)3j3&;G{ z?w@D<_AUX&p~aE|uZ zR?*dVYJ($J-9kr$N(2W5bWQNVL%zu=$)*07y{&LyzIKC)?Z}!t!CuxTQ*X5&j8||HAB_g{)ipOgnE^QtdUK57#q#rTJRX|9ioH!!{ zT42+F$UZ!5EC^V$_fW~>rJ=mnhS2w^$-D?E#Vted$&DFTMVO8X&uaM zFKtAPP<8iv?z{TS(6+$8 zsG#&F1r|-_tV=odOOeSW_LJ@!0E+tM{=d1zt>}-+*Psc&qiuBPmd10&gDOl7c{?AV zwaDkqZ91*NP3n#wTOg=7svLK5C8N{PfyeU zIJHO?boR3Z+?3n^qF?ayxa3#KoZzuXH{$0#PEr?1fcd!LTk;LR=?OfLov4PjNr0}N z(+5#zu)TKSRR`EJcWL2!Tcs{Jd-9^I^vgph=Q=_;71;2hO z5~HB@*;fYUBlW5atjaO=P&6pEr^WE-0=uFwD_VXpOD!(SK;?C;M zCdwc~`WSCt`V{cn=~>ZG%jk1YaP2~jBBQ1j zq`W5VQ201AOgVy8RU99DCzDjeXPFh?dnBw;>&Y-RE+$92Bo%Gf98o5Yp9m?a*lG-dU4LeVWjy^;ab* zkMsD z#hFH5zT)j5cpQB2jvMqK#D}Fdc|U8TZFkw}W+B|ApQ}lwMdno*|L)h&`WHq^VC$!m zef-xD(*iat+noRFk`41}j&uLAG{=P-Ki)VVua-Hy8I>Nl;qc>4&P*WUwqt{kBOBd3 zgZmL#)RHUi;-%8RJ?OcvOc~7{q|t?Rm4y&Bf7V!LQeGB|}rpv{T`-Q!kF=hAMZ=WmoNg-)j+rm`& z1nqA*`7e=1A|K8G-su!9bYkSz%{vybEp1(G7Tjin1#aG{(5i#7tCr$RJOx_?3$E|2 zx+H-)EqQpgws;E#-YSFpN;BGsA>JX<(1V}m@cN0i>YUw*!-_Ozzo=rgFKD5$kAV*T zZd(x$%bnWt(o6^mCGKL6@Pe3!pW9s7c@RAO#hbPz!yOZv?fUrwcAzY<3RBD3j}#JQ zd3bGY9_@P!l`lIbzqr18a(gW|A%07PQ+LHYA%X42(EsQD81vPM&7IXwpVa_16%lan O4Z@q-n^l_-F8mKWCTVE^ literal 0 HcmV?d00001 diff --git a/openpype/tools/publisher/widgets/images/warning.png b/openpype/tools/publisher/widgets/images/warning.png index 76d1e34b6ccf951b166db53cd59d8183cded7586..531f62b741804a2fa48769c17f9e29fbb0cd5429 100644 GIT binary patch literal 11546 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 E3l)7W-~a#s diff --git a/openpype/tools/publisher/widgets/publish_frame.py b/openpype/tools/publisher/widgets/publish_frame.py index e4e6740532..d21130deff 100644 --- a/openpype/tools/publisher/widgets/publish_frame.py +++ b/openpype/tools/publisher/widgets/publish_frame.py @@ -468,45 +468,14 @@ class PublishFrame(QtWidgets.QWidget): widget.setProperty("state", state) widget.style().polish(widget) - def _copy_report(self): - logs = self._controller.get_publish_report() - logs_string = json.dumps(logs, indent=4) - - mime_data = QtCore.QMimeData() - mime_data.setText(logs_string) - QtWidgets.QApplication.instance().clipboard().setMimeData( - mime_data - ) - - def _export_report(self): - default_filename = "publish-report-{}".format( - time.strftime("%y%m%d-%H-%M") - ) - default_filepath = os.path.join( - os.path.expanduser("~"), - default_filename - ) - new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName( - self, "Save report", default_filepath, ".json" - ) - if not ext or not new_filepath: - return - - logs = self._controller.get_publish_report() - full_path = new_filepath + ext - dir_path = os.path.dirname(full_path) - if not os.path.exists(dir_path): - os.makedirs(dir_path) - - with open(full_path, "w") as file_stream: - json.dump(logs, file_stream) - def _on_report_triggered(self, identifier): if identifier == "export_report": - self._export_report() + self._controller.event_system.emit( + "export_report.request", {}, "publish_frame") elif identifier == "copy_report": - self._copy_report() + self._controller.event_system.emit( + "copy_report.request", {}, "publish_frame") elif identifier == "go_to_report": self.details_page_requested.emit() diff --git a/openpype/tools/publisher/widgets/report_page.py b/openpype/tools/publisher/widgets/report_page.py new file mode 100644 index 0000000000..50a619f0a8 --- /dev/null +++ b/openpype/tools/publisher/widgets/report_page.py @@ -0,0 +1,1876 @@ +# -*- coding: utf-8 -*- +import collections +import logging + +try: + import commonmark +except Exception: + commonmark = None + +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors +from openpype.tools.utils import ( + BaseClickableFrame, + ClickableFrame, + ExpandingTextEdit, + FlowLayout, + ClassicExpandBtn, + paint_image_with_color, + SeparatorWidget, +) +from .widgets import IconValuePixmapLabel +from .icons import ( + get_pixmap, + get_image, +) +from ..constants import ( + INSTANCE_ID_ROLE, + CONTEXT_ID, + CONTEXT_LABEL, +) + +LOG_DEBUG_VISIBLE = 1 << 0 +LOG_INFO_VISIBLE = 1 << 1 +LOG_WARNING_VISIBLE = 1 << 2 +LOG_ERROR_VISIBLE = 1 << 3 +LOG_CRITICAL_VISIBLE = 1 << 4 +ERROR_VISIBLE = 1 << 5 +INFO_VISIBLE = 1 << 6 + + +class VerticalScrollArea(QtWidgets.QScrollArea): + """Scroll area for validation error titles. + + The biggest difference is that the scroll area has scroll bar on left side + and resize of content will also resize scrollarea itself. + + Resize if deferred by 100ms because at the moment of resize are not yet + propagated sizes and visibility of scroll bars. + """ + + def __init__(self, *args, **kwargs): + super(VerticalScrollArea, self).__init__(*args, **kwargs) + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.setLayoutDirection(QtCore.Qt.RightToLeft) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + # Background of scrollbar will be transparent + scrollbar_bg = self.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setViewportMargins(0, 0, 0, 0) + + self.verticalScrollBar().installEventFilter(self) + + # Timer with 100ms offset after changing size + size_changed_timer = QtCore.QTimer() + size_changed_timer.setInterval(100) + size_changed_timer.setSingleShot(True) + + size_changed_timer.timeout.connect(self._on_timer_timeout) + self._size_changed_timer = size_changed_timer + + def setVerticalScrollBar(self, widget): + old_widget = self.verticalScrollBar() + if old_widget: + old_widget.removeEventFilter(self) + + super(VerticalScrollArea, self).setVerticalScrollBar(widget) + if widget: + widget.installEventFilter(self) + + def setWidget(self, widget): + old_widget = self.widget() + if old_widget: + old_widget.removeEventFilter(self) + + super(VerticalScrollArea, self).setWidget(widget) + if widget: + widget.installEventFilter(self) + + def _on_timer_timeout(self): + width = self.widget().width() + if self.verticalScrollBar().isVisible(): + width += self.verticalScrollBar().width() + self.setMinimumWidth(width) + + def eventFilter(self, obj, event): + if ( + event.type() == QtCore.QEvent.Resize + and (obj is self.widget() or obj is self.verticalScrollBar()) + ): + self._size_changed_timer.start() + return super(VerticalScrollArea, self).eventFilter(obj, event) + + +# --- Publish actions widget --- +class ActionButton(BaseClickableFrame): + """Plugin's action callback button. + + Action may have label or icon or both. + + Args: + plugin_action_item (PublishPluginActionItem): Action item that can be + triggered by its id. + """ + + action_clicked = QtCore.Signal(str, str) + + def __init__(self, plugin_action_item, parent): + super(ActionButton, self).__init__(parent) + + self.setObjectName("ValidationActionButton") + + self.plugin_action_item = plugin_action_item + + action_label = plugin_action_item.label + action_icon = plugin_action_item.icon + label_widget = QtWidgets.QLabel(action_label, self) + icon_label = None + if action_icon: + icon_label = IconValuePixmapLabel(action_icon, self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 0, 5, 0) + layout.addWidget(label_widget, 1) + if icon_label: + layout.addWidget(icon_label, 0) + + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + self.sizePolicy().verticalPolicy() + ) + + def _mouse_release_callback(self): + self.action_clicked.emit( + self.plugin_action_item.plugin_id, + self.plugin_action_item.action_id + ) + + +class ValidateActionsWidget(QtWidgets.QFrame): + """Wrapper widget for plugin actions. + + Change actions based on selected validation error. + """ + + def __init__(self, controller, parent): + super(ValidateActionsWidget, self).__init__(parent) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + content_widget = QtWidgets.QWidget(self) + content_layout = FlowLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(content_widget) + + self._controller = controller + self._content_widget = content_widget + self._content_layout = content_layout + + self._actions_mapping = {} + + self._visible_mode = True + + def _update_visibility(self): + self.setVisible( + self._visible_mode + and self._content_layout.count() > 0 + ) + + def set_visible_mode(self, visible): + if self._visible_mode is visible: + return + self._visible_mode = visible + self._update_visibility() + + def _clear(self): + """Remove actions from widget.""" + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + self._actions_mapping = {} + + def set_error_info(self, error_info): + """Set selected plugin and show it's actions. + + Clears current actions from widget and recreate them from the plugin. + + Args: + Dict[str, Any]: Object holding error items, title and possible + actions to run. + """ + + self._clear() + + if not error_info: + self.setVisible(False) + return + + plugin_action_items = error_info["plugin_action_items"] + for plugin_action_item in plugin_action_items: + if not plugin_action_item.active: + continue + + if plugin_action_item.on_filter not in ("failed", "all"): + continue + + action_id = plugin_action_item.action_id + self._actions_mapping[action_id] = plugin_action_item + + action_btn = ActionButton(plugin_action_item, self._content_widget) + action_btn.action_clicked.connect(self._on_action_click) + self._content_layout.addWidget(action_btn) + + self._update_visibility() + + def _on_action_click(self, plugin_id, action_id): + self._controller.run_action(plugin_id, action_id) + + +# --- Validation error titles --- +class ValidationErrorInstanceList(QtWidgets.QListView): + """List of publish instances that caused a validation error. + + Instances are collected per plugin's validation error title. + """ + def __init__(self, *args, **kwargs): + super(ValidationErrorInstanceList, self).__init__(*args, **kwargs) + + self.setObjectName("ValidationErrorInstanceList") + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + def minimumSizeHint(self): + return self.sizeHint() + + def sizeHint(self): + result = super(ValidationErrorInstanceList, self).sizeHint() + row_count = self.model().rowCount() + height = 0 + if row_count > 0: + height = self.sizeHintForRow(0) * row_count + result.setHeight(height) + return result + + +class ValidationErrorTitleWidget(QtWidgets.QWidget): + """Title of validation error. + + Widget is used as radio button so requires clickable functionality and + changing style on selection/deselection. + + Has toggle button to show/hide instances on which validation error happened + if there is a list (Valdation error may happen on context). + """ + + selected = QtCore.Signal(str) + instance_changed = QtCore.Signal(str) + + def __init__(self, title_id, error_info, parent): + super(ValidationErrorTitleWidget, self).__init__(parent) + + self._title_id = title_id + self._error_info = error_info + self._selected = False + + title_frame = ClickableFrame(self) + title_frame.setObjectName("ValidationErrorTitleFrame") + + toggle_instance_btn = QtWidgets.QToolButton(title_frame) + toggle_instance_btn.setObjectName("ArrowBtn") + toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + toggle_instance_btn.setMaximumWidth(14) + + label_widget = QtWidgets.QLabel(error_info["title"], title_frame) + + title_frame_layout = QtWidgets.QHBoxLayout(title_frame) + title_frame_layout.addWidget(label_widget, 1) + title_frame_layout.addWidget(toggle_instance_btn, 0) + + instances_model = QtGui.QStandardItemModel() + + instance_ids = [] + + items = [] + context_validation = False + for error_item in error_info["error_items"]: + context_validation = error_item.context_validation + if context_validation: + toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + instance_ids.append(CONTEXT_ID) + # Add fake item to have minimum size hint of view widget + items.append(QtGui.QStandardItem(CONTEXT_LABEL)) + continue + + label = error_item.instance_label + item = QtGui.QStandardItem(label) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(error_item.instance_id, INSTANCE_ID_ROLE) + items.append(item) + instance_ids.append(error_item.instance_id) + + if items: + root_item = instances_model.invisibleRootItem() + root_item.appendRows(items) + + instances_view = ValidationErrorInstanceList(self) + instances_view.setModel(instances_model) + + self.setLayoutDirection(QtCore.Qt.LeftToRight) + + view_widget = QtWidgets.QWidget(self) + view_layout = QtWidgets.QHBoxLayout(view_widget) + view_layout.setContentsMargins(0, 0, 0, 0) + view_layout.setSpacing(0) + view_layout.addSpacing(14) + view_layout.addWidget(instances_view, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(title_frame, 0) + layout.addWidget(view_widget, 0) + view_widget.setVisible(False) + + if not context_validation: + toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) + + title_frame.clicked.connect(self._mouse_release_callback) + instances_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + + self._title_frame = title_frame + + self._toggle_instance_btn = toggle_instance_btn + + self._view_widget = view_widget + + self._instances_model = instances_model + self._instances_view = instances_view + + self._context_validation = context_validation + + self._instance_ids = instance_ids + self._expanded = False + + def sizeHint(self): + result = super(ValidationErrorTitleWidget, self).sizeHint() + expected_width = max( + self._view_widget.minimumSizeHint().width(), + self._view_widget.sizeHint().width() + ) + + if expected_width < 200: + expected_width = 200 + + if result.width() < expected_width: + result.setWidth(expected_width) + + return result + + def minimumSizeHint(self): + return self.sizeHint() + + def _mouse_release_callback(self): + """Mark this widget as selected on click.""" + + self.set_selected(True) + + @property + def is_selected(self): + """Is widget marked a selected. + + Returns: + bool: Item is selected or not. + """ + + return self._selected + + @property + def id(self): + return self._title_id + + def _change_style_property(self, selected): + """Change style of widget based on selection.""" + + value = "1" if selected else "" + self._title_frame.setProperty("selected", value) + self._title_frame.style().polish(self._title_frame) + + def set_selected(self, selected=None): + """Change selected state of widget.""" + + if selected is None: + selected = not self._selected + + # Clear instance view selection on deselect + if not selected: + self._instances_view.clearSelection() + + # Skip if has same value + if selected == self._selected: + return + + self._selected = selected + self._change_style_property(selected) + if selected: + self.selected.emit(self._title_id) + self._set_expanded(True) + + def _on_toggle_btn_click(self): + """Show/hide instances list.""" + + self._set_expanded() + + def _set_expanded(self, expanded=None): + if expanded is None: + expanded = not self._expanded + + elif expanded is self._expanded: + return + + if expanded and self._context_validation: + return + + self._expanded = expanded + self._view_widget.setVisible(expanded) + if expanded: + self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow) + else: + self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + + def _on_selection_change(self): + self.instance_changed.emit(self._title_id) + + def get_selected_instances(self): + if self._context_validation: + return [CONTEXT_ID] + sel_model = self._instances_view.selectionModel() + return [ + index.data(INSTANCE_ID_ROLE) + for index in sel_model.selectedIndexes() + if index.isValid() + ] + + def get_available_instances(self): + return list(self._instance_ids) + + +class ValidationArtistMessage(QtWidgets.QWidget): + def __init__(self, message, parent): + super(ValidationArtistMessage, self).__init__(parent) + + artist_msg_label = QtWidgets.QLabel(message, self) + artist_msg_label.setAlignment(QtCore.Qt.AlignCenter) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget( + artist_msg_label, 1, QtCore.Qt.AlignCenter + ) + + +class ValidationErrorsView(QtWidgets.QWidget): + selection_changed = QtCore.Signal() + + def __init__(self, parent): + super(ValidationErrorsView, self).__init__(parent) + + errors_scroll = VerticalScrollArea(self) + errors_scroll.setWidgetResizable(True) + + errors_widget = QtWidgets.QWidget(errors_scroll) + errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + errors_scroll.setWidget(errors_widget) + + errors_layout = QtWidgets.QVBoxLayout(errors_widget) + errors_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(errors_scroll, 1) + + self._errors_widget = errors_widget + self._errors_layout = errors_layout + self._title_widgets = {} + self._previous_select = None + + def _clear(self): + """Delete all dynamic widgets and hide all wrappers.""" + + self._title_widgets = {} + self._previous_select = None + while self._errors_layout.count(): + item = self._errors_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + def set_errors(self, grouped_error_items): + """Set errors into context and created titles. + + Args: + validation_error_report (PublishValidationErrorsReport): Report + with information about validation errors and publish plugin + actions. + """ + + self._clear() + + first_id = None + for title_item in grouped_error_items: + title_id = title_item["id"] + if first_id is None: + first_id = title_id + widget = ValidationErrorTitleWidget(title_id, title_item, self) + widget.selected.connect(self._on_select) + widget.instance_changed.connect(self._on_instance_change) + self._errors_layout.addWidget(widget) + self._title_widgets[title_id] = widget + + self._errors_layout.addStretch(1) + + if first_id: + self._title_widgets[first_id].set_selected(True) + else: + self.selection_changed.emit() + + self.updateGeometry() + + def _on_select(self, title_id): + if self._previous_select: + if self._previous_select.id == title_id: + return + self._previous_select.set_selected(False) + + self._previous_select = self._title_widgets[title_id] + self.selection_changed.emit() + + def _on_instance_change(self, title_id): + if self._previous_select and self._previous_select.id != title_id: + self._title_widgets[title_id].set_selected(True) + else: + self.selection_changed.emit() + + def get_selected_items(self): + if not self._previous_select: + return None, [] + + title_id = self._previous_select.id + instance_ids = self._previous_select.get_selected_instances() + if not instance_ids: + instance_ids = self._previous_select.get_available_instances() + return title_id, instance_ids + + +# ----- Publish instance report ----- +class _InstanceItem: + """Publish instance item for report UI. + + Contains only data related to an instance in publishing. Has implemented + sorting methods and prepares information, e.g. if contains error or + warnings. + """ + + _attrs = ( + "creator_identifier", + "family", + "label", + "name", + ) + + def __init__( + self, + instance_id, + creator_identifier, + family, + name, + label, + exists, + logs, + errored, + warned + ): + self.id = instance_id + self.creator_identifier = creator_identifier + self.family = family + self.name = name + self.label = label + self.exists = exists + self.logs = logs + self.errored = errored + self.warned = warned + + def __eq__(self, other): + for attr in self._attrs: + if getattr(self, attr) != getattr(other, attr): + return False + return True + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + for attr in self._attrs: + self_value = getattr(self, attr) + other_value = getattr(other, attr) + if self_value == other_value: + continue + values = [self_value, other_value] + values.sort() + return values[0] == other_value + return None + + def __lt__(self, other): + for attr in self._attrs: + self_value = getattr(self, attr) + other_value = getattr(other, attr) + if self_value == other_value: + continue + if self_value is None: + return False + if other_value is None: + return True + values = [self_value, other_value] + values.sort() + return values[0] == self_value + return None + + def __ge__(self, other): + if self == other: + return True + return self.__gt__(other) + + def __le__(self, other): + if self == other: + return True + return self.__lt__(other) + + @classmethod + def from_report(cls, instance_id, instance_data, logs): + errored, warned = cls.extract_basic_log_info(logs) + + return cls( + instance_id, + instance_data["creator_identifier"], + instance_data["family"], + instance_data["name"], + instance_data["label"], + instance_data["exists"], + logs, + errored, + warned, + ) + + @classmethod + def create_context_item(cls, context_label, logs): + errored, warned = cls.extract_basic_log_info(logs) + return cls( + CONTEXT_ID, + None, + "", + CONTEXT_LABEL, + context_label, + True, + logs, + errored, + warned + ) + + @staticmethod + def extract_basic_log_info(logs): + warned = False + errored = False + for log in logs: + if log["type"] == "error": + errored = True + elif log["type"] == "record": + level_no = log["levelno"] + if level_no and level_no >= logging.WARNING: + warned = True + + if warned and errored: + break + return errored, warned + + +class FamilyGroupLabel(QtWidgets.QWidget): + def __init__(self, family, parent): + super(FamilyGroupLabel, self).__init__(parent) + + self.setLayoutDirection(QtCore.Qt.LeftToRight) + + label_widget = QtWidgets.QLabel(family, self) + + line_widget = QtWidgets.QWidget(self) + line_widget.setObjectName("Separator") + line_widget.setMinimumHeight(2) + line_widget.setMaximumHeight(2) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setAlignment(QtCore.Qt.AlignVCenter) + main_layout.setSpacing(10) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(label_widget, 0) + main_layout.addWidget(line_widget, 1) + + +class PublishInstanceCardWidget(BaseClickableFrame): + selection_requested = QtCore.Signal(str) + + _warning_pix = None + _error_pix = None + _success_pix = None + _in_progress_pix = None + + def __init__(self, instance, icon, publish_finished, parent): + super(PublishInstanceCardWidget, self).__init__(parent) + + self.setObjectName("CardViewWidget") + + icon_widget = IconValuePixmapLabel(icon, self) + icon_widget.setObjectName("FamilyIconLabel") + + label_widget = QtWidgets.QLabel(instance.label, self) + + if instance.errored: + state_pix = self.get_error_pix() + elif instance.warned: + state_pix = self.get_warning_pix() + elif publish_finished: + state_pix = self.get_success_pix() + else: + state_pix = self.get_in_progress_pix() + + state_label = IconValuePixmapLabel(state_pix, self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(10, 7, 10, 7) + layout.addWidget(icon_widget, 0) + layout.addWidget(label_widget, 1) + layout.addWidget(state_label, 0) + + # Change direction -> parent is scroll area where scrolls are on + # left side + self.setLayoutDirection(QtCore.Qt.LeftToRight) + + self._id = instance.id + + self._selected = False + + self._update_style_state() + + @classmethod + def _prepare_pixes(cls): + publisher_colors = get_objected_colors("publisher") + cls._warning_pix = paint_image_with_color( + get_image("warning"), + publisher_colors["warning"].get_qcolor() + ) + cls._error_pix = paint_image_with_color( + get_image("error"), + publisher_colors["error"].get_qcolor() + ) + cls._success_pix = paint_image_with_color( + get_image("success"), + publisher_colors["success"].get_qcolor() + ) + cls._in_progress_pix = paint_image_with_color( + get_image("success"), + publisher_colors["progress"].get_qcolor() + ) + + @classmethod + def get_warning_pix(cls): + if cls._warning_pix is None: + cls._prepare_pixes() + return cls._warning_pix + + @classmethod + def get_error_pix(cls): + if cls._error_pix is None: + cls._prepare_pixes() + return cls._error_pix + + @classmethod + def get_success_pix(cls): + if cls._success_pix is None: + cls._prepare_pixes() + return cls._success_pix + + @classmethod + def get_in_progress_pix(cls): + if cls._in_progress_pix is None: + cls._prepare_pixes() + return cls._in_progress_pix + + @property + def id(self): + """Id of card. + + Returns: + str: Id of item. + """ + + return self._id + + @property + def is_selected(self): + """Is card selected. + + Returns: + bool: Item widget is marked as selected. + """ + + return self._selected + + def set_selected(self, selected): + """Set card as selected. + + Args: + selected (bool): Item should be marked as selected. + """ + + if selected == self._selected: + return + self._selected = selected + self._update_style_state() + + def _update_style_state(self): + state = "" + if self._selected: + state = "selected" + + self.setProperty("state", state) + self.style().polish(self) + + def _mouse_release_callback(self): + """Trigger selected signal.""" + + self.selection_requested.emit(self.id) + + +class PublishInstancesViewWidget(QtWidgets.QWidget): + # Sane minimum width of instance cards - size calulated using font metrics + _min_width_measure_string = 24 * "O" + selection_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(PublishInstancesViewWidget, self).__init__(parent) + + scroll_area = VerticalScrollArea(self) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + scrollbar_bg = scroll_area.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + scroll_area.setViewportMargins(0, 0, 0, 0) + + instance_view = QtWidgets.QWidget(scroll_area) + + scroll_area.setWidget(instance_view) + + instance_layout = QtWidgets.QVBoxLayout(instance_view) + instance_layout.setContentsMargins(0, 0, 0, 0) + instance_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scroll_area, 1) + + self._controller = controller + self._scroll_area = scroll_area + self._instance_view = instance_view + self._instance_layout = instance_layout + + self._context_widget = None + + self._widgets_by_instance_id = {} + self._group_widgets = [] + self._ordered_widgets = [] + + self._explicitly_selected_instance_ids = [] + + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + self.sizePolicy().verticalPolicy() + ) + + def sizeHint(self): + """Modify sizeHint based on visibility of scroll bars.""" + # Calculate width hint by content widget and vertical scroll bar + scroll_bar = self._scroll_area.verticalScrollBar() + view_size = self._instance_view.sizeHint().width() + fm = self._instance_view.fontMetrics() + width = ( + max(view_size, fm.width(self._min_width_measure_string)) + + scroll_bar.sizeHint().width() + ) + + result = super(PublishInstancesViewWidget, self).sizeHint() + result.setWidth(width) + return result + + def _get_selected_widgets(self): + return [ + widget + for widget in self._ordered_widgets + if widget.is_selected + ] + + def get_selected_instance_ids(self): + return [ + widget.id + for widget in self._get_selected_widgets() + ] + + def clear(self): + """Remove actions from widget.""" + while self._instance_layout.count(): + item = self._instance_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + self._ordered_widgets = [] + self._group_widgets = [] + self._widgets_by_instance_id = {} + + def update_instances(self, instance_items): + self.clear() + identifiers = { + instance_item.creator_identifier + for instance_item in instance_items + } + identifier_icons = { + identifier: self._controller.get_creator_icon(identifier) + for identifier in identifiers + } + + widgets = [] + group_widgets = [] + + publish_finished = ( + self._controller.publish_has_crashed + or self._controller.publish_has_validation_errors + or self._controller.publish_has_finished + ) + instances_by_family = collections.defaultdict(list) + for instance_item in instance_items: + if not instance_item.exists: + continue + instances_by_family[instance_item.family].append(instance_item) + + sorted_by_family = sorted( + instances_by_family.items(), key=lambda i: i[0] + ) + for family, instance_items in sorted_by_family: + # Only instance without family is context + if family: + group_widget = FamilyGroupLabel(family, self._instance_view) + self._instance_layout.addWidget(group_widget, 0) + group_widgets.append(group_widget) + + sorted_items = sorted(instance_items, key=lambda i: i.label) + for instance_item in sorted_items: + icon = identifier_icons[instance_item.creator_identifier] + + widget = PublishInstanceCardWidget( + instance_item, icon, publish_finished, self._instance_view + ) + widget.selection_requested.connect(self._on_selection_request) + self._instance_layout.addWidget(widget, 0) + + widgets.append(widget) + self._widgets_by_instance_id[widget.id] = widget + self._instance_layout.addStretch(1) + self._ordered_widgets = widgets + self._group_widgets = group_widgets + + def _on_selection_request(self, instance_id): + instance_widget = self._widgets_by_instance_id[instance_id] + selected_widgets = self._get_selected_widgets() + if instance_widget in selected_widgets: + instance_widget.set_selected(False) + else: + instance_widget.set_selected(True) + for widget in selected_widgets: + widget.set_selected(False) + self.selection_changed.emit() + + +class LogIconFrame(QtWidgets.QFrame): + """Draw log item icon next to message. + + Todos: + Paint event could be slow, maybe we could cache the image into pixmaps + so each item does not have to redraw it again. + """ + + info_color = QtGui.QColor("#ffffff") + error_color = QtGui.QColor("#ff4a4a") + level_to_color = dict(( + (10, QtGui.QColor("#ff66e8")), + (20, QtGui.QColor("#66abff")), + (30, QtGui.QColor("#ffba66")), + (40, QtGui.QColor("#ff4d58")), + (50, QtGui.QColor("#ff4f75")), + )) + _error_pix = None + _validation_error_pix = None + + def __init__(self, parent, log_type, log_level, is_validation_error): + super(LogIconFrame, self).__init__(parent) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self._is_record = log_type == "record" + self._is_error = log_type == "error" + self._is_validation_error = bool(is_validation_error) + self._log_color = self.level_to_color.get(log_level) + + @classmethod + def get_validation_error_icon(cls): + if cls._validation_error_pix is None: + cls._validation_error_pix = get_pixmap("warning") + return cls._validation_error_pix + + @classmethod + def get_error_icon(cls): + if cls._error_pix is None: + cls._error_pix = get_pixmap("error") + return cls._error_pix + + def minimumSizeHint(self): + fm = self.fontMetrics() + size = fm.height() + return QtCore.QSize(size, size) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + painter.setPen(QtCore.Qt.NoPen) + rect = self.rect() + new_size = min(rect.width(), rect.height()) + new_rect = QtCore.QRect(1, 1, new_size - 2, new_size - 2) + if self._is_error: + if self._is_validation_error: + error_icon = self.get_validation_error_icon() + else: + error_icon = self.get_error_icon() + scaled_error_icon = error_icon.scaled( + new_rect.size(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + painter.drawPixmap(new_rect, scaled_error_icon) + + else: + if self._is_record: + color = self._log_color + else: + color = QtGui.QColor(255, 255, 255) + painter.setBrush(color) + painter.drawEllipse(new_rect) + painter.end() + + +class LogItemWidget(QtWidgets.QWidget): + log_level_to_flag = { + 10: LOG_DEBUG_VISIBLE, + 20: LOG_INFO_VISIBLE, + 30: LOG_WARNING_VISIBLE, + 40: LOG_ERROR_VISIBLE, + 50: LOG_CRITICAL_VISIBLE, + } + + def __init__(self, log, parent): + super(LogItemWidget, self).__init__(parent) + + type_flag, level_n = self._get_log_info(log) + icon_label = LogIconFrame( + self, log["type"], level_n, log.get("is_validation_error")) + message_label = QtWidgets.QLabel(log["msg"].rstrip(), self) + message_label.setObjectName("PublishLogMessage") + message_label.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction) + message_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) + message_label.setWordWrap(True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(8) + main_layout.addWidget(icon_label, 0) + main_layout.addWidget(message_label, 1) + + self._type_flag = type_flag + self._plugin_id = log["plugin_id"] + self._log_type_filtered = False + self._plugin_filtered = False + + @property + def type_flag(self): + return self._type_flag + + @property + def plugin_id(self): + return self._plugin_id + + def _get_log_info(self, log): + log_type = log["type"] + if log_type == "error": + return ERROR_VISIBLE, None + + if log_type != "record": + return INFO_VISIBLE, None + + level_n = log["levelno"] + if level_n < 10: + level_n = 10 + elif level_n % 10 != 0: + level_n -= (level_n % 10) + 10 + + flag = self.log_level_to_flag.get(level_n, LOG_CRITICAL_VISIBLE) + return flag, level_n + + def _update_visibility(self): + self.setVisible( + not self._log_type_filtered + and not self._plugin_filtered + ) + + def set_log_type_filtered(self, filtered): + if filtered is self._log_type_filtered: + return + self._log_type_filtered = filtered + self._update_visibility() + + def set_plugin_filtered(self, filtered): + if filtered is self._plugin_filtered: + return + self._plugin_filtered = filtered + self._update_visibility() + + +class LogsWithIconsView(QtWidgets.QWidget): + """Show logs in a grid with 2 columns. + + First column is for icon second is for message. + + Todos: + Add filtering by type (exception, debug, info, etc.). + """ + + def __init__(self, logs, parent): + super(LogsWithIconsView, self).__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + logs_layout = QtWidgets.QVBoxLayout(self) + logs_layout.setContentsMargins(0, 0, 0, 0) + logs_layout.setSpacing(4) + + widgets_by_flag = collections.defaultdict(list) + widgets_by_plugins_id = collections.defaultdict(list) + + for log in logs: + widget = LogItemWidget(log, self) + widgets_by_flag[widget.type_flag].append(widget) + widgets_by_plugins_id[widget.plugin_id].append(widget) + logs_layout.addWidget(widget, 0) + + self._widgets_by_flag = widgets_by_flag + self._widgets_by_plugins_id = widgets_by_plugins_id + + self._visibility_by_flags = { + LOG_DEBUG_VISIBLE: True, + LOG_INFO_VISIBLE: True, + LOG_WARNING_VISIBLE: True, + LOG_ERROR_VISIBLE: True, + LOG_CRITICAL_VISIBLE: True, + ERROR_VISIBLE: True, + INFO_VISIBLE: True, + } + self._flags_filter = sum(self._visibility_by_flags.keys()) + self._plugin_ids_filter = None + + def _update_flags_filtering(self): + for flag in ( + LOG_DEBUG_VISIBLE, + LOG_INFO_VISIBLE, + LOG_WARNING_VISIBLE, + LOG_ERROR_VISIBLE, + LOG_CRITICAL_VISIBLE, + ERROR_VISIBLE, + INFO_VISIBLE, + ): + visible = (self._flags_filter & flag) != 0 + if visible is not self._visibility_by_flags[flag]: + self._visibility_by_flags[flag] = visible + for widget in self._widgets_by_flag[flag]: + widget.set_log_type_filtered(not visible) + + def _update_plugin_filtering(self): + if self._plugin_ids_filter is None: + for widgets in self._widgets_by_plugins_id.values(): + for widget in widgets: + widget.set_plugin_filtered(False) + + else: + for plugin_id, widgets in self._widgets_by_plugins_id.items(): + filtered = plugin_id not in self._plugin_ids_filter + for widget in widgets: + widget.set_plugin_filtered(filtered) + + def set_log_filters(self, visibility_filter, plugin_ids): + if self._flags_filter != visibility_filter: + self._flags_filter = visibility_filter + self._update_flags_filtering() + + if self._plugin_ids_filter != plugin_ids: + if plugin_ids is not None: + plugin_ids = set(plugin_ids) + self._plugin_ids_filter = plugin_ids + self._update_plugin_filtering() + + +class InstanceLogsWidget(QtWidgets.QWidget): + """Widget showing logs of one publish instance. + + Args: + instance (_InstanceItem): Item of instance used as data source. + parent (QtWidgets.QWidget): Parent widget. + """ + + def __init__(self, instance, parent): + super(InstanceLogsWidget, self).__init__(parent) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + label_widget = QtWidgets.QLabel(instance.label, self) + label_widget.setObjectName("PublishInstanceLogsLabel") + logs_grid = LogsWithIconsView(instance.logs, self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 0) + layout.addWidget(logs_grid, 0) + + self._logs_grid = logs_grid + + def set_log_filters(self, visibility_filter, plugin_ids): + """Change logs filter. + + Args: + visibility_filter (int): Number contained of flags for each log + type and level. + plugin_ids (Iterable[str]): Plugin ids to which are logs filtered. + """ + + self._logs_grid.set_log_filters(visibility_filter, plugin_ids) + + +class InstancesLogsView(QtWidgets.QFrame): + """Publish instances logs view widget.""" + + def __init__(self, parent): + super(InstancesLogsView, self).__init__(parent) + self.setObjectName("InstancesLogsView") + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + scroll_area.setAttribute(QtCore.Qt.WA_TranslucentBackground) + scrollbar_bg = scroll_area.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + content_wrap_widget = QtWidgets.QWidget(scroll_area) + content_wrap_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + content_widget = QtWidgets.QWidget(content_wrap_widget) + content_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setSpacing(15) + + scroll_area.setWidget(content_wrap_widget) + + content_wrap_layout = QtWidgets.QVBoxLayout(content_wrap_widget) + content_wrap_layout.setContentsMargins(0, 0, 0, 0) + content_wrap_layout.addWidget(content_widget, 0) + content_wrap_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scroll_area, 1) + + self._visible_filters = ( + LOG_INFO_VISIBLE + | LOG_WARNING_VISIBLE + | LOG_ERROR_VISIBLE + | LOG_CRITICAL_VISIBLE + | ERROR_VISIBLE + | INFO_VISIBLE + ) + + self._content_widget = content_widget + self._content_layout = content_layout + + self._instances_order = [] + self._instances_by_id = {} + self._views_by_instance_id = {} + self._is_showed = False + self._clear_needed = False + self._update_needed = False + self._instance_ids_filter = [] + self._plugin_ids_filter = None + + def showEvent(self, event): + super(InstancesLogsView, self).showEvent(event) + self._is_showed = True + self._update_instances() + + def hideEvent(self, event): + super(InstancesLogsView, self).hideEvent(event) + self._is_showed = False + + def closeEvent(self, event): + super(InstancesLogsView, self).closeEvent(event) + self._is_showed = False + + def _update_instances(self): + if not self._is_showed: + return + + if self._clear_needed: + self._clear_widgets() + self._clear_needed = False + + if not self._update_needed: + return + self._update_needed = False + + instance_ids = self._instance_ids_filter + to_hide = set() + if not instance_ids: + instance_ids = self._instances_by_id + else: + to_hide = set(self._instances_by_id) - set(instance_ids) + + for instance_id in instance_ids: + widget = self._views_by_instance_id.get(instance_id) + if widget is None: + instance = self._instances_by_id[instance_id] + widget = InstanceLogsWidget(instance, self._content_widget) + self._views_by_instance_id[instance_id] = widget + self._content_layout.addWidget(widget, 0) + + widget.setVisible(True) + widget.set_log_filters( + self._visible_filters, self._plugin_ids_filter + ) + + for instance_id in to_hide: + widget = self._views_by_instance_id.get(instance_id) + if widget is not None: + widget.setVisible(False) + + def _clear_widgets(self): + """Remove all widgets from layout and from cache.""" + + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + self._views_by_instance_id = {} + + def update_instances(self, instances): + """Update publish instance from report. + + Args: + instances (list[_InstanceItem]): Instance data from report. + """ + + self._instances_order = [ + instance.id for instance in instances + ] + self._instances_by_id = { + instance.id: instance + for instance in instances + } + self._instance_ids_filter = [] + self._plugin_ids_filter = None + self._clear_needed = True + self._update_needed = True + self._update_instances() + + def set_instances_filter(self, instance_ids=None): + """Set instance filter. + + Args: + instance_ids (Optional[list[str]]): List of instances to keep + visible. Pass empty list to hide all items. + """ + + self._instance_ids_filter = instance_ids + self._update_needed = True + self._update_instances() + + def set_plugins_filter(self, plugin_ids=None): + if self._plugin_ids_filter == plugin_ids: + return + self._plugin_ids_filter = plugin_ids + self._update_needed = True + self._update_instances() + + +class CrashWidget(QtWidgets.QWidget): + """Widget shown when publishing crashes. + + Contains only minimal information for artist with easy access to report + actions. + """ + + def __init__(self, controller, parent): + super(CrashWidget, self).__init__(parent) + + main_label = QtWidgets.QLabel("This is not your fault", self) + main_label.setAlignment(QtCore.Qt.AlignCenter) + main_label.setObjectName("PublishCrashMainLabel") + + report_label = QtWidgets.QLabel( + ( + "Please report the error to your pipeline support" + " using one of the options below." + ), + self + ) + report_label.setAlignment(QtCore.Qt.AlignCenter) + report_label.setWordWrap(True) + report_label.setObjectName("PublishCrashReportLabel") + + btns_widget = QtWidgets.QWidget(self) + copy_clipboard_btn = QtWidgets.QPushButton( + "Copy to clipboard", btns_widget) + save_to_disk_btn = QtWidgets.QPushButton( + "Save to disk", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addStretch(1) + btns_layout.addWidget(copy_clipboard_btn, 0) + btns_layout.addSpacing(20) + btns_layout.addWidget(save_to_disk_btn, 0) + btns_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.addStretch(1) + layout.addWidget(main_label, 0) + layout.addSpacing(20) + layout.addWidget(report_label, 0) + layout.addSpacing(20) + layout.addWidget(btns_widget, 0) + layout.addStretch(2) + + copy_clipboard_btn.clicked.connect(self._on_copy_to_clipboard) + save_to_disk_btn.clicked.connect(self._on_save_to_disk_click) + + self._controller = controller + + def _on_copy_to_clipboard(self): + self._controller.event_system.emit( + "copy_report.request", {}, "report_page") + + def _on_save_to_disk_click(self): + self._controller.event_system.emit( + "export_report.request", {}, "report_page") + + +class ErrorDetailsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(ErrorDetailsWidget, self).__init__(parent) + + inputs_widget = QtWidgets.QWidget(self) + # Error 'Description' input + error_description_input = ExpandingTextEdit(inputs_widget) + error_description_input.setObjectName("InfoText") + error_description_input.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + # Error 'Details' widget -> Collapsible + error_details_widget = QtWidgets.QWidget(inputs_widget) + + error_details_top = ClickableFrame(error_details_widget) + + error_details_expand_btn = ClassicExpandBtn(error_details_top) + error_details_expand_label = QtWidgets.QLabel( + "Details", error_details_top) + + line_widget = SeparatorWidget(1, parent=error_details_top) + + error_details_top_l = QtWidgets.QHBoxLayout(error_details_top) + error_details_top_l.setContentsMargins(0, 0, 10, 0) + error_details_top_l.addWidget(error_details_expand_btn, 0) + error_details_top_l.addWidget(error_details_expand_label, 0) + error_details_top_l.addWidget(line_widget, 1) + + error_details_input = ExpandingTextEdit(error_details_widget) + error_details_input.setObjectName("InfoText") + error_details_input.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + error_details_input.setVisible(not error_details_expand_btn.collapsed) + + error_details_layout = QtWidgets.QVBoxLayout(error_details_widget) + error_details_layout.setContentsMargins(0, 0, 0, 0) + error_details_layout.addWidget(error_details_top, 0) + error_details_layout.addWidget(error_details_input, 0) + error_details_layout.addStretch(1) + + # Description and Details layout + inputs_layout = QtWidgets.QVBoxLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.setSpacing(10) + inputs_layout.addWidget(error_description_input, 0) + inputs_layout.addWidget(error_details_widget, 1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(inputs_widget, 1) + + error_details_top.clicked.connect(self._on_detail_toggle) + + self._error_details_widget = error_details_widget + self._error_description_input = error_description_input + self._error_details_expand_btn = error_details_expand_btn + self._error_details_input = error_details_input + + def _on_detail_toggle(self): + self._error_details_expand_btn.set_collapsed() + self._error_details_input.setVisible( + not self._error_details_expand_btn.collapsed) + + def set_error_item(self, error_item): + detail = "" + description = "" + if error_item: + description = error_item.description or description + detail = error_item.detail or detail + + if commonmark: + self._error_description_input.setHtml( + commonmark.commonmark(description) + ) + self._error_details_input.setHtml( + commonmark.commonmark(detail) + ) + + elif hasattr(self._error_details_input, "setMarkdown"): + self._error_description_input.setMarkdown(description) + self._error_details_input.setMarkdown(detail) + + else: + self._error_description_input.setText(description) + self._error_details_input.setText(detail) + + self._error_details_widget.setVisible(bool(detail)) + + +class ReportsWidget(QtWidgets.QWidget): + """ + # Crash layout + ┌──────┬─────────┬─────────┐ + │Views │ Logs │ Details │ + │ │ │ │ + │ │ │ │ + └──────┴─────────┴─────────┘ + # Success layout + ┌──────┬───────────────────┐ + │View │ Logs │ + │ │ │ + │ │ │ + └──────┴───────────────────┘ + # Validation errors layout + ┌──────┬─────────┬─────────┐ + │Views │ Actions │ │ + │ ├─────────┤ Details │ + │ │ Logs │ │ + │ │ │ │ + └──────┴─────────┴─────────┘ + """ + + def __init__(self, controller, parent): + super(ReportsWidget, self).__init__(parent) + + # Instances view + views_widget = QtWidgets.QWidget(self) + + instances_view = PublishInstancesViewWidget(controller, views_widget) + + validation_error_view = ValidationErrorsView(views_widget) + + views_layout = QtWidgets.QStackedLayout(views_widget) + views_layout.setContentsMargins(0, 0, 0, 0) + views_layout.addWidget(instances_view) + views_layout.addWidget(validation_error_view) + + views_layout.setCurrentWidget(instances_view) + + # Error description with actions and optional detail + details_widget = QtWidgets.QFrame(self) + details_widget.setObjectName("PublishInstancesDetails") + + # Actions widget + actions_widget = ValidateActionsWidget(controller, details_widget) + + pages_widget = QtWidgets.QWidget(details_widget) + + # Logs view + logs_view = InstancesLogsView(pages_widget) + + # Validation details + # Description and details inputs are in scroll + # - single scroll for both inputs, they are forced to not use theirs + detail_inputs_spacer = QtWidgets.QWidget(pages_widget) + detail_inputs_spacer.setMinimumWidth(30) + detail_inputs_spacer.setMaximumWidth(30) + + detail_input_scroll = QtWidgets.QScrollArea(pages_widget) + + detail_inputs_widget = ErrorDetailsWidget(detail_input_scroll) + detail_inputs_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + detail_input_scroll.setWidget(detail_inputs_widget) + detail_input_scroll.setWidgetResizable(True) + detail_input_scroll.setViewportMargins(0, 0, 0, 0) + + # Crash information + crash_widget = CrashWidget(controller, details_widget) + + # Layout pages + pages_layout = QtWidgets.QHBoxLayout(pages_widget) + pages_layout.setContentsMargins(0, 0, 0, 0) + pages_layout.addWidget(logs_view, 1) + pages_layout.addWidget(detail_inputs_spacer, 0) + pages_layout.addWidget(detail_input_scroll, 1) + pages_layout.addWidget(crash_widget, 1) + + details_layout = QtWidgets.QVBoxLayout(details_widget) + margins = details_layout.contentsMargins() + margins.setTop(margins.top() * 2) + margins.setBottom(margins.bottom() * 2) + details_layout.setContentsMargins(margins) + details_layout.setSpacing(margins.top()) + details_layout.addWidget(actions_widget, 0) + details_layout.addWidget(pages_widget, 1) + + content_layout = QtWidgets.QHBoxLayout(self) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.addWidget(views_widget, 0) + content_layout.addWidget(details_widget, 1) + + instances_view.selection_changed.connect(self._on_instance_selection) + validation_error_view.selection_changed.connect( + self._on_error_selection) + + self._views_layout = views_layout + self._instances_view = instances_view + self._validation_error_view = validation_error_view + + self._actions_widget = actions_widget + self._detail_inputs_widget = detail_inputs_widget + self._logs_view = logs_view + self._detail_inputs_spacer = detail_inputs_spacer + self._detail_input_scroll = detail_input_scroll + self._crash_widget = crash_widget + + self._controller = controller + + self._validation_errors_by_id = {} + + def _get_instance_items(self): + report = self._controller.get_publish_report() + context_label = report["context"]["label"] or CONTEXT_LABEL + instances_by_id = report["instances"] + plugins_info = report["plugins_data"] + logs_by_instance_id = collections.defaultdict(list) + for plugin_info in plugins_info: + plugin_id = plugin_info["id"] + for instance_info in plugin_info["instances_data"]: + instance_id = instance_info["id"] or CONTEXT_ID + for log in instance_info["logs"]: + log["plugin_id"] = plugin_id + logs_by_instance_id[instance_id].extend(instance_info["logs"]) + + context_item = _InstanceItem.create_context_item( + context_label, logs_by_instance_id[CONTEXT_ID]) + instance_items = [ + _InstanceItem.from_report( + instance_id, instance, logs_by_instance_id[instance_id] + ) + for instance_id, instance in instances_by_id.items() + if instance["exists"] + ] + instance_items.sort() + instance_items.insert(0, context_item) + return instance_items + + def update_data(self): + view = self._instances_view + validation_error_mode = False + if ( + not self._controller.publish_has_crashed + and self._controller.publish_has_validation_errors + ): + view = self._validation_error_view + validation_error_mode = True + + self._actions_widget.set_visible_mode(validation_error_mode) + self._detail_inputs_spacer.setVisible(validation_error_mode) + self._detail_input_scroll.setVisible(validation_error_mode) + self._views_layout.setCurrentWidget(view) + + self._crash_widget.setVisible(self._controller.publish_has_crashed) + self._logs_view.setVisible(not self._controller.publish_has_crashed) + + # Instance view & logs update + instance_items = self._get_instance_items() + self._instances_view.update_instances(instance_items) + self._logs_view.update_instances(instance_items) + + # Validation errors + validation_errors = self._controller.get_validation_errors() + grouped_error_items = validation_errors.group_items_by_title() + + validation_errors_by_id = { + title_item["id"]: title_item + for title_item in grouped_error_items + } + + self._validation_errors_by_id = validation_errors_by_id + self._validation_error_view.set_errors(grouped_error_items) + + def _on_instance_selection(self): + instance_ids = self._instances_view.get_selected_instance_ids() + self._logs_view.set_instances_filter(instance_ids) + + def _on_error_selection(self): + title_id, instance_ids = ( + self._validation_error_view.get_selected_items()) + error_info = self._validation_errors_by_id.get(title_id) + if error_info is None: + self._actions_widget.set_error_info(None) + self._detail_inputs_widget.set_error_item(None) + return + + self._logs_view.set_instances_filter(instance_ids) + self._logs_view.set_plugins_filter([error_info["plugin_id"]]) + + match_error_item = None + for error_item in error_info["error_items"]: + instance_id = error_item.instance_id or CONTEXT_ID + if instance_id in instance_ids: + match_error_item = error_item + break + + self._actions_widget.set_error_info(error_info) + self._detail_inputs_widget.set_error_item(match_error_item) + + +class ReportPageWidget(QtWidgets.QFrame): + """Widgets showing report for artis. + + There are 5 possible states: + 1. Publishing did not start yet. > Only label. + 2. Publishing is paused. ┐ + 3. Publishing successfully finished. │> Instances with logs. + 4. Publishing crashed. ┘ + 5. Crashed because of validation error. > Errors with logs. + + This widget is shown if validation errors happened during validation part. + + Shows validation error titles with instances on which they happened + and validation error detail with possible actions (repair). + """ + + def __init__(self, controller, parent): + super(ReportPageWidget, self).__init__(parent) + + header_label = QtWidgets.QLabel(self) + header_label.setAlignment(QtCore.Qt.AlignCenter) + header_label.setObjectName("PublishReportHeader") + + publish_instances_widget = ReportsWidget(controller, self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(header_label, 0) + layout.addWidget(publish_instances_widget, 0) + + controller.event_system.add_callback( + "publish.process.started", self._on_publish_start + ) + controller.event_system.add_callback( + "publish.reset.finished", self._on_publish_reset + ) + controller.event_system.add_callback( + "publish.process.stopped", self._on_publish_stop + ) + + self._header_label = header_label + self._publish_instances_widget = publish_instances_widget + + self._controller = controller + + def _update_label(self): + if not self._controller.publish_has_started: + # This probably never happen when this widget is visible + header_label = "Nothing to report until you run publish" + elif self._controller.publish_has_crashed: + header_label = "Publish error report" + elif self._controller.publish_has_validation_errors: + header_label = "Publish validation report" + elif self._controller.publish_has_finished: + header_label = "Publish success report" + else: + header_label = "Publish report" + self._header_label.setText(header_label) + + def _update_state(self): + self._update_label() + publish_started = self._controller.publish_has_started + self._publish_instances_widget.setVisible(publish_started) + if publish_started: + self._publish_instances_widget.update_data() + + self.updateGeometry() + + def _on_publish_start(self): + self._update_state() + + def _on_publish_reset(self): + self._update_state() + + def _on_publish_stop(self): + self._update_state() diff --git a/openpype/tools/publisher/widgets/thumbnail_widget.py b/openpype/tools/publisher/widgets/thumbnail_widget.py index e234f4cdc1..b17ca0adc8 100644 --- a/openpype/tools/publisher/widgets/thumbnail_widget.py +++ b/openpype/tools/publisher/widgets/thumbnail_widget.py @@ -75,6 +75,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): painter = QtGui.QPainter() painter.begin(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) painter.drawPixmap(0, 0, self._cached_pix) painter.end() @@ -183,6 +184,18 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): backgrounded_images.append(new_pix) return backgrounded_images + def _paint_dash_line(self, painter, rect): + pen = QtGui.QPen() + pen.setWidth(1) + pen.setBrush(QtCore.Qt.darkGray) + pen.setStyle(QtCore.Qt.DashLine) + + new_rect = rect.adjusted(1, 1, -1, -1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + # painter.drawRect(rect) + painter.drawRect(new_rect) + def _cache_pix(self): rect = self.rect() rect_width = rect.width() @@ -264,13 +277,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): # Draw drop enabled dashes if used_default_pix: - pen = QtGui.QPen() - pen.setWidth(1) - pen.setBrush(QtCore.Qt.darkGray) - pen.setStyle(QtCore.Qt.DashLine) - final_painter.setPen(pen) - final_painter.setBrush(QtCore.Qt.transparent) - final_painter.drawRect(rect) + self._paint_dash_line(final_painter, rect) final_painter.end() diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py deleted file mode 100644 index 0abe85c0b8..0000000000 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ /dev/null @@ -1,715 +0,0 @@ -# -*- coding: utf-8 -*- -try: - import commonmark -except Exception: - commonmark = None - -from qtpy import QtWidgets, QtCore, QtGui - -from openpype.tools.utils import BaseClickableFrame, ClickableFrame -from .widgets import ( - IconValuePixmapLabel -) -from ..constants import ( - INSTANCE_ID_ROLE -) - - -class ValidationErrorInstanceList(QtWidgets.QListView): - """List of publish instances that caused a validation error. - - Instances are collected per plugin's validation error title. - """ - def __init__(self, *args, **kwargs): - super(ValidationErrorInstanceList, self).__init__(*args, **kwargs) - - self.setObjectName("ValidationErrorInstanceList") - - self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - - def minimumSizeHint(self): - return self.sizeHint() - - def sizeHint(self): - result = super(ValidationErrorInstanceList, self).sizeHint() - row_count = self.model().rowCount() - height = 0 - if row_count > 0: - height = self.sizeHintForRow(0) * row_count - result.setHeight(height) - return result - - -class ValidationErrorTitleWidget(QtWidgets.QWidget): - """Title of validation error. - - Widget is used as radio button so requires clickable functionality and - changing style on selection/deselection. - - Has toggle button to show/hide instances on which validation error happened - if there is a list (Valdation error may happen on context). - """ - - selected = QtCore.Signal(int) - instance_changed = QtCore.Signal(int) - - def __init__(self, index, error_info, parent): - super(ValidationErrorTitleWidget, self).__init__(parent) - - self._index = index - self._error_info = error_info - self._selected = False - - title_frame = ClickableFrame(self) - title_frame.setObjectName("ValidationErrorTitleFrame") - - toggle_instance_btn = QtWidgets.QToolButton(title_frame) - toggle_instance_btn.setObjectName("ArrowBtn") - toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) - toggle_instance_btn.setMaximumWidth(14) - - label_widget = QtWidgets.QLabel(error_info["title"], title_frame) - - title_frame_layout = QtWidgets.QHBoxLayout(title_frame) - title_frame_layout.addWidget(label_widget, 1) - title_frame_layout.addWidget(toggle_instance_btn, 0) - - instances_model = QtGui.QStandardItemModel() - - help_text_by_instance_id = {} - - items = [] - context_validation = False - for error_item in error_info["error_items"]: - context_validation = error_item.context_validation - if context_validation: - toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) - description = self._prepare_description(error_item) - help_text_by_instance_id[None] = description - # Add fake item to have minimum size hint of view widget - items.append(QtGui.QStandardItem("Context")) - continue - - label = error_item.instance_label - item = QtGui.QStandardItem(label) - item.setFlags( - QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - ) - item.setData(label, QtCore.Qt.ToolTipRole) - item.setData(error_item.instance_id, INSTANCE_ID_ROLE) - items.append(item) - description = self._prepare_description(error_item) - help_text_by_instance_id[error_item.instance_id] = description - - if items: - root_item = instances_model.invisibleRootItem() - root_item.appendRows(items) - - instances_view = ValidationErrorInstanceList(self) - instances_view.setModel(instances_model) - - self.setLayoutDirection(QtCore.Qt.LeftToRight) - - view_widget = QtWidgets.QWidget(self) - view_layout = QtWidgets.QHBoxLayout(view_widget) - view_layout.setContentsMargins(0, 0, 0, 0) - view_layout.setSpacing(0) - view_layout.addSpacing(14) - view_layout.addWidget(instances_view, 0) - - layout = QtWidgets.QVBoxLayout(self) - layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(title_frame, 0) - layout.addWidget(view_widget, 0) - view_widget.setVisible(False) - - if not context_validation: - toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) - - title_frame.clicked.connect(self._mouse_release_callback) - instances_view.selectionModel().selectionChanged.connect( - self._on_seleciton_change - ) - - self._title_frame = title_frame - - self._toggle_instance_btn = toggle_instance_btn - - self._view_widget = view_widget - - self._instances_model = instances_model - self._instances_view = instances_view - - self._context_validation = context_validation - self._help_text_by_instance_id = help_text_by_instance_id - - self._expanded = False - - def sizeHint(self): - result = super(ValidationErrorTitleWidget, self).sizeHint() - expected_width = max( - self._view_widget.minimumSizeHint().width(), - self._view_widget.sizeHint().width() - ) - - if expected_width < 200: - expected_width = 200 - - if result.width() < expected_width: - result.setWidth(expected_width) - - return result - - def minimumSizeHint(self): - return self.sizeHint() - - def _prepare_description(self, error_item): - """Prepare description text for detail intput. - - Args: - error_item (ValidationErrorItem): Item which hold information about - validation error. - - Returns: - str: Prepared detailed description. - """ - - dsc = error_item.description - detail = error_item.detail - if detail: - dsc += "

{}".format(detail) - - description = dsc - if commonmark: - description = commonmark.commonmark(dsc) - return description - - def _mouse_release_callback(self): - """Mark this widget as selected on click.""" - - self.set_selected(True) - - def current_description_text(self): - if self._context_validation: - return self._help_text_by_instance_id[None] - index = self._instances_view.currentIndex() - # TODO make sure instance is selected - if not index.isValid(): - index = self._instances_model.index(0, 0) - - indence_id = index.data(INSTANCE_ID_ROLE) - return self._help_text_by_instance_id[indence_id] - - @property - def is_selected(self): - """Is widget marked a selected. - - Returns: - bool: Item is selected or not. - """ - - return self._selected - - @property - def index(self): - """Widget's index set by parent. - - Returns: - int: Index of widget. - """ - - return self._index - - def set_index(self, index): - """Set index of widget (called by parent). - - Args: - int: New index of widget. - """ - - self._index = index - - def _change_style_property(self, selected): - """Change style of widget based on selection.""" - - value = "1" if selected else "" - self._title_frame.setProperty("selected", value) - self._title_frame.style().polish(self._title_frame) - - def set_selected(self, selected=None): - """Change selected state of widget.""" - - if selected is None: - selected = not self._selected - - # Clear instance view selection on deselect - if not selected: - self._instances_view.clearSelection() - - # Skip if has same value - if selected == self._selected: - return - - self._selected = selected - self._change_style_property(selected) - if selected: - self.selected.emit(self._index) - self._set_expanded(True) - - def _on_toggle_btn_click(self): - """Show/hide instances list.""" - - self._set_expanded() - - def _set_expanded(self, expanded=None): - if expanded is None: - expanded = not self._expanded - - elif expanded is self._expanded: - return - - if expanded and self._context_validation: - return - - self._expanded = expanded - self._view_widget.setVisible(expanded) - if expanded: - self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow) - else: - self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) - - def _on_seleciton_change(self): - sel_model = self._instances_view.selectionModel() - if sel_model.selectedIndexes(): - self.instance_changed.emit(self._index) - - -class ActionButton(BaseClickableFrame): - """Plugin's action callback button. - - Action may have label or icon or both. - - Args: - plugin_action_item (PublishPluginActionItem): Action item that can be - triggered by it's id. - """ - - action_clicked = QtCore.Signal(str, str) - - def __init__(self, plugin_action_item, parent): - super(ActionButton, self).__init__(parent) - - self.setObjectName("ValidationActionButton") - - self.plugin_action_item = plugin_action_item - - action_label = plugin_action_item.label - action_icon = plugin_action_item.icon - label_widget = QtWidgets.QLabel(action_label, self) - icon_label = None - if action_icon: - icon_label = IconValuePixmapLabel(action_icon, self) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(5, 0, 5, 0) - layout.addWidget(label_widget, 1) - if icon_label: - layout.addWidget(icon_label, 0) - - self.setSizePolicy( - QtWidgets.QSizePolicy.Minimum, - self.sizePolicy().verticalPolicy() - ) - - def _mouse_release_callback(self): - self.action_clicked.emit( - self.plugin_action_item.plugin_id, - self.plugin_action_item.action_id - ) - - -class ValidateActionsWidget(QtWidgets.QFrame): - """Wrapper widget for plugin actions. - - Change actions based on selected validation error. - """ - - def __init__(self, controller, parent): - super(ValidateActionsWidget, self).__init__(parent) - - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - - content_widget = QtWidgets.QWidget(self) - content_layout = QtWidgets.QVBoxLayout(content_widget) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(content_widget) - - self._controller = controller - self._content_widget = content_widget - self._content_layout = content_layout - self._actions_mapping = {} - - def clear(self): - """Remove actions from widget.""" - while self._content_layout.count(): - item = self._content_layout.takeAt(0) - widget = item.widget() - if widget: - widget.setVisible(False) - widget.deleteLater() - self._actions_mapping = {} - - def set_error_item(self, error_item): - """Set selected plugin and show it's actions. - - Clears current actions from widget and recreate them from the plugin. - - Args: - Dict[str, Any]: Object holding error items, title and possible - actions to run. - """ - - self.clear() - - if not error_item: - self.setVisible(False) - return - - plugin_action_items = error_item["plugin_action_items"] - for plugin_action_item in plugin_action_items: - if not plugin_action_item.active: - continue - - if plugin_action_item.on_filter not in ("failed", "all"): - continue - - action_id = plugin_action_item.action_id - self._actions_mapping[action_id] = plugin_action_item - - action_btn = ActionButton(plugin_action_item, self._content_widget) - action_btn.action_clicked.connect(self._on_action_click) - self._content_layout.addWidget(action_btn) - - if self._content_layout.count() > 0: - self.setVisible(True) - self._content_layout.addStretch(1) - else: - self.setVisible(False) - - def _on_action_click(self, plugin_id, action_id): - self._controller.run_action(plugin_id, action_id) - - -class VerticallScrollArea(QtWidgets.QScrollArea): - """Scroll area for validation error titles. - - The biggest difference is that the scroll area has scroll bar on left side - and resize of content will also resize scrollarea itself. - - Resize if deferred by 100ms because at the moment of resize are not yet - propagated sizes and visibility of scroll bars. - """ - - def __init__(self, *args, **kwargs): - super(VerticallScrollArea, self).__init__(*args, **kwargs) - - self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.setLayoutDirection(QtCore.Qt.RightToLeft) - - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - # Background of scrollbar will be transparent - scrollbar_bg = self.verticalScrollBar().parent() - if scrollbar_bg: - scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.setViewportMargins(0, 0, 0, 0) - - self.verticalScrollBar().installEventFilter(self) - - # Timer with 100ms offset after changing size - size_changed_timer = QtCore.QTimer() - size_changed_timer.setInterval(100) - size_changed_timer.setSingleShot(True) - - size_changed_timer.timeout.connect(self._on_timer_timeout) - self._size_changed_timer = size_changed_timer - - def setVerticalScrollBar(self, widget): - old_widget = self.verticalScrollBar() - if old_widget: - old_widget.removeEventFilter(self) - - super(VerticallScrollArea, self).setVerticalScrollBar(widget) - if widget: - widget.installEventFilter(self) - - def setWidget(self, widget): - old_widget = self.widget() - if old_widget: - old_widget.removeEventFilter(self) - - super(VerticallScrollArea, self).setWidget(widget) - if widget: - widget.installEventFilter(self) - - def _on_timer_timeout(self): - width = self.widget().width() - if self.verticalScrollBar().isVisible(): - width += self.verticalScrollBar().width() - self.setMinimumWidth(width) - - def eventFilter(self, obj, event): - if ( - event.type() == QtCore.QEvent.Resize - and (obj is self.widget() or obj is self.verticalScrollBar()) - ): - self._size_changed_timer.start() - return super(VerticallScrollArea, self).eventFilter(obj, event) - - -class ValidationArtistMessage(QtWidgets.QWidget): - def __init__(self, message, parent): - super(ValidationArtistMessage, self).__init__(parent) - - artist_msg_label = QtWidgets.QLabel(message, self) - artist_msg_label.setAlignment(QtCore.Qt.AlignCenter) - - main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget( - artist_msg_label, 1, QtCore.Qt.AlignCenter - ) - - -class ValidationsWidget(QtWidgets.QFrame): - """Widgets showing validation error. - - This widget is shown if validation error/s happened during validation part. - - Shows validation error titles with instances on which happened and - validation error detail with possible actions (repair). - - ┌──────┬────────────────┬───────┐ - │titles│ │actions│ - │ │ │ │ - │ │ Error detail │ │ - │ │ │ │ - │ │ │ │ - └──────┴────────────────┴───────┘ - """ - - def __init__(self, controller, parent): - super(ValidationsWidget, self).__init__(parent) - - # Before publishing - before_publish_widget = ValidationArtistMessage( - "Nothing to report until you run publish", self - ) - # After success publishing - publish_started_widget = ValidationArtistMessage( - "So far so good", self - ) - # After success publishing - publish_stop_ok_widget = ValidationArtistMessage( - "Publishing finished successfully", self - ) - # After failed publishing (not with validation error) - publish_stop_fail_widget = ValidationArtistMessage( - "This is not your fault...", self - ) - - # Validation errors - validations_widget = QtWidgets.QWidget(self) - - content_widget = QtWidgets.QWidget(validations_widget) - - errors_scroll = VerticallScrollArea(content_widget) - errors_scroll.setWidgetResizable(True) - - errors_widget = QtWidgets.QWidget(errors_scroll) - errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - errors_layout = QtWidgets.QVBoxLayout(errors_widget) - errors_layout.setContentsMargins(0, 0, 0, 0) - - errors_scroll.setWidget(errors_widget) - - error_details_frame = QtWidgets.QFrame(content_widget) - error_details_input = QtWidgets.QTextEdit(error_details_frame) - error_details_input.setObjectName("InfoText") - error_details_input.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - - actions_widget = ValidateActionsWidget(controller, content_widget) - actions_widget.setMinimumWidth(140) - - error_details_layout = QtWidgets.QHBoxLayout(error_details_frame) - error_details_layout.addWidget(error_details_input, 1) - error_details_layout.addWidget(actions_widget, 0) - - content_layout = QtWidgets.QHBoxLayout(content_widget) - content_layout.setSpacing(0) - content_layout.setContentsMargins(0, 0, 0, 0) - - content_layout.addWidget(errors_scroll, 0) - content_layout.addWidget(error_details_frame, 1) - - top_label = QtWidgets.QLabel( - "Publish validation report", content_widget - ) - top_label.setObjectName("PublishInfoMainLabel") - top_label.setAlignment(QtCore.Qt.AlignCenter) - - validation_layout = QtWidgets.QVBoxLayout(validations_widget) - validation_layout.setContentsMargins(0, 0, 0, 0) - validation_layout.addWidget(top_label, 0) - validation_layout.addWidget(content_widget, 1) - - main_layout = QtWidgets.QStackedLayout(self) - main_layout.addWidget(before_publish_widget) - main_layout.addWidget(publish_started_widget) - main_layout.addWidget(publish_stop_ok_widget) - main_layout.addWidget(publish_stop_fail_widget) - main_layout.addWidget(validations_widget) - - main_layout.setCurrentWidget(before_publish_widget) - - controller.event_system.add_callback( - "publish.process.started", self._on_publish_start - ) - controller.event_system.add_callback( - "publish.reset.finished", self._on_publish_reset - ) - controller.event_system.add_callback( - "publish.process.stopped", self._on_publish_stop - ) - - self._main_layout = main_layout - - self._before_publish_widget = before_publish_widget - self._publish_started_widget = publish_started_widget - self._publish_stop_ok_widget = publish_stop_ok_widget - self._publish_stop_fail_widget = publish_stop_fail_widget - self._validations_widget = validations_widget - - self._top_label = top_label - self._errors_widget = errors_widget - self._errors_layout = errors_layout - self._error_details_frame = error_details_frame - self._error_details_input = error_details_input - self._actions_widget = actions_widget - - self._title_widgets = {} - self._error_info = {} - self._previous_select = None - - self._controller = controller - - def clear(self): - """Delete all dynamic widgets and hide all wrappers.""" - self._title_widgets = {} - self._error_info = {} - self._previous_select = None - while self._errors_layout.count(): - item = self._errors_layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - - self._top_label.setVisible(False) - self._error_details_frame.setVisible(False) - self._errors_widget.setVisible(False) - self._actions_widget.setVisible(False) - - def _set_errors(self, validation_error_report): - """Set errors into context and created titles. - - Args: - validation_error_report (PublishValidationErrorsReport): Report - with information about validation errors and publish plugin - actions. - """ - - self.clear() - if not validation_error_report: - return - - self._top_label.setVisible(True) - self._error_details_frame.setVisible(True) - self._errors_widget.setVisible(True) - - grouped_error_items = validation_error_report.group_items_by_title() - for idx, error_info in enumerate(grouped_error_items): - widget = ValidationErrorTitleWidget(idx, error_info, self) - widget.selected.connect(self._on_select) - widget.instance_changed.connect(self._on_instance_change) - self._errors_layout.addWidget(widget) - self._title_widgets[idx] = widget - self._error_info[idx] = error_info - - self._errors_layout.addStretch(1) - - if self._title_widgets: - self._title_widgets[0].set_selected(True) - - self.updateGeometry() - - def _set_current_widget(self, widget): - self._main_layout.setCurrentWidget(widget) - - def _on_publish_start(self): - self._set_current_widget(self._publish_started_widget) - - def _on_publish_reset(self): - self._set_current_widget(self._before_publish_widget) - - def _on_publish_stop(self): - if self._controller.publish_has_crashed: - self._set_current_widget(self._publish_stop_fail_widget) - return - - if self._controller.publish_has_validation_errors: - validation_errors = self._controller.get_validation_errors() - self._set_current_widget(self._validations_widget) - self._set_errors(validation_errors) - return - - if self._controller.publish_has_finished: - self._set_current_widget(self._publish_stop_ok_widget) - return - - self._set_current_widget(self._publish_started_widget) - - def _on_select(self, index): - if self._previous_select: - if self._previous_select.index == index: - return - self._previous_select.set_selected(False) - - self._previous_select = self._title_widgets[index] - - error_item = self._error_info[index] - - self._actions_widget.set_error_item(error_item) - - self._update_description() - - def _on_instance_change(self, index): - if self._previous_select and self._previous_select.index != index: - self._title_widgets[index].set_selected(True) - else: - self._update_description() - - def _update_description(self): - description = self._previous_select.current_description_text() - if commonmark: - html = commonmark.commonmark(description) - self._error_details_input.setHtml(html) - elif hasattr(self._error_details_input, "setMarkdown"): - self._error_details_input.setMarkdown(description) - else: - self._error_details_input.setText(description) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index cd1f1f5a96..0b13f26d57 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -40,6 +40,41 @@ from ..constants import ( INPUTS_LAYOUT_VSPACING, ) +FA_PREFIXES = ["", "fa.", "fa5.", "fa5b.", "fa5s.", "ei.", "mdi."] + + +def parse_icon_def( + icon_def, default_width=None, default_height=None, color=None +): + if not icon_def: + return None + + if isinstance(icon_def, QtGui.QPixmap): + return icon_def + + color = color or "white" + default_width = default_width or 512 + default_height = default_height or 512 + + if isinstance(icon_def, QtGui.QIcon): + return icon_def.pixmap(default_width, default_height) + + try: + if os.path.exists(icon_def): + return QtGui.QPixmap(icon_def) + except Exception: + # TODO logging + pass + + for prefix in FA_PREFIXES: + try: + icon_name = "{}{}".format(prefix, icon_def) + icon = qtawesome.icon(icon_name, color=color) + return icon.pixmap(default_width, default_height) + except Exception: + # TODO logging + continue + class PublishPixmapLabel(PixmapLabel): def _get_pix_size(self): @@ -54,7 +89,6 @@ class IconValuePixmapLabel(PublishPixmapLabel): Handle icon parsing from creators/instances. Using of QAwesome module of path to images. """ - fa_prefixes = ["", "fa."] default_size = 200 def __init__(self, icon_def, parent): @@ -77,31 +111,9 @@ class IconValuePixmapLabel(PublishPixmapLabel): return pix def _parse_icon_def(self, icon_def): - if not icon_def: - return self._default_pixmap() - - if isinstance(icon_def, QtGui.QPixmap): - return icon_def - - if isinstance(icon_def, QtGui.QIcon): - return icon_def.pixmap(self.default_size, self.default_size) - - try: - if os.path.exists(icon_def): - return QtGui.QPixmap(icon_def) - except Exception: - # TODO logging - pass - - for prefix in self.fa_prefixes: - try: - icon_name = "{}{}".format(prefix, icon_def) - icon = qtawesome.icon(icon_name, color="white") - return icon.pixmap(self.default_size, self.default_size) - except Exception: - # TODO logging - continue - + icon = parse_icon_def(icon_def, self.default_size, self.default_size) + if icon: + return icon return self._default_pixmap() @@ -692,6 +704,7 @@ class TasksCombobox(QtWidgets.QComboBox): style.drawControl( QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self ) + painter.end() def is_valid(self): """Are all selected items valid.""" diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index b3471163ae..fc90e66f21 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -1,3 +1,6 @@ +import os +import json +import time import collections import copy from qtpy import QtWidgets, QtCore, QtGui @@ -15,10 +18,11 @@ from openpype.tools.utils import ( from .constants import ResetKeySequence from .publish_report_viewer import PublishReportViewerWidget +from .control import CardMessageTypes from .control_qt import QtPublisherController from .widgets import ( OverviewWidget, - ValidationsWidget, + ReportPageWidget, PublishFrame, PublisherTabsWidget, @@ -182,7 +186,7 @@ class PublisherWindow(QtWidgets.QDialog): controller, content_stacked_widget ) - report_widget = ValidationsWidget(controller, parent) + report_widget = ReportPageWidget(controller, parent) # Details - Publish details publish_details_widget = PublishReportViewerWidget( @@ -313,6 +317,13 @@ class PublisherWindow(QtWidgets.QDialog): controller.event_system.add_callback( "convertors.find.failed", self._on_convertor_error ) + controller.event_system.add_callback( + "export_report.request", self._export_report + ) + controller.event_system.add_callback( + "copy_report.request", self._copy_report + ) + # Store extra header widget for TrayPublisher # - can be used to add additional widgets to header between context @@ -825,6 +836,9 @@ class PublisherWindow(QtWidgets.QDialog): self._validate_btn.setEnabled(validate_enabled) self._publish_btn.setEnabled(publish_enabled) + if not publish_enabled: + self._publish_frame.set_shrunk_state(True) + self._update_publish_details_widget() def _validate_create_instances(self): @@ -941,6 +955,46 @@ class PublisherWindow(QtWidgets.QDialog): under_mouse = widget_x < global_pos.x() self._create_overlay_button.set_under_mouse(under_mouse) + def _copy_report(self): + logs = self._controller.get_publish_report() + logs_string = json.dumps(logs, indent=4) + + mime_data = QtCore.QMimeData() + mime_data.setText(logs_string) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + self._controller.emit_card_message( + "Report added to clipboard", + CardMessageTypes.info) + + def _export_report(self): + default_filename = "publish-report-{}".format( + time.strftime("%y%m%d-%H-%M") + ) + default_filepath = os.path.join( + os.path.expanduser("~"), + default_filename + ) + new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName( + self, "Save report", default_filepath, ".json" + ) + if not ext or not new_filepath: + return + + logs = self._controller.get_publish_report() + full_path = new_filepath + ext + dir_path = os.path.dirname(full_path) + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + with open(full_path, "w") as file_stream: + json.dump(logs, file_stream) + + self._controller.emit_card_message( + "Report saved", + CardMessageTypes.info) + class ErrorsMessageBox(ErrorMessageBox): def __init__(self, error_title, failed_info, message_start, parent): diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 4149763f80..10bd527692 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -1,13 +1,16 @@ +from .layouts import FlowLayout from .widgets import ( FocusSpinBox, FocusDoubleSpinBox, ComboBox, CustomTextComboBox, PlaceholderLineEdit, + ExpandingTextEdit, BaseClickableFrame, ClickableFrame, ClickableLabel, ExpandBtn, + ClassicExpandBtn, PixmapLabel, IconButton, PixmapButton, @@ -37,15 +40,19 @@ from .overlay_messages import ( __all__ = ( + "FlowLayout", + "FocusSpinBox", "FocusDoubleSpinBox", "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "ExpandingTextEdit", "BaseClickableFrame", "ClickableFrame", "ClickableLabel", "ExpandBtn", + "ClassicExpandBtn", "PixmapLabel", "IconButton", "PixmapButton", diff --git a/openpype/tools/utils/layouts.py b/openpype/tools/utils/layouts.py new file mode 100644 index 0000000000..65ea087c27 --- /dev/null +++ b/openpype/tools/utils/layouts.py @@ -0,0 +1,150 @@ +from qtpy import QtWidgets, QtCore + + +class FlowLayout(QtWidgets.QLayout): + """Layout that organize widgets by minimum size into a flow layout. + + Layout is putting widget from left to right and top to bottom. When widget + can't fit a row it is added to next line. Minimum size matches widget with + biggest 'sizeHint' width and height using calculated geometry. + + Content margins are part of calculations. It is possible to define + horizontal and vertical spacing. + + Layout does not support stretch and spacing items. + + Todos: + Unified width concept -> use width of largest item so all of them are + same. This could allow to have minimum columns option too. + """ + + def __init__(self, parent=None): + super(FlowLayout, self).__init__(parent) + + # spaces between each item + self._horizontal_spacing = 5 + self._vertical_spacing = 5 + + self._items = [] + + def __del__(self): + while self.count(): + self.takeAt(0, False) + + def isEmpty(self): + for item in self._items: + if not item.isEmpty(): + return False + return True + + def setSpacing(self, spacing): + self._horizontal_spacing = spacing + self._vertical_spacing = spacing + self.invalidate() + + def setHorizontalSpacing(self, spacing): + self._horizontal_spacing = spacing + self.invalidate() + + def setVerticalSpacing(self, spacing): + self._vertical_spacing = spacing + self.invalidate() + + def addItem(self, item): + self._items.append(item) + self.invalidate() + + def count(self): + return len(self._items) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index, invalidate=True): + if 0 <= index < len(self._items): + item = self._items.pop(index) + if invalidate: + self.invalidate() + return item + return None + + def expandingDirections(self): + return QtCore.Qt.Orientations(QtCore.Qt.Vertical) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._setup_geometry(QtCore.QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self._setup_geometry(rect) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QtCore.QSize(0, 0) + for item in self._items: + widget = item.widget() + if widget is not None: + parent = widget.parent() + if not widget.isVisibleTo(parent): + continue + size = size.expandedTo(item.minimumSize()) + + if size.width() < 1 or size.height() < 1: + return size + l_margin, t_margin, r_margin, b_margin = self.getContentsMargins() + size += QtCore.QSize(l_margin + r_margin, t_margin + b_margin) + return size + + def _setup_geometry(self, rect, only_calculate=False): + h_spacing = self._horizontal_spacing + v_spacing = self._vertical_spacing + l_margin, t_margin, r_margin, b_margin = self.getContentsMargins() + + left_x = rect.x() + l_margin + top_y = rect.y() + t_margin + pos_x = left_x + pos_y = top_y + row_height = 0 + for item in self._items: + item_hint = item.sizeHint() + item_width = item_hint.width() + item_height = item_hint.height() + if item_width < 1 or item_height < 1: + continue + + end_x = pos_x + item_width + + wrap = ( + row_height > 0 + and ( + end_x > rect.right() + or (end_x + r_margin) > rect.right() + ) + ) + if not wrap: + next_pos_x = end_x + h_spacing + else: + pos_x = left_x + pos_y += row_height + v_spacing + next_pos_x = pos_x + item_width + h_spacing + row_height = 0 + + if not only_calculate: + item.setGeometry( + QtCore.QRect(pos_x, pos_y, item_width, item_height) + ) + + pos_x = next_pos_x + row_height = max(row_height, item_height) + + height = (pos_y - top_y) + row_height + if height > 0: + height += b_margin + return height diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index bae89aeb09..5a8104611b 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -101,6 +101,46 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class ExpandingTextEdit(QtWidgets.QTextEdit): + """QTextEdit which does not have sroll area but expands height.""" + + def __init__(self, parent=None): + super(ExpandingTextEdit, self).__init__(parent) + + size_policy = self.sizePolicy() + size_policy.setHeightForWidth(True) + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Preferred) + self.setSizePolicy(size_policy) + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + doc = self.document() + doc.contentsChanged.connect(self._on_doc_change) + + def _on_doc_change(self): + self.updateGeometry() + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + margins = self.contentsMargins() + + document_width = 0 + if width >= margins.left() + margins.right(): + document_width = width - margins.left() - margins.right() + + document = self.document().clone() + document.setTextWidth(document_width) + + return margins.top() + document.size().height() + margins.bottom() + + def sizeHint(self): + width = super(ExpandingTextEdit, self).sizeHint().width() + return QtCore.QSize(width, self.heightForWidth(width)) + + class BaseClickableFrame(QtWidgets.QFrame): """Widget that catch left mouse click and can trigger a callback. @@ -161,19 +201,34 @@ class ClickableLabel(QtWidgets.QLabel): class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" + state_changed = QtCore.Signal() + + def __init__(self, parent): super(ExpandBtnLabel, self).__init__(parent) - self._source_collapsed_pix = QtGui.QPixmap( - get_style_image_path("branch_closed") - ) - self._source_expanded_pix = QtGui.QPixmap( - get_style_image_path("branch_open") - ) + self._source_collapsed_pix = self._create_collapsed_pixmap() + self._source_expanded_pix = self._create_expanded_pixmap() self._current_image = self._source_collapsed_pix self._collapsed = True - def set_collapsed(self, collapsed): + def _create_collapsed_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("branch_closed") + ) + + def _create_expanded_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("branch_open") + ) + + @property + def collapsed(self): + return self._collapsed + + def set_collapsed(self, collapsed=None): + if collapsed is None: + collapsed = not self._collapsed if self._collapsed == collapsed: return self._collapsed = collapsed @@ -182,6 +237,7 @@ class ExpandBtnLabel(QtWidgets.QLabel): else: self._current_image = self._source_expanded_pix self._set_resized_pix() + self.state_changed.emit() def resizeEvent(self, event): self._set_resized_pix() @@ -203,21 +259,55 @@ class ExpandBtnLabel(QtWidgets.QLabel): class ExpandBtn(ClickableFrame): + state_changed = QtCore.Signal() + def __init__(self, parent=None): super(ExpandBtn, self).__init__(parent) - pixmap_label = ExpandBtnLabel(self) + pixmap_label = self._create_pix_widget(self) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(pixmap_label) + pixmap_label.state_changed.connect(self.state_changed) + self._pixmap_label = pixmap_label - def set_collapsed(self, collapsed): + def _create_pix_widget(self, parent=None): + if parent is None: + parent = self + return ExpandBtnLabel(parent) + + @property + def collapsed(self): + return self._pixmap_label.collapsed + + def set_collapsed(self, collapsed=None): self._pixmap_label.set_collapsed(collapsed) +class ClassicExpandBtnLabel(ExpandBtnLabel): + def _create_collapsed_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("right_arrow") + ) + + def _create_expanded_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("down_arrow") + ) + + +class ClassicExpandBtn(ExpandBtn): + """Same as 'ExpandBtn' but with arrow images.""" + + def _create_pix_widget(self, parent=None): + if parent is None: + parent = self + return ClassicExpandBtnLabel(parent) + + class ImageButton(QtWidgets.QPushButton): """PushButton with icon and size of font. From 179dc65f501faa6e71d5e783ef0d01f0d1ac09aa Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 24 May 2023 03:25:50 +0000 Subject: [PATCH 14/24] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index 8874eb510d..3d7f64b991 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.8-nightly.2" +__version__ = "3.15.8-nightly.3" From ea2d87d903ed95200e1ceb121440e690b65907e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 May 2023 03:26:39 +0000 Subject: [PATCH 15/24] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 244eb1a363..a9f1f1cc02 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.15.8-nightly.3 - 3.15.8-nightly.2 - 3.15.8-nightly.1 - 3.15.7 @@ -134,7 +135,6 @@ body: - 3.14.2-nightly.3 - 3.14.2-nightly.2 - 3.14.2-nightly.1 - - 3.14.1 validations: required: true - type: dropdown From fe02a093128b17a02d0c2e87405c8beca7bc23e4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 24 May 2023 10:01:59 +0200 Subject: [PATCH 16/24] Deadline: fix selection from multiple webservices (#5015) * OP-4380 - override default DL from project settings * OP-4380 - updated documentation --- .../collect_default_deadline_server.py | 26 ++++++++++++++++++- website/docs/module_deadline.md | 3 +++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py index e6ad6a9aa1..cb2b0cf156 100644 --- a/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py +++ b/openpype/modules/deadline/plugins/publish/collect_default_deadline_server.py @@ -4,7 +4,18 @@ import pyblish.api class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): - """Collect default Deadline Webservice URL.""" + """Collect default Deadline Webservice URL. + + DL webservice addresses must be configured first in System Settings for + project settings enum to work. + + Default webservice could be overriden by + `project_settings/deadline/deadline_servers`. Currently only single url + is expected. + + This url could be overriden by some hosts directly on instances with + `CollectDeadlineServerFromInstance`. + """ order = pyblish.api.CollectorOrder + 0.410 label = "Default Deadline Webservice" @@ -23,3 +34,16 @@ class CollectDefaultDeadlineServer(pyblish.api.ContextPlugin): context.data["defaultDeadline"] = deadline_module.deadline_urls["default"] # noqa: E501 context.data["deadlinePassMongoUrl"] = self.pass_mongo_url + + deadline_servers = (context.data + ["project_settings"] + ["deadline"] + ["deadline_servers"]) + if deadline_servers: + deadline_server_name = deadline_servers[0] + deadline_webservice = deadline_module.deadline_urls.get( + deadline_server_name) + if deadline_webservice: + context.data["defaultDeadline"] = deadline_webservice + self.log.debug("Overriding from project settings with {}".format( # noqa: E501 + deadline_webservice)) diff --git a/website/docs/module_deadline.md b/website/docs/module_deadline.md index 94b6a381c2..bca2a83936 100644 --- a/website/docs/module_deadline.md +++ b/website/docs/module_deadline.md @@ -22,6 +22,9 @@ For [AWS Thinkbox Deadline](https://www.awsthinkbox.com/deadline) support you ne 5. Install our custom plugin and scripts to your deadline repository. It should be as simple as copying content of `openpype/modules/deadline/repository/custom` to `path/to/your/deadline/repository/custom`. +Multiple different DL webservice could be configured. First set them in point 4., then they could be configured per project in `project_settings/deadline/deadline_servers`. +Only single webservice could be a target of publish though. + ## Configuration From 248336bb0ddf2a61632e579600021b093c24440f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Wed, 24 May 2023 10:51:35 +0200 Subject: [PATCH 17/24] General: Lib code cleanup (#5003) * implemented 'is_func_signature_supported' function * 'WeakMethod' can be imported from 'python_2_comp' all the time * simplified events logic for callback registration * modified docstrings in publish lib * removed unused imports * fixed 'run_openpype_process' docstring --- openpype/lib/__init__.py | 6 ++- openpype/lib/events.py | 43 +++--------------- openpype/lib/execute.py | 2 +- openpype/lib/python_2_comp.py | 65 +++++++++++++++------------- openpype/lib/python_module_tools.py | 67 +++++++++++++++++++++++++++++ openpype/pipeline/publish/lib.py | 34 ++++++++------- 6 files changed, 129 insertions(+), 88 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 9eb7724a60..06de486f2e 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # flake8: noqa E402 -"""Pype module API.""" +"""OpenPype lib functions.""" # add vendor to sys path based on Python version import sys import os @@ -94,7 +94,8 @@ from .python_module_tools import ( modules_from_path, recursive_bases_from_class, classes_from_module, - import_module_from_dirpath + import_module_from_dirpath, + is_func_signature_supported, ) from .profiles_filtering import ( @@ -243,6 +244,7 @@ __all__ = [ "recursive_bases_from_class", "classes_from_module", "import_module_from_dirpath", + "is_func_signature_supported", "get_transcode_temp_directory", "should_convert_for_ffmpeg", diff --git a/openpype/lib/events.py b/openpype/lib/events.py index bed00fe659..dca58fcf93 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -6,10 +6,9 @@ import inspect import logging import weakref from uuid import uuid4 -try: - from weakref import WeakMethod -except Exception: - from openpype.lib.python_2_comp import WeakMethod + +from .python_2_comp import WeakMethod +from .python_module_tools import is_func_signature_supported class MissingEventSystem(Exception): @@ -80,40 +79,8 @@ class EventCallback(object): # Get expected arguments from function spec # - positional arguments are always preferred - expect_args = False - expect_kwargs = False - fake_event = "fake" - if hasattr(inspect, "signature"): - # Python 3 using 'Signature' object where we try to bind arg - # or kwarg. Using signature is recommended approach based on - # documentation. - sig = inspect.signature(func) - try: - sig.bind(fake_event) - expect_args = True - except TypeError: - pass - - try: - sig.bind(event=fake_event) - expect_kwargs = True - except TypeError: - pass - - else: - # In Python 2 'signature' is not available so 'getcallargs' is used - # - 'getcallargs' is marked as deprecated since Python 3.0 - try: - inspect.getcallargs(func, fake_event) - expect_args = True - except TypeError: - pass - - try: - inspect.getcallargs(func, event=fake_event) - expect_kwargs = True - except TypeError: - pass + expect_args = is_func_signature_supported(func, "fake") + expect_kwargs = is_func_signature_supported(func, event="fake") self._func_ref = func_ref self._func_name = func_name diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index ef456395e7..6f52efdfcc 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -190,7 +190,7 @@ def run_openpype_process(*args, **kwargs): Example: ``` - run_openpype_process("run", "") + run_detached_process("run", "") ``` Args: diff --git a/openpype/lib/python_2_comp.py b/openpype/lib/python_2_comp.py index d7137dbe9c..091c51a6f6 100644 --- a/openpype/lib/python_2_comp.py +++ b/openpype/lib/python_2_comp.py @@ -1,41 +1,44 @@ import weakref -class _weak_callable: - def __init__(self, obj, func): - self.im_self = obj - self.im_func = func +WeakMethod = getattr(weakref, "WeakMethod", None) - def __call__(self, *args, **kws): - if self.im_self is None: - return self.im_func(*args, **kws) - else: - return self.im_func(self.im_self, *args, **kws) +if WeakMethod is None: + class _WeakCallable: + def __init__(self, obj, func): + self.im_self = obj + self.im_func = func + + def __call__(self, *args, **kws): + if self.im_self is None: + return self.im_func(*args, **kws) + else: + return self.im_func(self.im_self, *args, **kws) -class WeakMethod: - """ Wraps a function or, more importantly, a bound method in - a way that allows a bound method's object to be GCed, while - providing the same interface as a normal weak reference. """ + class WeakMethod: + """ Wraps a function or, more importantly, a bound method in + a way that allows a bound method's object to be GCed, while + providing the same interface as a normal weak reference. """ - def __init__(self, fn): - try: - self._obj = weakref.ref(fn.im_self) - self._meth = fn.im_func - except AttributeError: - # It's not a bound method - self._obj = None - self._meth = fn + def __init__(self, fn): + try: + self._obj = weakref.ref(fn.im_self) + self._meth = fn.im_func + except AttributeError: + # It's not a bound method + self._obj = None + self._meth = fn - def __call__(self): - if self._dead(): - return None - return _weak_callable(self._getobj(), self._meth) + def __call__(self): + if self._dead(): + return None + return _WeakCallable(self._getobj(), self._meth) - def _dead(self): - return self._obj is not None and self._obj() is None + def _dead(self): + return self._obj is not None and self._obj() is None - def _getobj(self): - if self._obj is None: - return None - return self._obj() + def _getobj(self): + if self._obj is None: + return None + return self._obj() diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index 9e8e94842c..a10263f991 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -230,3 +230,70 @@ def import_module_from_dirpath(dirpath, folder_name, dst_module_name=None): dirpath, folder_name, dst_module_name ) return module + + +def is_func_signature_supported(func, *args, **kwargs): + """Check if a function signature supports passed args and kwargs. + + This check does not actually call the function, just look if function can + be called with the arguments. + + Notes: + This does NOT check if the function would work with passed arguments + only if they can be passed in. If function have *args, **kwargs + in paramaters, this will always return 'True'. + + Example: + >>> def my_function(my_number): + ... return my_number + 1 + ... + >>> is_func_signature_supported(my_function, 1) + True + >>> is_func_signature_supported(my_function, 1, 2) + False + >>> is_func_signature_supported(my_function, my_number=1) + True + >>> is_func_signature_supported(my_function, number=1) + False + >>> is_func_signature_supported(my_function, "string") + True + >>> def my_other_function(*args, **kwargs): + ... my_function(*args, **kwargs) + ... + >>> is_func_signature_supported( + ... my_other_function, + ... "string", + ... 1, + ... other=None + ... ) + True + + Args: + func (function): A function where the signature should be tested. + *args (tuple[Any]): Positional arguments for function signature. + **kwargs (dict[str, Any]): Keyword arguments for function signature. + + Returns: + bool: Function can pass in arguments. + """ + + if hasattr(inspect, "signature"): + # Python 3 using 'Signature' object where we try to bind arg + # or kwarg. Using signature is recommended approach based on + # documentation. + sig = inspect.signature(func) + try: + sig.bind(*args, **kwargs) + return True + except TypeError: + pass + + else: + # In Python 2 'signature' is not available so 'getcallargs' is used + # - 'getcallargs' is marked as deprecated since Python 3.0 + try: + inspect.getcallargs(func, *args, **kwargs) + return True + except TypeError: + pass + return False diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 40186238aa..63a856e326 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -1,12 +1,10 @@ import os import sys -import types import inspect import copy import tempfile import xml.etree.ElementTree -import six import pyblish.util import pyblish.plugin import pyblish.api @@ -42,7 +40,9 @@ def get_template_name_profiles( Args: project_name (str): Name of project where to look for templates. - project_settings(Dic[str, Any]): Prepared project settings. + project_settings (Dict[str, Any]): Prepared project settings. + logger (Optional[logging.Logger]): Logger object to be used instead + of default logger. Returns: List[Dict[str, Any]]: Publish template profiles. @@ -103,7 +103,9 @@ def get_hero_template_name_profiles( Args: project_name (str): Name of project where to look for templates. - project_settings(Dic[str, Any]): Prepared project settings. + project_settings (Dict[str, Any]): Prepared project settings. + logger (Optional[logging.Logger]): Logger object to be used instead + of default logger. Returns: List[Dict[str, Any]]: Publish template profiles. @@ -172,9 +174,10 @@ def get_publish_template_name( project_name (str): Name of project where to look for settings. host_name (str): Name of host integration. family (str): Family for which should be found template. - task_name (str): Task name on which is intance working. - task_type (str): Task type on which is intance working. - project_setting (Dict[str, Any]): Prepared project settings. + task_name (str): Task name on which is instance working. + task_type (str): Task type on which is instance working. + project_settings (Dict[str, Any]): Prepared project settings. + hero (bool): Template is for hero version publishing. logger (logging.Logger): Custom logger used for 'filter_profiles' function. @@ -264,19 +267,18 @@ def load_help_content_from_plugin(plugin): def publish_plugins_discover(paths=None): """Find and return available pyblish plug-ins - Overridden function from `pyblish` module to be able collect crashed files - and reason of their crash. + Overridden function from `pyblish` module to be able to collect + crashed files and reason of their crash. Arguments: paths (list, optional): Paths to discover plug-ins from. If no paths are provided, all paths are searched. - """ # The only difference with `pyblish.api.discover` result = DiscoverResult(pyblish.api.Plugin) - plugins = dict() + plugins = {} plugin_names = [] allow_duplicates = pyblish.plugin.ALLOW_DUPLICATES @@ -302,7 +304,7 @@ def publish_plugins_discover(paths=None): mod_name, mod_ext = os.path.splitext(fname) - if not mod_ext == ".py": + if mod_ext != ".py": continue try: @@ -533,10 +535,10 @@ def find_close_plugin(close_plugin_name, log): def remote_publish(log, close_plugin_name=None, raise_error=False): """Loops through all plugins, logs to console. Used for tests. - Args: - log (openpype.lib.Logger) - close_plugin_name (str): name of plugin with responsibility to - close host app + Args: + log (Logger) + close_plugin_name (str): name of plugin with responsibility to + close host app """ # Error exit as soon as any error occurs. error_format = "Failed {plugin.__name__}: {error} -- {error.traceback}" From 17a38c32a4ba6e33798a097418c1f91c732d1fb8 Mon Sep 17 00:00:00 2001 From: Roy Nieterau Date: Wed, 24 May 2023 10:54:31 +0200 Subject: [PATCH 18/24] Enhancement: Improve logging levels and messages for artist facing publish reports (#5018) * Tweak log levels and message to be more informative to artist in report page * Tweak levels and clarity of logs * Tweak levels and clarity of logs + tweak grammar * Cosmetics * Improve logging * Simplify logging * Convert to debug log if it's skipping thumbnail integration if there's no thumbnail whatsoever to integrate * Tweak to debug since they only show representation ids hardly understandable to the artist * Match logging message across hosts + include filepath for full clarity * Tweak message to clarify it only starts checking and not that it requires filling + to debug log * Tweak to debug log if there's basically no thumbnail to integrate at the end * Tweak log levels - Artist doesn't care what's prepared, especially since afterwards it's logged what gets written to the database anyway * Log clearly it's processing a legacy instance * Cosmetics --- .../fusion/plugins/publish/collect_inputs.py | 2 +- .../fusion/plugins/publish/save_scene.py | 2 +- .../houdini/plugins/publish/collect_frames.py | 7 ++-- .../houdini/plugins/publish/collect_inputs.py | 2 +- .../plugins/publish/collect_instances.py | 4 +- .../plugins/publish/collect_workfile.py | 3 +- .../houdini/plugins/publish/save_scene.py | 2 +- .../publish/validate_workfile_paths.py | 41 ++++++++++++++----- .../maya/plugins/publish/collect_inputs.py | 2 +- .../hosts/maya/plugins/publish/save_scene.py | 2 +- .../plugins/publish/save_workfile.py | 5 ++- .../plugins/publish/submit_fusion_deadline.py | 2 +- .../plugins/publish/submit_nuke_deadline.py | 2 +- .../plugins/publish/submit_publish_job.py | 2 +- .../publish/validate_deadline_pools.py | 2 +- openpype/pipeline/publish/publish_plugins.py | 13 +++--- openpype/plugins/publish/cleanup.py | 9 ++-- .../publish/collect_anatomy_context_data.py | 5 ++- .../publish/collect_anatomy_instance_data.py | 8 ++-- .../plugins/publish/collect_anatomy_object.py | 2 +- .../publish/collect_custom_staging_dir.py | 2 +- .../publish/collect_from_create_context.py | 4 +- .../plugins/publish/collect_scene_version.py | 5 ++- openpype/plugins/publish/extract_burnin.py | 4 +- .../publish/extract_color_transcode.py | 6 +-- openpype/plugins/publish/extract_review.py | 6 +-- openpype/plugins/publish/extract_thumbnail.py | 14 +++---- .../publish/extract_thumbnail_from_source.py | 4 +- openpype/plugins/publish/integrate.py | 8 ++-- openpype/plugins/publish/integrate_legacy.py | 6 ++- .../plugins/publish/integrate_thumbnail.py | 4 +- 31 files changed, 106 insertions(+), 74 deletions(-) diff --git a/openpype/hosts/fusion/plugins/publish/collect_inputs.py b/openpype/hosts/fusion/plugins/publish/collect_inputs.py index 1bb3cd1220..a6628300db 100644 --- a/openpype/hosts/fusion/plugins/publish/collect_inputs.py +++ b/openpype/hosts/fusion/plugins/publish/collect_inputs.py @@ -113,4 +113,4 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin): inputs = [c["representation"] for c in containers] instance.data["inputRepresentations"] = inputs - self.log.info("Collected inputs: %s" % inputs) + self.log.debug("Collected inputs: %s" % inputs) diff --git a/openpype/hosts/fusion/plugins/publish/save_scene.py b/openpype/hosts/fusion/plugins/publish/save_scene.py index a249c453d8..0798e7c8b7 100644 --- a/openpype/hosts/fusion/plugins/publish/save_scene.py +++ b/openpype/hosts/fusion/plugins/publish/save_scene.py @@ -17,5 +17,5 @@ class FusionSaveComp(pyblish.api.ContextPlugin): current = comp.GetAttrs().get("COMPS_FileName", "") assert context.data['currentFile'] == current - self.log.info("Saving current file..") + self.log.info("Saving current file: {}".format(current)) comp.Save() diff --git a/openpype/hosts/houdini/plugins/publish/collect_frames.py b/openpype/hosts/houdini/plugins/publish/collect_frames.py index 6c695f64e9..059793e3c5 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_frames.py +++ b/openpype/hosts/houdini/plugins/publish/collect_frames.py @@ -8,7 +8,6 @@ import pyblish.api from openpype.hosts.houdini.api import lib - class CollectFrames(pyblish.api.InstancePlugin): """Collect all frames which would be saved from the ROP nodes""" @@ -34,8 +33,10 @@ class CollectFrames(pyblish.api.InstancePlugin): self.log.warning("Using current frame: {}".format(hou.frame())) output = output_parm.eval() - _, ext = lib.splitext(output, - allowed_multidot_extensions=[".ass.gz"]) + _, ext = lib.splitext( + output, + allowed_multidot_extensions=[".ass.gz"] + ) file_name = os.path.basename(output) result = file_name diff --git a/openpype/hosts/houdini/plugins/publish/collect_inputs.py b/openpype/hosts/houdini/plugins/publish/collect_inputs.py index 6411376ea3..e92a42f2e8 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_inputs.py +++ b/openpype/hosts/houdini/plugins/publish/collect_inputs.py @@ -117,4 +117,4 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin): inputs = [c["representation"] for c in containers] instance.data["inputRepresentations"] = inputs - self.log.info("Collected inputs: %s" % inputs) + self.log.debug("Collected inputs: %s" % inputs) diff --git a/openpype/hosts/houdini/plugins/publish/collect_instances.py b/openpype/hosts/houdini/plugins/publish/collect_instances.py index bb85630552..5d5347f96e 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_instances.py +++ b/openpype/hosts/houdini/plugins/publish/collect_instances.py @@ -55,7 +55,9 @@ class CollectInstances(pyblish.api.ContextPlugin): has_family = node.evalParm("family") assert has_family, "'%s' is missing 'family'" % node.name() - self.log.info("processing {}".format(node)) + self.log.info( + "Processing legacy instance node {}".format(node.path()) + ) data = lib.read(node) # Check bypass state and reverse diff --git a/openpype/hosts/houdini/plugins/publish/collect_workfile.py b/openpype/hosts/houdini/plugins/publish/collect_workfile.py index a6e94ec29e..aa533bcf1b 100644 --- a/openpype/hosts/houdini/plugins/publish/collect_workfile.py +++ b/openpype/hosts/houdini/plugins/publish/collect_workfile.py @@ -32,5 +32,4 @@ class CollectWorkfile(pyblish.api.InstancePlugin): "stagingDir": folder, }] - self.log.info('Collected instance: {}'.format(file)) - self.log.info('staging Dir: {}'.format(folder)) + self.log.debug('Collected workfile instance: {}'.format(file)) diff --git a/openpype/hosts/houdini/plugins/publish/save_scene.py b/openpype/hosts/houdini/plugins/publish/save_scene.py index d6e07ccab0..703d3e4895 100644 --- a/openpype/hosts/houdini/plugins/publish/save_scene.py +++ b/openpype/hosts/houdini/plugins/publish/save_scene.py @@ -20,7 +20,7 @@ class SaveCurrentScene(pyblish.api.ContextPlugin): ) if host.has_unsaved_changes(): - self.log.info("Saving current file {}...".format(current_file)) + self.log.info("Saving current file: {}".format(current_file)) host.save_workfile(current_file) else: self.log.debug("No unsaved changes, skipping file save..") diff --git a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py index 7707cc2dba..543c8e1407 100644 --- a/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py +++ b/openpype/hosts/houdini/plugins/publish/validate_workfile_paths.py @@ -28,18 +28,37 @@ class ValidateWorkfilePaths( if not self.is_active(instance.data): return invalid = self.get_invalid() - self.log.info( - "node types to check: {}".format(", ".join(self.node_types))) - self.log.info( - "prohibited vars: {}".format(", ".join(self.prohibited_vars)) + self.log.debug( + "Checking node types: {}".format(", ".join(self.node_types))) + self.log.debug( + "Searching prohibited vars: {}".format( + ", ".join(self.prohibited_vars) + ) ) - if invalid: - for param in invalid: - self.log.error( - "{}: {}".format(param.path(), param.unexpandedString())) - raise PublishValidationError( - "Invalid paths found", title=self.label) + if invalid: + all_container_vars = set() + for param in invalid: + value = param.unexpandedString() + contained_vars = [ + var for var in self.prohibited_vars + if var in value + ] + all_container_vars.update(contained_vars) + + self.log.error( + "Parm {} contains prohibited vars {}: {}".format( + param.path(), + ", ".join(contained_vars), + value) + ) + + message = ( + "Prohibited vars {} found in parameter values".format( + ", ".join(all_container_vars) + ) + ) + raise PublishValidationError(message, title=self.label) @classmethod def get_invalid(cls): @@ -63,7 +82,7 @@ class ValidateWorkfilePaths( def repair(cls, instance): invalid = cls.get_invalid() for param in invalid: - cls.log.info("processing: {}".format(param.path())) + cls.log.info("Processing: {}".format(param.path())) cls.log.info("Replacing {} for {}".format( param.unexpandedString(), hou.text.expandString(param.unexpandedString()))) diff --git a/openpype/hosts/maya/plugins/publish/collect_inputs.py b/openpype/hosts/maya/plugins/publish/collect_inputs.py index 9c3f0f5efa..895c92762b 100644 --- a/openpype/hosts/maya/plugins/publish/collect_inputs.py +++ b/openpype/hosts/maya/plugins/publish/collect_inputs.py @@ -166,7 +166,7 @@ class CollectUpstreamInputs(pyblish.api.InstancePlugin): inputs = [c["representation"] for c in containers] instance.data["inputRepresentations"] = inputs - self.log.info("Collected inputs: %s" % inputs) + self.log.debug("Collected inputs: %s" % inputs) def _collect_renderlayer_inputs(self, scene_containers, instance): """Collects inputs from nodes in renderlayer, incl. shaders + camera""" diff --git a/openpype/hosts/maya/plugins/publish/save_scene.py b/openpype/hosts/maya/plugins/publish/save_scene.py index 45e62e7b44..495c339731 100644 --- a/openpype/hosts/maya/plugins/publish/save_scene.py +++ b/openpype/hosts/maya/plugins/publish/save_scene.py @@ -31,5 +31,5 @@ class SaveCurrentScene(pyblish.api.ContextPlugin): # remove lockfile before saving if is_workfile_lock_enabled("maya", project_name, project_settings): remove_workfile_lock(current) - self.log.info("Saving current file..") + self.log.info("Saving current file: {}".format(current)) cmds.file(save=True, force=True) diff --git a/openpype/hosts/substancepainter/plugins/publish/save_workfile.py b/openpype/hosts/substancepainter/plugins/publish/save_workfile.py index 4874b5e5c7..9662f31922 100644 --- a/openpype/hosts/substancepainter/plugins/publish/save_workfile.py +++ b/openpype/hosts/substancepainter/plugins/publish/save_workfile.py @@ -16,11 +16,12 @@ class SaveCurrentWorkfile(pyblish.api.ContextPlugin): def process(self, context): host = registered_host() - if context.data["currentFile"] != host.get_current_workfile(): + current = host.get_current_workfile() + if context.data["currentFile"] != current: raise KnownPublishError("Workfile has changed during publishing!") if host.has_unsaved_changes(): - self.log.info("Saving current file..") + self.log.info("Saving current file: {}".format(current)) host.save_workfile() else: self.log.debug("Skipping workfile save because there are no " diff --git a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py index 717391100d..a48596c6bf 100644 --- a/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_fusion_deadline.py @@ -73,7 +73,7 @@ class FusionSubmitDeadline( def process(self, instance): if not instance.data.get("farm"): - self.log.info("Skipping local instance.") + self.log.debug("Skipping local instance.") return attribute_values = self.get_attr_values_from_data( diff --git a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py index 5c598df94b..4900231783 100644 --- a/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_nuke_deadline.py @@ -86,7 +86,7 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin, def process(self, instance): if not instance.data.get("farm"): - self.log.info("Skipping local instance.") + self.log.debug("Skipping local instance.") return instance.data["attributeValues"] = self.get_attr_values_from_data( diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index eeb813cb62..68eb0a437d 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -762,7 +762,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin): """ if not instance.data.get("farm"): - self.log.info("Skipping local instance.") + self.log.debug("Skipping local instance.") return data = instance.data.copy() diff --git a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py index 7c8ab62d4d..e1c0595830 100644 --- a/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py +++ b/openpype/modules/deadline/plugins/publish/validate_deadline_pools.py @@ -26,7 +26,7 @@ class ValidateDeadlinePools(OptionalPyblishPluginMixin, def process(self, instance): if not instance.data.get("farm"): - self.log.info("Skipping local instance.") + self.log.debug("Skipping local instance.") return # get default deadline webservice url from deadline module diff --git a/openpype/pipeline/publish/publish_plugins.py b/openpype/pipeline/publish/publish_plugins.py index a38896ec8e..a67c8397b1 100644 --- a/openpype/pipeline/publish/publish_plugins.py +++ b/openpype/pipeline/publish/publish_plugins.py @@ -379,7 +379,9 @@ class ColormanagedPyblishPluginMixin(object): # check if ext in lower case is in self.allowed_ext if ext.lstrip(".").lower() not in self.allowed_ext: - self.log.debug("Extension is not in allowed extensions.") + self.log.debug( + "Extension '{}' is not in allowed extensions.".format(ext) + ) return if colorspace_settings is None: @@ -393,8 +395,7 @@ class ColormanagedPyblishPluginMixin(object): self.log.warning("No colorspace management was defined") return - self.log.info("Config data is : `{}`".format( - config_data)) + self.log.debug("Config data is: `{}`".format(config_data)) project_name = context.data["projectName"] host_name = context.data["hostName"] @@ -405,8 +406,7 @@ class ColormanagedPyblishPluginMixin(object): if isinstance(filename, list): filename = filename[0] - self.log.debug("__ filename: `{}`".format( - filename)) + self.log.debug("__ filename: `{}`".format(filename)) # get matching colorspace from rules colorspace = colorspace or get_imageio_colorspace_from_filepath( @@ -415,8 +415,7 @@ class ColormanagedPyblishPluginMixin(object): file_rules=file_rules, project_settings=project_settings ) - self.log.debug("__ colorspace: `{}`".format( - colorspace)) + self.log.debug("__ colorspace: `{}`".format(colorspace)) # infuse data to representation if colorspace: diff --git a/openpype/plugins/publish/cleanup.py b/openpype/plugins/publish/cleanup.py index b90c88890d..57cc9c0ab5 100644 --- a/openpype/plugins/publish/cleanup.py +++ b/openpype/plugins/publish/cleanup.py @@ -81,7 +81,8 @@ class CleanUp(pyblish.api.InstancePlugin): staging_dir = instance.data.get("stagingDir", None) if not staging_dir: - self.log.info("Staging dir not set.") + self.log.debug("Skipping cleanup. Staging dir not set " + "on instance: {}.".format(instance)) return if not os.path.normpath(staging_dir).startswith(temp_root): @@ -90,7 +91,7 @@ class CleanUp(pyblish.api.InstancePlugin): return if not os.path.exists(staging_dir): - self.log.info("No staging directory found: %s" % staging_dir) + self.log.debug("No staging directory found at: %s" % staging_dir) return if instance.data.get("stagingDir_persistent"): @@ -131,7 +132,9 @@ class CleanUp(pyblish.api.InstancePlugin): try: os.remove(src) except PermissionError: - self.log.warning("Insufficient permission to delete {}".format(src)) + self.log.warning( + "Insufficient permission to delete {}".format(src) + ) continue # add dir for cleanup diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py index 55ce8e06f4..508b01447b 100644 --- a/openpype/plugins/publish/collect_anatomy_context_data.py +++ b/openpype/plugins/publish/collect_anatomy_context_data.py @@ -67,5 +67,6 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): # Store context.data["anatomyData"] = anatomy_data - self.log.info("Global anatomy Data collected") - self.log.debug(json.dumps(anatomy_data, indent=4)) + self.log.debug("Global Anatomy Context Data collected:\n{}".format( + json.dumps(anatomy_data, indent=4) + )) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 4fbb93324b..128ad90b4f 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -46,17 +46,17 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): follow_workfile_version = False def process(self, context): - self.log.info("Collecting anatomy data for all instances.") + self.log.debug("Collecting anatomy data for all instances.") project_name = context.data["projectName"] self.fill_missing_asset_docs(context, project_name) self.fill_latest_versions(context, project_name) self.fill_anatomy_data(context) - self.log.info("Anatomy Data collection finished.") + self.log.debug("Anatomy Data collection finished.") def fill_missing_asset_docs(self, context, project_name): - self.log.debug("Qeurying asset documents for instances.") + self.log.debug("Querying asset documents for instances.") context_asset_doc = context.data.get("assetEntity") @@ -271,7 +271,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): instance_name = instance.data["name"] instance_label = instance.data.get("label") if instance_label: - instance_name += "({})".format(instance_label) + instance_name += " ({})".format(instance_label) self.log.debug("Anatomy data for instance {}: {}".format( instance_name, json.dumps(anatomy_data, indent=4) diff --git a/openpype/plugins/publish/collect_anatomy_object.py b/openpype/plugins/publish/collect_anatomy_object.py index 725cae2b14..f792cf3abd 100644 --- a/openpype/plugins/publish/collect_anatomy_object.py +++ b/openpype/plugins/publish/collect_anatomy_object.py @@ -30,6 +30,6 @@ class CollectAnatomyObject(pyblish.api.ContextPlugin): context.data["anatomy"] = Anatomy(project_name) - self.log.info( + self.log.debug( "Anatomy object collected for project \"{}\".".format(project_name) ) diff --git a/openpype/plugins/publish/collect_custom_staging_dir.py b/openpype/plugins/publish/collect_custom_staging_dir.py index b749b251c0..669c4873e0 100644 --- a/openpype/plugins/publish/collect_custom_staging_dir.py +++ b/openpype/plugins/publish/collect_custom_staging_dir.py @@ -65,6 +65,6 @@ class CollectCustomStagingDir(pyblish.api.InstancePlugin): else: result_str = "Not adding" - self.log.info("{} custom staging dir for instance with '{}'".format( + self.log.debug("{} custom staging dir for instance with '{}'".format( result_str, family )) diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 5fcf8feb56..4888476fff 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -92,5 +92,5 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): instance.data["transientData"] = transient_data - self.log.info("collected instance: {}".format(instance.data)) - self.log.info("parsing data: {}".format(in_data)) + self.log.debug("collected instance: {}".format(instance.data)) + self.log.debug("parsing data: {}".format(in_data)) diff --git a/openpype/plugins/publish/collect_scene_version.py b/openpype/plugins/publish/collect_scene_version.py index fdbcb3cb9d..cd3231a07d 100644 --- a/openpype/plugins/publish/collect_scene_version.py +++ b/openpype/plugins/publish/collect_scene_version.py @@ -48,10 +48,13 @@ class CollectSceneVersion(pyblish.api.ContextPlugin): if '' in filename: return + self.log.debug( + "Collecting scene version from filename: {}".format(filename) + ) + version = get_version_from_path(filename) assert version, "Cannot determine version" rootVersion = int(version) context.data['version'] = rootVersion - self.log.info("{}".format(type(rootVersion))) self.log.info('Scene Version: %s' % context.data.get('version')) diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index a12e8d18b4..10b366dcd6 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -517,8 +517,8 @@ class ExtractBurnin(publish.Extractor): """ if "burnin" not in (repre.get("tags") or []): - self.log.info(( - "Representation \"{}\" don't have \"burnin\" tag. Skipped." + self.log.debug(( + "Representation \"{}\" does not have \"burnin\" tag. Skipped." ).format(repre["name"])) return False diff --git a/openpype/plugins/publish/extract_color_transcode.py b/openpype/plugins/publish/extract_color_transcode.py index 58e0350a2e..45b10620d1 100644 --- a/openpype/plugins/publish/extract_color_transcode.py +++ b/openpype/plugins/publish/extract_color_transcode.py @@ -336,13 +336,13 @@ class ExtractOIIOTranscode(publish.Extractor): if repre.get("ext") not in self.supported_exts: self.log.debug(( - "Representation '{}' of unsupported extension. Skipped." - ).format(repre["name"])) + "Representation '{}' has unsupported extension: '{}'. Skipped." + ).format(repre["name"], repre.get("ext"))) return False if not repre.get("files"): self.log.debug(( - "Representation '{}' have empty files. Skipped." + "Representation '{}' has empty files. Skipped." ).format(repre["name"])) return False diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index 1062683319..a68addda7d 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -92,8 +92,8 @@ class ExtractReview(pyblish.api.InstancePlugin): host_name = instance.context.data["hostName"] family = self.main_family_from_instance(instance) - self.log.info("Host: \"{}\"".format(host_name)) - self.log.info("Family: \"{}\"".format(family)) + self.log.debug("Host: \"{}\"".format(host_name)) + self.log.debug("Family: \"{}\"".format(family)) profile = filter_profiles( self.profiles, @@ -351,7 +351,7 @@ class ExtractReview(pyblish.api.InstancePlugin): temp_data = self.prepare_temp_data(instance, repre, output_def) files_to_clean = [] if temp_data["input_is_sequence"]: - self.log.info("Filling gaps in sequence.") + self.log.debug("Checking sequence to fill gaps in sequence..") files_to_clean = self.fill_sequence_gaps( files=temp_data["origin_repre"]["files"], staging_dir=new_repre["stagingDir"], diff --git a/openpype/plugins/publish/extract_thumbnail.py b/openpype/plugins/publish/extract_thumbnail.py index 54b933a76d..b98ab64f56 100644 --- a/openpype/plugins/publish/extract_thumbnail.py +++ b/openpype/plugins/publish/extract_thumbnail.py @@ -36,7 +36,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): ).format(subset_name)) return - self.log.info( + self.log.debug( "Processing instance with subset name {}".format(subset_name) ) @@ -89,13 +89,13 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): src_staging = os.path.normpath(repre["stagingDir"]) full_input_path = os.path.join(src_staging, input_file) - self.log.info("input {}".format(full_input_path)) + self.log.debug("input {}".format(full_input_path)) filename = os.path.splitext(input_file)[0] jpeg_file = filename + "_thumb.jpg" full_output_path = os.path.join(dst_staging, jpeg_file) if oiio_supported: - self.log.info("Trying to convert with OIIO") + self.log.debug("Trying to convert with OIIO") # If the input can read by OIIO then use OIIO method for # conversion otherwise use ffmpeg thumbnail_created = self.create_thumbnail_oiio( @@ -148,7 +148,7 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): def _already_has_thumbnail(self, repres): for repre in repres: - self.log.info("repre {}".format(repre)) + self.log.debug("repre {}".format(repre)) if repre["name"] == "thumbnail": return True return False @@ -173,20 +173,20 @@ class ExtractThumbnail(pyblish.api.InstancePlugin): return filtered_repres def create_thumbnail_oiio(self, src_path, dst_path): - self.log.info("outputting {}".format(dst_path)) + self.log.info("Extracting thumbnail {}".format(dst_path)) oiio_tool_path = get_oiio_tools_path() oiio_cmd = [ oiio_tool_path, "-a", src_path, "-o", dst_path ] - self.log.info("running: {}".format(" ".join(oiio_cmd))) + self.log.debug("running: {}".format(" ".join(oiio_cmd))) try: run_subprocess(oiio_cmd, logger=self.log) return True except Exception: self.log.warning( - "Failed to create thubmnail using oiiotool", + "Failed to create thumbnail using oiiotool", exc_info=True ) return False diff --git a/openpype/plugins/publish/extract_thumbnail_from_source.py b/openpype/plugins/publish/extract_thumbnail_from_source.py index a92f762cde..a9c95d6065 100644 --- a/openpype/plugins/publish/extract_thumbnail_from_source.py +++ b/openpype/plugins/publish/extract_thumbnail_from_source.py @@ -39,7 +39,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): self._create_context_thumbnail(instance.context) subset_name = instance.data["subset"] - self.log.info( + self.log.debug( "Processing instance with subset name {}".format(subset_name) ) thumbnail_source = instance.data.get("thumbnailSource") @@ -104,7 +104,7 @@ class ExtractThumbnailFromSource(pyblish.api.InstancePlugin): full_output_path = os.path.join(dst_staging, dst_filename) if oiio_supported: - self.log.info("Trying to convert with OIIO") + self.log.debug("Trying to convert with OIIO") # If the input can read by OIIO then use OIIO method for # conversion otherwise use ffmpeg thumbnail_created = self.create_thumbnail_oiio( diff --git a/openpype/plugins/publish/integrate.py b/openpype/plugins/publish/integrate.py index 8e984a9e97..f392cf67f7 100644 --- a/openpype/plugins/publish/integrate.py +++ b/openpype/plugins/publish/integrate.py @@ -267,7 +267,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): instance_stagingdir = instance.data.get("stagingDir") if not instance_stagingdir: - self.log.info(( + self.log.debug(( "{0} is missing reference to staging directory." " Will try to get it from representation." ).format(instance)) @@ -480,7 +480,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin): update_data ) - self.log.info("Prepared subset: {}".format(subset_name)) + self.log.debug("Prepared subset: {}".format(subset_name)) return subset_doc def prepare_version(self, instance, op_session, subset_doc, project_name): @@ -521,7 +521,9 @@ class IntegrateAsset(pyblish.api.InstancePlugin): project_name, version_doc["type"], version_doc ) - self.log.info("Prepared version: v{0:03d}".format(version_doc["name"])) + self.log.debug( + "Prepared version: v{0:03d}".format(version_doc["name"]) + ) return version_doc diff --git a/openpype/plugins/publish/integrate_legacy.py b/openpype/plugins/publish/integrate_legacy.py index c67ce62bf6..c238cca633 100644 --- a/openpype/plugins/publish/integrate_legacy.py +++ b/openpype/plugins/publish/integrate_legacy.py @@ -147,7 +147,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): def process(self, instance): if instance.data.get("processedWithNewIntegrator"): - self.log.info("Instance was already processed with new integrator") + self.log.debug( + "Instance was already processed with new integrator" + ) return for ef in self.exclude_families: @@ -274,7 +276,7 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): stagingdir = instance.data.get("stagingDir") if not stagingdir: - self.log.info(( + self.log.debug(( "{0} is missing reference to staging directory." " Will try to get it from representation." ).format(instance)) diff --git a/openpype/plugins/publish/integrate_thumbnail.py b/openpype/plugins/publish/integrate_thumbnail.py index 16cc47d432..f6d4f654f5 100644 --- a/openpype/plugins/publish/integrate_thumbnail.py +++ b/openpype/plugins/publish/integrate_thumbnail.py @@ -41,7 +41,7 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): # Filter instances which can be used for integration filtered_instance_items = self._prepare_instances(context) if not filtered_instance_items: - self.log.info( + self.log.debug( "All instances were filtered. Thumbnail integration skipped." ) return @@ -162,7 +162,7 @@ class IntegrateThumbnails(pyblish.api.ContextPlugin): # Skip instance if thumbnail path is not available for it if not thumbnail_path: - self.log.info(( + self.log.debug(( "Skipping thumbnail integration for instance \"{}\"." " Instance and context" " thumbnail paths are not available." From 22e7f9bd8497bdda99eea9e560788fdea35cb21d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 24 May 2023 10:58:40 +0200 Subject: [PATCH 19/24] Update openpype/hosts/nuke/plugins/publish/extract_thumbnail.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/plugins/publish/extract_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 2336487b37..34f4b4e8cf 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -125,7 +125,7 @@ class ExtractThumbnail(publish.Extractor): temporary_nodes.append(rnode) previous_node = rnode - if not self.reposition_nodes: + if self.reposition_nodes is None: # [deprecated] create reformat node old way reformat_node = nuke.createNode("Reformat") ref_node = self.nodes.get("Reformat", None) From 084a15ec8c2433e2584dd2c0646417cf811262cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Je=C5=BEek?= Date: Wed, 24 May 2023 10:58:47 +0200 Subject: [PATCH 20/24] Update openpype/hosts/nuke/plugins/publish/extract_thumbnail.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/nuke/plugins/publish/extract_thumbnail.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py index 34f4b4e8cf..21eefda249 100644 --- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py @@ -30,7 +30,7 @@ class ExtractThumbnail(publish.Extractor): bake_viewer_process = True bake_viewer_input_process = True nodes = {} - reposition_nodes = [] + reposition_nodes = None def process(self, instance): if instance.data.get("farm"): From e5733450e428f7f26e5bfe76fc9fe1e80b42b9f2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 24 May 2023 12:18:57 +0200 Subject: [PATCH 21/24] Global: plugins cleanup plugin will leave beauty rendered files (#4790) * OP-1066 - add expected files in Deadline into explicit cleanup Implicit cleanup doesn't work correctly, safest option is for DL submissions to mark only files that should be rendered to be deleted after successful publish. * OP-1066 - moved collecting of expected files into collector Parsing of json didn't have context implemented, it is easier to mark expected files in collector. * OP-4793 - delete full stagingDir Reviews might be extracted into staging dir, should be removed too. * Revert "OP-4793 - delete full stagingDir" This reverts commit 8b002191e1ad3b31a0cbe439ca1158946c43b049. * OP-1066 - added function to mark representation files to be cleaned up Should be applicable for all new representations, as reviews, thumbnails, to clean up their intermediate files. * OP-1066 - moved files to better file Cleaned up occurences where not necessary. * OP-1066 - removed unused import * OP-1066 - removed unnecessary setdefault * OP-1066 - removed unnecessary logging * OP-1066 - cleanup metadata json Try to cleanup parent folder if empty. --- openpype/pipeline/publish/lib.py | 19 +++++++++++++++++++ .../plugins/publish/collect_rendered_files.py | 6 ++++++ openpype/plugins/publish/extract_burnin.py | 3 +++ openpype/plugins/publish/extract_review.py | 3 +++ 4 files changed, 31 insertions(+) diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 63a856e326..b55f813b5e 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -847,3 +847,22 @@ def _validate_transient_template(project_name, template_name, anatomy): raise ValueError(("There is not set \"folder\" template in \"{}\" anatomy" # noqa " for project \"{}\"." ).format(template_name, project_name)) + + +def add_repre_files_for_cleanup(instance, repre): + """ Explicitly mark repre files to be deleted. + + Should be used on intermediate files (eg. review, thumbnails) to be + explicitly deleted. + """ + files = repre["files"] + staging_dir = repre.get("stagingDir") + if not staging_dir: + return + + if isinstance(files, str): + files = [files] + + for file_name in files: + expected_file = os.path.join(staging_dir, file_name) + instance.context.data["cleanupFullPaths"].append(expected_file) diff --git a/openpype/plugins/publish/collect_rendered_files.py b/openpype/plugins/publish/collect_rendered_files.py index 8f8d0a5eeb..6c8d1e9ca5 100644 --- a/openpype/plugins/publish/collect_rendered_files.py +++ b/openpype/plugins/publish/collect_rendered_files.py @@ -13,6 +13,7 @@ import json import pyblish.api from openpype.pipeline import legacy_io, KnownPublishError +from openpype.pipeline.publish.lib import add_repre_files_for_cleanup class CollectRenderedFiles(pyblish.api.ContextPlugin): @@ -89,6 +90,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): # now we can just add instances from json file and we are done for instance_data in data.get("instances"): + self.log.info(" - processing instance for {}".format( instance_data.get("subset"))) instance = self._context.create_instance( @@ -107,6 +109,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): self._fill_staging_dir(repre_data, anatomy) representations.append(repre_data) + add_repre_files_for_cleanup(instance, repre_data) + instance.data["representations"] = representations # add audio if in metadata data @@ -157,6 +161,8 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin): os.environ.update(session_data) session_is_set = True self._process_path(data, anatomy) + context.data["cleanupFullPaths"].append(path) + context.data["cleanupEmptyDirs"].append(os.path.dirname(path)) except Exception as e: self.log.error(e, exc_info=True) raise Exception("Error") from e diff --git a/openpype/plugins/publish/extract_burnin.py b/openpype/plugins/publish/extract_burnin.py index 10b366dcd6..6a8ae958d2 100644 --- a/openpype/plugins/publish/extract_burnin.py +++ b/openpype/plugins/publish/extract_burnin.py @@ -19,6 +19,7 @@ from openpype.lib import ( should_convert_for_ffmpeg ) from openpype.lib.profiles_filtering import filter_profiles +from openpype.pipeline.publish.lib import add_repre_files_for_cleanup class ExtractBurnin(publish.Extractor): @@ -353,6 +354,8 @@ class ExtractBurnin(publish.Extractor): # Add new representation to instance instance.data["representations"].append(new_repre) + add_repre_files_for_cleanup(instance, new_repre) + # Cleanup temp staging dir after procesisng of output definitions if do_convert: temp_dir = repre["stagingDir"] diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index a68addda7d..fa58c03df1 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -24,6 +24,7 @@ from openpype.lib.transcoding import ( get_transcode_temp_directory, ) from openpype.pipeline.publish import KnownPublishError +from openpype.pipeline.publish.lib import add_repre_files_for_cleanup class ExtractReview(pyblish.api.InstancePlugin): @@ -425,6 +426,8 @@ class ExtractReview(pyblish.api.InstancePlugin): ) instance.data["representations"].append(new_repre) + add_repre_files_for_cleanup(instance, new_repre) + def input_is_sequence(self, repre): """Deduce from representation data if input is sequence.""" # TODO GLOBAL ISSUE - Find better way how to find out if input From 8410055b2499e30c80cc7d3bd8c4d10cf76369bb Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 24 May 2023 10:21:38 +0000 Subject: [PATCH 22/24] [Automated] Release --- CHANGELOG.md | 298 ++++++++++++++++++++++++++++++++++++++++++++ openpype/version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 300 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bba6b64bfe..a33904735b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,304 @@ # Changelog +## [3.15.8](https://github.com/ynput/OpenPype/tree/3.15.8) + + +[Full Changelog](https://github.com/ynput/OpenPype/compare/3.15.7...3.15.8) + +### **🆕 New features** + + +

+Publisher: Show instances in report page #4915 + +Show publish instances in report page. Also added basic log view with logs grouped by instance. Validation error detail now have 2 colums, one with erro details second with logs. Crashed state shows fast access to report action buttons. Success will show only logs. Publish frame is shrunked automatically on publish stop. + + +___ + +
+ + +
+Fusion - Loader plugins updates #4920 + +Update to some Fusion loader plugins:The sequence loader can now load footage from the image and online family.The FBX loader can now import all formats Fusions FBX node can read.You can now import the content of another workfile into your current comp with the workfile loader. + + +___ + +
+ + +
+Fusion: deadline farm rendering #4955 + +Enabling Fusion for deadline farm rendering. + + +___ + +
+ + +
+AfterEffects: set frame range and resolution #4983 + +Frame information (frame start, duration, fps) and resolution (width and height) is applied to selected composition from Asset Management System (Ftrack or DB) automatically when published instance is created.It is also possible explicitly propagate both values from DB to selected composition by newly added menu buttons. + + +___ + +
+ + +
+Publish: Enhance automated publish plugin settings #4986 + +Added plugins option to define settings category where to look for settings of a plugin and added public helper functions to apply settings `get_plugin_settings` and `apply_plugin_settings_automatically`. + + +___ + +
+ +### **🚀 Enhancements** + + +
+Load Rig References - Change Rig to Animation in Animation instance #4877 + +We are using the template builder to build an animation scene. All the rig placeholders are imported correctly, but the automatically created animation instances retain the rig family in their names and subsets. In our example, we need animationMain instead of rigMain, because this name will be used in the following steps like lighting.Here is the result we need. I checked, and it's not a template builder problem, because even if I load a rig as a reference, the result is the same. For me, since we are in the animation instance, it makes more sense to have animation instead of rig in the name. The naming is just fine if we use create from the Openpype menu. + + +___ + +
+ + +
+Enhancement: Resolve prelaunch code refactoring and update defaults #4916 + +The main reason of this PR is wrong default settings in `openpype/settings/defaults/system_settings/applications.json` for Resolve host. The `bin` folder should not be a part of the macos and Linux `RESOLVE_PYTHON3_PATH` variable.The rest of this PR is some code cleanups for Resolve prelaunch hook to simplify further development.Also added a .gitignore for vscode workspace files. + + +___ + +
+ + +
+Unreal: 🚚 move Unreal plugin to separate repository #4980 + +To support Epic Marketplace have to move AYON Unreal integration plugins to separate repository. This is replacing current files with git submodule, so the change should be functionally without impact.New repository lives here: https://github.com/ynput/ayon-unreal-plugin + + +___ + +
+ + +
+General: Lib code cleanup #5003 + +Small cleanup in lib files in openpype. + + +___ + +
+ + +
+Allow to open with djv by extension instead of representation name #5004 + +Filter open in djv action by extension instead of representation. + + +___ + +
+ + +
+DJV open action `extensions` as `set` #5005 + +Change `extensions` attribute to `set`. + + +___ + +
+ + +
+Nuke: extract thumbnail with multiple reposition nodes #5011 + +Added support for multiple reposition nodes. + + +___ + +
+ + +
+Enhancement: Improve logging levels and messages for artist facing publish reports #5018 + +Tweak the logging levels and messages to try and only show those logs that an artist should see and could understand. Move anything that's slightly more involved into a "debug" message instead. + + +___ + +
+ +### **🐛 Bug fixes** + + +
+Bugfix/frame variable fix #4978 + +Renamed variables to match OpenPype terminology to reduce confusion and add consistency. +___ + +
+ + +
+Global: plugins cleanup plugin will leave beauty rendered files #4790 + +Attempt to mark more files to be cleaned up explicitly in intermediate `renders` folder in work area for farm jobs. + + +___ + +
+ + +
+Fix: Download last workfile doesn't work if not already downloaded #4942 + +Some optimization condition is messing with the feature: if the published workfile is not already downloaded, it won't download it... + + +___ + +
+ + +
+Unreal: Fix transform when loading layout to match existing assets #4972 + +Fixed transform when loading layout to match existing assets. + + +___ + +
+ + +
+fix the bug of fbx loaders in Max #4977 + +bug fix of fbx loaders for not being able to parent to the CON instances while importing cameras(and models) which is published from other DCCs such as Maya. + + +___ + +
+ + +
+AfterEffects: allow returning stub with not saved workfile #4984 + +Allows to use Workfile app to Save first empty workfile. + + +___ + +
+ + +
+Blender: Fix Alembic loading #4985 + +Fixed problem occurring when trying to load an Alembic model in Blender. + + +___ + +
+ + +
+Unreal: Addon Py2 compatibility #4994 + +Fixed Python 2 compatibility of unreal addon. + + +___ + +
+ + +
+Nuke: fixed missing files key in representation #4999 + +Issue with missing keys once rendering target set to existing frames is fixed. Instance has to be evaluated in validation for missing files. + + +___ + +
+ + +
+Unreal: Fix the frame range when loading camera #5002 + +The keyframes of the camera, when loaded, were not using the correct frame range. + + +___ + +
+ + +
+Fusion: fixing frame range targeting #5013 + +Frame range targeting at Rendering instances is now following configured options. + + +___ + +
+ + +
+Deadline: fix selection from multiple webservices #5015 + +Multiple different DL webservice could be configured. First they must by configured in System Settings., then they could be configured per project in `project_settings/deadline/deadline_servers`.Only single webservice could be a target of publish though. + + +___ + +
+ +### **Merged pull requests** + + +
+3dsmax: Refactored publish plugins to use proper implementation of pymxs #4988 + + +___ + +
+ + + + ## [3.15.7](https://github.com/ynput/OpenPype/tree/3.15.7) diff --git a/openpype/version.py b/openpype/version.py index 3d7f64b991..342bbfc85a 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.8-nightly.3" +__version__ = "3.15.8" diff --git a/pyproject.toml b/pyproject.toml index 190ecb9329..a72a3d66d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.15.7" # OpenPype +version = "3.15.8" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" From 4647e39142da5c0a1a5cc62844a38c105c166dc9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 24 May 2023 10:22:35 +0000 Subject: [PATCH 23/24] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a9f1f1cc02..4d7d06a2c8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.15.8 - 3.15.8-nightly.3 - 3.15.8-nightly.2 - 3.15.8-nightly.1 @@ -134,7 +135,6 @@ body: - 3.14.2-nightly.4 - 3.14.2-nightly.3 - 3.14.2-nightly.2 - - 3.14.2-nightly.1 validations: required: true - type: dropdown From 7e692ad5acc284540f1d29672ea86386b0f22468 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Wed, 24 May 2023 14:01:50 +0200 Subject: [PATCH 24/24] added option to nest settings templates --- openpype/settings/entities/lib.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py index 1c7dc9bed0..93abc27b0e 100644 --- a/openpype/settings/entities/lib.py +++ b/openpype/settings/entities/lib.py @@ -323,7 +323,10 @@ class SchemasHub: filled_template = self._fill_template( schema_data, template_def ) - return filled_template + new_template_def = [] + for item in filled_template: + new_template_def.extend(self.resolve_schema_data(item)) + return new_template_def def create_schema_object(self, schema_data, *args, **kwargs): """Create entity for passed schema data.